Self-Hosting
Self-hosting GiftWrapt is meant to be boring: one published image, one compose file, one .env. The base stack is app + Postgres + S3-compatible storage. Cron, email, and MCP are addons you opt into by picking a richer compose shape.
Each shape lives at docker/compose.selfhost-{backend}-{shape}.yaml. Storage backend is garage or rustfs. Shape is one of:
| Shape | What it adds on top of app + Postgres + storage |
|---|---|
minimal | Nothing. The smallest path. |
cron | + cron sidecar (always-on) |
full | + cron + MCP. MCP is profile-gated (--profile mcp). |
traefik | + Traefik reverse-proxy. App ports closed; ingress through the proxy. |
This page walks the base deployment first, then each addon in its own section. Read them in order if you’re starting from scratch.
Prerequisites
Section titled “Prerequisites”- Docker 24+ with Compose v2
- A host reachable over HTTP(S) at the URL you’ll use as
BETTER_AUTH_URL - The published image:
ghcr.io/shawnphoffman/giftwrapt:latest(or pin a tag like:vX.Y.Z)
Base Deployment
Section titled “Base Deployment”The base stack is the app container plus a Postgres database. Migrations run automatically on container startup, so there’s nothing to do by hand.
1. Clone and Configure
Section titled “1. Clone and Configure”git clone https://github.com/shawnphoffman/giftwrapt.gitcd giftwraptcp env.example docker/.env$EDITOR docker/.envAt minimum, set these in docker/.env:
| Var | Value |
|---|---|
POSTGRES_PASSWORD | Any strong random string |
BETTER_AUTH_SECRET | openssl rand -hex 32 |
BETTER_AUTH_URL | Public origin, e.g. https://giftwrapt.example.com |
If you’re picking a shape with the cron sidecar (-cron or -full), also set CRON_SECRET to openssl rand -base64 48 | tr -d '/+=' | head -c 48. The cron container fatals at startup without it.
2. Bring Up the Stack
Section titled “2. Bring Up the Stack”docker 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.
3. Done
Section titled “3. Done”That’s it. You’re running with image uploads via the bundled Garage sidecar. If you need scheduled jobs, email, or the MCP sidecar, keep reading and swap to a richer shape.
Addon: Storage
Section titled “Addon: Storage”The bundled compose files ship one of two storage sidecars: Garage or RustFS. Both are S3-compatible; both are managed by the app’s entrypoint, which creates the bucket and imports keys on first boot. Pick one - they’re functionally equivalent for GiftWrapt’s needs.
Garage is a small Rust S3 server with admin-API-driven bootstrap. The Garage compose file is the default in getting-started.
# Add to docker/.envGARAGE_RPC_SECRET=<openssl rand -hex 32>GARAGE_ADMIN_TOKEN=<openssl rand -hex 32>INIT_GARAGE=true
# Standard storage vars (Garage enforces specific formats):STORAGE_ENDPOINT=http://garage:3900STORAGE_REGION=garageSTORAGE_BUCKET=giftwraptSTORAGE_ACCESS_KEY_ID=GK<24 hex chars> # printf 'GK%s' "$(openssl rand -hex 12)"STORAGE_SECRET_ACCESS_KEY=<64 hex chars> # openssl rand -hex 32STORAGE_FORCE_PATH_STYLE=truedocker compose -f docker/compose.selfhost-garage-minimal.yaml up -dRustFS is a MinIO-compatible drop-in. Credentials are arbitrary strings, bootstrap is a single CreateBucket.
# Add to docker/.envINIT_RUSTFS=trueSTORAGE_ENDPOINT=http://rustfs:9000STORAGE_REGION=us-east-1STORAGE_BUCKET=giftwraptSTORAGE_ACCESS_KEY_ID=<any string>STORAGE_SECRET_ACCESS_KEY=<any string>STORAGE_FORCE_PATH_STYLE=truedocker compose -f docker/compose.selfhost-rustfs-minimal.yaml up -dAlready have AWS S3, Cloudflare R2, or Supabase Storage? Use either compose file with INIT_GARAGE=false (or INIT_RUSTFS=false) and point STORAGE_* at your bucket. See Storage for per-provider recipes.
If you’re not sure: start with Garage. It’s the path the bundled compose was designed around. If you outgrow it or want geo-distribution, swap to RustFS or an external bucket without app changes.
Addon: Scheduled Jobs (Cron)
Section titled “Addon: Scheduled Jobs (Cron)”GiftWrapt has five scheduled endpoints under /api/cron/*:
/auto-archive- triggers the recipient-side reveal flow/birthday-emails- day-of greetings, post-event summaries, reminders/cleanup-verification- retention sweeps/intelligence-recommendations- AI suggestion runs/item-scrape-queue- bulk-import drain
The -cron and -full compose shapes bundle a cron sidecar - a tiny alpine container running busybox crond that curls each endpoint on a daily UTC schedule. No setup beyond CRON_SECRET. The sidecar fatals at startup if CRON_SECRET is unset, so the stack boot-loops until you set it. If you started with -minimal.yaml, swap to -cron.yaml (same env, same volumes) to opt in.
To customize schedules: edit docker/cron-entrypoint.sh and recreate the sidecar.
docker compose -f docker/compose.selfhost-garage-cron.yaml up -d --force-recreate cronPer-user advisory locks make higher cadences safe. See Cron and scheduling for the full reference.
Addon: Email
Section titled “Addon: Email”Email is fully optional. The app boots and runs without it. To enable, sign up for Resend and add to docker/.env:
RESEND_API_KEY=re_...RESEND_FROM_EMAIL=giftwrapt@yourdomain.exampleRESEND_FROM_NAME=GiftWrapt # optionalRESEND_BCC_ADDRESS= # optionalRestart the app. The admin Settings page surfaces the email-family toggles once these are set; before that, those toggles are hidden.
See Emails for which features each toggle controls.
Addon: AI Features
Section titled “Addon: AI Features”GiftWrapt’s optional URL scraping AI extractor, title cleaner, and Suggestions all share one LLM provider config. Configure it from Admin > AI in the UI (recommended), or pin it via env vars:
AI_PROVIDER_TYPE=openai # openai | anthropic | openai-compatibleAI_API_KEY=sk-...AI_MODEL=gpt-4o-miniAI_BASE_URL= # required for openai-compatibleEvery AI feature ships off by default. See AI provider for the full story.
Addon: MCP
Section titled “Addon: MCP”The -full compose shape defines a Model Context Protocol sidecar (profile-gated) so you can talk to your wishlist from Claude Desktop / Claude Code. It’s a thin shim over the mobile API. Switch to -full.yaml and pass --profile mcp to bring it up:
docker compose -f docker/compose.selfhost-garage-full.yaml --profile mcp up -dYou’ll need to mint an API key from /settings/devices first. See the giftwrapt-mcp repo for client wiring.
Multi-Origin and LAN Access
Section titled “Multi-Origin and LAN Access”By default the app trusts exactly one origin (BETTER_AUTH_URL). Requests from any other origin are rejected with “Invalid origin.” Two env vars cover the common cases:
# Comma-separated extra origins. BETTER_AUTH_URL is always trusted.TRUSTED_ORIGINS=http://192.168.1.137:3888,http://giftwrapt.local:3888
# Required if any trusted origin is plain HTTP. Drops the Secure flag on# auth cookies, which weakens the HTTPS path too. Leave unset unless you# actually need HTTP origin login to work.INSECURE_COOKIES=trueThe server refuses to boot with both INSECURE_COOKIES=true and an HTTPS BETTER_AUTH_URL.
Reverse Proxy (Traefik, Caddy, nginx)
Section titled “Reverse Proxy (Traefik, Caddy, nginx)”If a proxy terminates TLS in front of the container:
- Point
BETTER_AUTH_URLat the public HTTPS URL. - The proxy must forward the
Hostheader andX-Forwarded-Proto: https(Traefik and Caddy do this by default).
Admin CLI
Section titled “Admin CLI”If the first-user auto-promotion didn’t fire (you’re recovering from a deleted admin), you can mint one from the container:
# Use whichever compose shape you brought the stack up with.docker compose -f docker/compose.selfhost-garage-minimal.yaml exec app \ node .output/scripts/admin-create.mjs \ --email=admin@example.com --password=SecurePass123 --name=AdminOther bundled CLIs under .output/scripts/:
admin-reset-password.mjs- reset a user’s password and revoke their sessionsmigrate.mjs- run pending migrations manuallyseed.mjs- destructive, do not run in production
Updating
Section titled “Updating”# Substitute the shape you actually deployed.docker compose -f docker/compose.selfhost-garage-minimal.yaml pulldocker compose -f docker/compose.selfhost-garage-minimal.yaml up -dMigrations run on the first container start of the new image. Always back up Postgres and your storage volume before pulling a new major version.
Backups
Section titled “Backups”Two stateful volumes:
postgres_data- everything except imagesgarage_data(orrustfs_data) - all images
A nightly pg_dump plus an aws s3 sync against your storage bucket is enough.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
| ”Invalid origin” on login | BETTER_AUTH_URL doesn’t match the browser’s origin. See multi-origin. |
| Login appears to succeed but bounces to sign-in | Cookies aren’t being stored. Plain HTTP needs INSECURE_COOKIES=true; HTTPS needs the proxy to forward X-Forwarded-Proto. |
| Storage init fails on first boot | Check docker compose logs app. Garage / RustFS bootstrap runs from the app entrypoint and reports failures there. |
| Migrations fail | Usually Postgres connectivity. The entrypoint waits for pg_isready but not forever. |
| Cron sidecar restart loops | CRON_SECRET is unset. |
The Full Compose File
Section titled “The Full Compose File”For reference, here’s the -full shape with every addon wired up - app, Postgres, Garage storage, cron, and the profile-gated MCP sidecar. The canonical version lives at docker/compose.selfhost-garage-full.yaml.
# Synced from docker/compose.selfhost-garage-full.yaml# =============================================================================# Self-Hosted (Garage backend, full (cron + profile-gated MCP))# =============================================================================# Includes the cron sidecar always-on AND the MCP sidecar profile-gated. Pass `--profile mcp` to bring MCP up.## 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-full.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
# Cron sidecar. Reads CRON_SECRET from the env file and curls the app's # /api/cron/* endpoints on a daily schedule (mirrors vercel.json). To # disable, run with `--profile no-cron` (the default profile is empty so # it always runs, but compose ignores undefined services on stop). To # change schedules, edit ./cron-entrypoint.sh and recreate the service. # Higher cadences are safe: per-user advisory locks de-duplicate work. cron: image: alpine:3.20 env_file: .env environment: # Talks to the app over the compose network. Override CRON_APP_URL if # the app service is named differently or you want crons to hit a # public URL (slower, but exercises the same code path as Vercel). CRON_APP_URL: ${CRON_APP_URL:-http://app:3001} TZ: ${TZ:-UTC} volumes: - ./cron-entrypoint.sh:/cron-entrypoint.sh:ro command: ['sh', '/cron-entrypoint.sh'] depends_on: app: condition: service_started restart: unless-stopped
# Model Context Protocol sidecar (opt-in via `--profile mcp`). Lets you # interact with your wishlist via Claude Desktop / Code / Web. Talks to # the app over the compose network using a giftwrapt apiKey (mint one # at /settings/devices). The image is published from the # shawnphoffman/giftwrapt-mcp repo. The service stays defined in # *-full.yaml so you can enable it on demand without editing the file. mcp: image: ${MCP_IMAGE:-ghcr.io/shawnphoffman/giftwrapt-mcp:latest} profiles: ['mcp'] environment: GIFTWRAPT_BASE_URL: http://app:3001 # Single-tenant pin: leave unset for multi-user pass-through (each # client sends its own Bearer header). See the giftwrapt-mcp README. GIFTWRAPT_API_KEY: ${GIFTWRAPT_MCP_API_KEY:-} # Shared-secret port gate; only meaningful when GIFTWRAPT_API_KEY is set. MCP_BEARER_TOKEN: ${MCP_BEARER_TOKEN:-} MCP_PORT: 8787 MCP_LOG_LEVEL: ${MCP_LOG_LEVEL:-info} ports: - '${MCP_PORT:-8787}:8787' depends_on: app: condition: service_started 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:The RustFS equivalent lives at docker/compose.selfhost-rustfs-full.yaml. The full matrix (minimal, cron, full, traefik × garage, rustfs) is in the docker/ directory.