Skip to content

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:

ShapeWhat it adds on top of app + Postgres + storage
minimalNothing. 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.

  • 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)

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.

Terminal window
git clone https://github.com/shawnphoffman/giftwrapt.git
cd giftwrapt
cp env.example docker/.env
$EDITOR docker/.env

At minimum, set these in docker/.env:

VarValue
POSTGRES_PASSWORDAny strong random string
BETTER_AUTH_SECRETopenssl rand -hex 32
BETTER_AUTH_URLPublic 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.

Terminal window
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.

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.

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.

Terminal window
# Add to docker/.env
GARAGE_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:3900
STORAGE_REGION=garage
STORAGE_BUCKET=giftwrapt
STORAGE_ACCESS_KEY_ID=GK<24 hex chars> # printf 'GK%s' "$(openssl rand -hex 12)"
STORAGE_SECRET_ACCESS_KEY=<64 hex chars> # openssl rand -hex 32
STORAGE_FORCE_PATH_STYLE=true
Terminal window
docker compose -f docker/compose.selfhost-garage-minimal.yaml up -d

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.

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.

Terminal window
docker compose -f docker/compose.selfhost-garage-cron.yaml up -d --force-recreate cron

Per-user advisory locks make higher cadences safe. See Cron and scheduling for the full reference.

Email is fully optional. The app boots and runs without it. To enable, sign up for Resend and add to docker/.env:

Terminal window
RESEND_API_KEY=re_...
RESEND_FROM_EMAIL=giftwrapt@yourdomain.example
RESEND_FROM_NAME=GiftWrapt # optional
RESEND_BCC_ADDRESS= # optional

Restart 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.

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:

Terminal window
AI_PROVIDER_TYPE=openai # openai | anthropic | openai-compatible
AI_API_KEY=sk-...
AI_MODEL=gpt-4o-mini
AI_BASE_URL= # required for openai-compatible

Every AI feature ships off by default. See AI provider for the full story.

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:

Terminal window
docker compose -f docker/compose.selfhost-garage-full.yaml --profile mcp up -d

You’ll need to mint an API key from /settings/devices first. See the giftwrapt-mcp repo for client wiring.

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:

Terminal window
# 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=true

The server refuses to boot with both INSECURE_COOKIES=true and an HTTPS BETTER_AUTH_URL.

If a proxy terminates TLS in front of the container:

  • Point BETTER_AUTH_URL at the public HTTPS URL.
  • The proxy must forward the Host header and X-Forwarded-Proto: https (Traefik and Caddy do this by default).

If the first-user auto-promotion didn’t fire (you’re recovering from a deleted admin), you can mint one from the container:

Terminal window
# 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=Admin

Other bundled CLIs under .output/scripts/:

  • admin-reset-password.mjs - reset a user’s password and revoke their sessions
  • migrate.mjs - run pending migrations manually
  • seed.mjs - destructive, do not run in production
Terminal window
# Substitute the shape you actually deployed.
docker compose -f docker/compose.selfhost-garage-minimal.yaml pull
docker compose -f docker/compose.selfhost-garage-minimal.yaml up -d

Migrations run on the first container start of the new image. Always back up Postgres and your storage volume before pulling a new major version.

Two stateful volumes:

  • postgres_data - everything except images
  • garage_data (or rustfs_data) - all images

A nightly pg_dump plus an aws s3 sync against your storage bucket is enough.

SymptomLikely cause
”Invalid origin” on loginBETTER_AUTH_URL doesn’t match the browser’s origin. See multi-origin.
Login appears to succeed but bounces to sign-inCookies aren’t being stored. Plain HTTP needs INSECURE_COOKIES=true; HTTPS needs the proxy to forward X-Forwarded-Proto.
Storage init fails on first bootCheck docker compose logs app. Garage / RustFS bootstrap runs from the app entrypoint and reports failures there.
Migrations failUsually Postgres connectivity. The entrypoint waits for pg_isready but not forever.
Cron sidecar restart loopsCRON_SECRET is unset.

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.