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:
Self-Host With Docker One docker compose up. Bundled Postgres, optional bundled storage. Runs anywhere.
Hosted (Vercel + Supabase) Zero-ops. Vercel for the app, Supabase for Postgres and S3 storage.
The Five-Minute Version
Section titled “The Five-Minute Version”Want to kick the tires before reading any more? This boots a complete stack locally with bundled Postgres + storage:
git clone https://github.com/shawnphoffman/giftwrapt.gitcd giftwraptcp env.example docker/.env$EDITOR docker/.env # set POSTGRES_PASSWORD, BETTER_AUTH_SECRET, BETTER_AUTH_URLdocker compose -f docker/compose.selfhost-garage-minimal.yaml up -dOpen BETTER_AUTH_URL in a browser. The first user to sign up is auto-promoted to admin.
Bare-Minimum Compose
Section titled “Bare-Minimum Compose”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.
Self-Hosting The full compose, plus addons for storage, cron, email, and MCP.
Hosted (Vercel + Supabase) The tried-and-true managed path.
Environment Variables Every var, grouped and commented.
Screenshots See what you're getting.