Self-hosting
GiftWrapt ships a published Docker image and two compose files - one per bundled storage backend. Pick whichever feels less weird to you; the app behaves identically either way.
| File | Storage sidecar | Why pick it |
|---|---|---|
docker/compose.selfhost-garage.yaml | Garage | Smaller image, geo-distribution if you ever want it. Bootstraps via Garage’s admin HTTP API. |
docker/compose.selfhost-rustfs.yaml | RustFS | MinIO-compatible drop-in. Credentials are arbitrary strings, bootstrap is a single CreateBucket. |
Already have an external bucket (AWS S3, Cloudflare R2, Supabase, etc.)? Use either compose file with INIT_GARAGE=false / INIT_RUSTFS=false and point STORAGE_* at your bucket. See storage.md.
Prerequisites
Section titled “Prerequisites”- Docker 24+ with Compose v2
- A host reachable over HTTP(S) at the URL you’ll set as
BETTER_AUTH_URL
The image is published by CI on every release tag:
ghcr.io/shawnphoffman/giftwrapt:latestghcr.io/shawnphoffman/giftwrapt:vX.Y.ZPin a specific version in production by setting APP_IMAGE in your .env.
Quick start (Garage)
Section titled “Quick start (Garage)”git clone https://github.com/shawnphoffman/wish-lists-2.0.git giftwraptcd giftwraptcp env.example docker/.env$EDITOR docker/.envdocker compose -f docker/compose.selfhost-garage.yaml up -dOpen BETTER_AUTH_URL in a browser and sign up. The first user to register is auto-promoted to admin.
Quick start (RustFS)
Section titled “Quick start (RustFS)”cp env.example docker/.env$EDITOR docker/.env # set STORAGE_ENDPOINT=http://rustfs:9000, INIT_RUSTFS=truedocker compose -f docker/compose.selfhost-rustfs.yaml up -dRequired env vars
Section titled “Required env vars”The annotated reference is env.example. At a minimum:
| Var | Notes |
|---|---|
POSTGRES_PASSWORD | Used by both the postgres service and the auto-built DATABASE_URL. |
BETTER_AUTH_SECRET | Long random string. openssl rand -hex 32 is fine. |
BETTER_AUTH_URL | The public origin clients reach the app from. Drives auth, email links, cookie scope. |
SERVER_URL | Usually the same as BETTER_AUTH_URL. |
STORAGE_* | See storage.md. Required - the server refuses to boot without storage configured. |
Garage-specific: GARAGE_RPC_SECRET and GARAGE_ADMIN_TOKEN (each openssl rand -hex 32).
Database migrations
Section titled “Database migrations”Migrations run automatically on container startup. The image bundles the SQL files and a standalone migrate CLI - no need to run anything by hand.
If you want to run migrations manually (e.g. one-shot from a CI job):
docker compose -f docker/compose.selfhost-garage.yaml exec app \ node .output/scripts/migrate.mjsAdmin CLI
Section titled “Admin CLI”If the first-user auto-promotion didn’t fire (you’re recovering from a deleted account, etc.):
docker compose -f docker/compose.selfhost-garage.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 sessionsseed.mjs- destructive, do not run in productionmigrate.mjs- run pending migrations
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_URLandSERVER_URLat the public HTTPS URL (e.g.https://giftwrapt.example.com). Better-auth uses these to validate origins, derive the cookieSecureflag, and build links in outbound emails. - The proxy must forward the
Hostheader andX-Forwarded-Proto: https(Traefik and Caddy do this by default). VITE_SERVER_URLis baked at image build time. Leave it unset when using the published image; the client falls back towindow.location.origin.
Multi-origin / LAN access
Section titled “Multi-origin / 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:
TRUSTED_ORIGINS (comma-separated) adds extra origins to the auth allow-list. Use this when the same instance is reachable via multiple hostnames (HTTPS via reverse proxy + LAN IP, etc.):
BETTER_AUTH_URL=https://giftwrapt.example.comTRUSTED_ORIGINS=http://192.168.1.137:3888,http://giftwrapt.local:3888INSECURE_COOKIES=true drops the Secure flag on auth cookies. Required if any trusted origin is plain HTTP - browsers refuse to store Secure cookies set from an HTTP page, so login otherwise succeeds with no session cookie. This weakens session security on the HTTPS path too (cookies become sniffable on the LAN), so leave it unset unless HTTP origin login is something you actually need.
Optional: transactional email
Section titled “Optional: transactional email”Email is powered by Resend and is fully optional. The app boots and runs without it. To enable:
RESEND_API_KEY(required)RESEND_FROM_EMAIL(required)RESEND_FROM_NAME(optional)RESEND_BCC_ADDRESS(optional)
When email is unconfigured:
- Comment notifications to list owners are skipped
- Day-of birthday greetings and the post-birthday gift summary cron are skipped
- The admin “send test email” button is hidden
- Birthday/Christmas/comment email toggles in admin settings are hidden
Cron and background jobs
Section titled “Cron and background jobs”The shipped compose files
(compose.selfhost-garage.yaml
and compose.selfhost-rustfs.yaml)
include a cron sidecar by default. It runs busybox crond
against a crontab generated at boot from
docker/cron-entrypoint.sh and hits the
five /api/cron/* endpoints (auto-archive, birthday emails,
verification cleanup, intelligence recommendations, item-scrape queue)
on a daily UTC schedule.
The sidecar requires CRON_SECRET to be set in docker/.env; if
unset it fatals at startup and the container restarts in a loop with a
clear error message. Generate a value with
openssl rand -base64 48 | tr -d '/+=' | head -c 48 and add it to the
env file before bringing the stack up. Operators can verify cron is
firing by visiting /admin/scheduling once signed in.
To customize schedules: edit docker/cron-entrypoint.sh and recreate
the service with
docker compose -f docker/compose.selfhost-garage.yaml up -d --force-recreate cron.
Higher cadences are safe (per-user advisory locks de-duplicate work).
Per-platform alternatives (system crontab, long-lived worker service, hitting the endpoints from a separate host) and the full inventory live in .notes/cron-and-jobs.md.
Updating
Section titled “Updating”docker compose -f docker/compose.selfhost-garage.yaml pulldocker compose -f docker/compose.selfhost-garage.yaml up -dMigrations run on the first container start of the new image. Always back up Postgres and your storage bucket before pulling a new major version.
Backups
Section titled “Backups”Two stateful volumes:
postgres_data- everything except imagesgarage_data/rustfs_data- all images
A nightly pg_dump plus an aws s3 sync against your storage bucket is enough. See storage.md for backend-specific notes.
[!IMPORTANT] Encrypt your database backups at rest. better-auth stores session tokens in the
sessiontable as plaintext (its design - the token IS the cookie). Anyone who can read apg_dumpcan hijack every active session until those sessions expire. Same goes for theverificationtable (password-reset tokens). Use full-disk encryption (LUKS, EBS encryption, etc.) on the volume that holds your dumps, or pipe the dump throughgpg/agebefore writing it. Theapp_settings.scrapeProvidersrows are already AES-256-GCM encrypted at rest usingBETTER_AUTH_SECRETas the master key, but everything else is in the clear. See sec-review L4.
Troubleshooting
Section titled “Troubleshooting”- “Invalid origin” on login:
BETTER_AUTH_URLdoesn’t match the origin the browser is using. See multi-origin above. - Login appears to succeed but I’m bounced back to the login page: cookies aren’t being stored. If you’re on plain HTTP, set
INSECURE_COOKIES=true. If you’re on HTTPS, check that the proxy forwardsX-Forwarded-Proto. - Storage init fails on first boot: see the storage troubleshooting section in storage.md.
- Migrations fail: check
docker compose logs app. Usually a connectivity issue to Postgres - the entrypoint waits forpg_isreadybut doesn’t wait forever.