Skip to content

Get Started

GiftWrapt runs as a single Docker container plus a Postgres database. Anything S3-compatible handles image storage. There are two supported deployment shapes:

Want to kick the tires before reading any more? This boots a complete stack locally with bundled Postgres + storage:

Terminal window
git clone https://github.com/shawnphoffman/giftwrapt.git
cd giftwrapt
cp env.example docker/.env
$EDITOR docker/.env # set POSTGRES_PASSWORD, BETTER_AUTH_SECRET, BETTER_AUTH_URL
docker compose -f docker/compose.selfhost-garage-minimal.yaml up -d

Open BETTER_AUTH_URL in a browser. The first user to sign up is auto-promoted to admin.

Here’s the canonical bare-minimum shape: app + Postgres + Garage storage. No cron sidecar, no MCP. Pulled live from the main repo so this stays in sync with what docker compose -f actually consumes.

# Synced from docker/compose.selfhost-garage-minimal.yaml
# =============================================================================
# Self-Hosted (Garage backend, bare minimum (app + DB + storage))
# =============================================================================
# No optional sidecars - add an MCP or cron block manually if you need one, or grab a richer shape.
#
# Quick start:
# 1. cp env.example docker/.env
# 2. Edit docker/.env - at minimum set POSTGRES_PASSWORD, BETTER_AUTH_SECRET,
# and BETTER_AUTH_URL (the public URL you'll reach the app from).
# 3. Set GARAGE_RPC_SECRET and GARAGE_ADMIN_TOKEN (see .env.example).
# The app bootstraps the bucket and access key on first boot.
# 4. docker compose -f docker/compose.selfhost-garage-minimal.yaml up -d
# 5. Open BETTER_AUTH_URL in your browser and sign up.
#
# Generated by `pnpm generate:compose` from scripts/compose-registry/.
# Add or change features in scripts/compose-registry/features/<id>.ts.
services:
app:
image: ${APP_IMAGE:-ghcr.io/shawnphoffman/giftwrapt:latest}
env_file: .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-giftwrapt}
NODE_ENV: production
# Structured logging. LOG_LEVEL can be flipped without a rebuild; valid
# values: fatal|error|warn|info|debug|trace|silent. LOG_PRETTY forces
# human-readable output even in prod (otherwise NDJSON is emitted).
LOG_LEVEL: ${LOG_LEVEL:-info}
LOG_PRETTY: ${LOG_PRETTY:-false}
# When the bundled Garage sidecar is part of the stack, the app
# entrypoint bootstraps it via Garage's admin HTTP API (layout assign,
# bucket create, key import, permission grant) before running DB
# migrations. Idempotent on re-run. Set to "false" (or omit) if you're
# pointing STORAGE_* at an external S3-compatible bucket.
INIT_GARAGE: ${INIT_GARAGE:-true}
ports:
- '${APP_PORT:-3001}:3001'
depends_on:
postgres:
condition: service_healthy
# service_started, not service_healthy: the container's healthcheck
# (`garage status`) returns non-zero on a fresh node with no layout
# assigned, which is the exact state on cold boot before INIT_GARAGE
# has had a chance to run. INIT_GARAGE itself runs from this app's
# entrypoint and polls Garage's admin /health endpoint with a 60s
# deadline (see scripts/init-garage.ts), so daemon-readiness is
# already gated app-side; the docker dep just needs the process up.
garage:
condition: service_started
# Health check is built into the image via HEALTHCHECK instruction.
restart: unless-stopped
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-giftwrapt}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-giftwrapt}']
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
garage:
image: dxflrs/garage:v1.0.1
environment:
GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET}
GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN}
volumes:
- garage_meta:/var/lib/garage/meta
- garage_data:/var/lib/garage/data
- ./garage.toml:/etc/garage.toml:ro
# Intentionally no host port: Garage is only reachable on the compose
# network. The app serves images via /api/files/* so clients never need
# direct bucket access. If you want direct S3 URLs (faster, offloads
# bandwidth), add a reverse-proxy rule for 3900 and set STORAGE_PUBLIC_URL
# in .env - see https://giftwrapt.dev/configuration/storage/.
healthcheck:
test: ['CMD', '/garage', 'status']
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
volumes:
postgres_data:
garage_meta:
garage_data:

To layer on cron, MCP, or the Traefik reverse-proxy variant, see Self-hosting for the full matrix of compose shapes.