A generic, repeatable guide for deploying the Giftamizer backend
(Giftamizer-Supabase)
and frontend (Giftamizer) to a
server via Docker Compose. Replace the placeholders below
(<SERVER_USER>, <SERVER_IP>, <FRONTEND_DOMAIN>, <API_DOMAIN>,
<DOCKERHUB_NAMESPACE>) with your own values.
- Frontend domain (
<FRONTEND_DOMAIN>, e.g.app.example.com) → forwarded by a reverse proxy to the frontend container's published port (8081in this guide). - Backend/API domain (
<API_DOMAIN>, e.g.api.example.com) → forwarded by the same reverse proxy to Kong's published port (8000). - This guide assumes the server itself only serves plain HTTP, and TLS termination + public DNS are handled by a reverse proxy in front of it (could be on the same box or elsewhere — nginx, Caddy, Cloudflare Tunnel, etc.). Point that proxy's upstreams at the two ports above once DNS/proxy config is ready. If you don't have an external proxy and want this box to terminate TLS itself, add one (e.g. Caddy) in front of these two ports instead of exposing them directly.
/giftamizer/
├── backend/ # git clone of ExplodedCode/Giftamizer-Supabase (Supabase stack)
│ ├── .env # generated secrets + prod config, chmod 600, NOT in git
│ └── ...
└── frontend/
└── docker-compose.yml # runs the pre-built frontend image
/giftamizer is just a convention used here — pick any path, just stay
consistent between this doc and your actual install.
- Docker + Docker Compose v2 on the target server
- Docker Hub (or another registry) login on both your dev machine (to build and push the frontend image) and the server (to pull it), if you're building the frontend image elsewhere and shipping it as a prebuilt image
- A user with passwordless (or at least scriptable)
sudoon the server, for creating/giftamizerand setting ownership
ssh <SERVER_USER>@<SERVER_IP>
sudo mkdir -p /giftamizer && sudo chown -R <SERVER_USER>:<SERVER_USER> /giftamizer
cd /giftamizer
git clone https://github.com/ExplodedCode/Giftamizer-Supabase.git backend
cd backend
cp .env.example .env
chmod 600 .envEdit .env. At minimum, generate fresh secrets — never ship a production
instance with the demo values from .env.example:
| Variable | How to generate |
|---|---|
POSTGRES_PASSWORD |
openssl rand -hex 24 (hex only — this value gets embedded in postgres:// connection strings elsewhere in the compose file, so avoid /, +, @ etc. that base64 could produce) |
JWT_SECRET |
openssl rand -hex 32 |
ANON_KEY / SERVICE_ROLE_KEY |
derived from JWT_SECRET — run ./utils/generate-keys.sh (or .\utils\generate-keys.ps1) and paste its output in |
DASHBOARD_USERNAME / DASHBOARD_PASSWORD |
pick a username; openssl rand -hex 16 for the password |
SECRET_KEY_BASE |
openssl rand -hex 64 |
VAULT_ENC_KEY |
openssl rand -hex 16 (32+ chars required) |
PG_META_CRYPTO_KEY |
openssl rand -hex 16 (32+ chars required) |
POOLER_TENANT_ID |
openssl rand -hex 8 (just needs to be unique, not secret) |
RESTIC_PASSWORD |
openssl rand -base64 32 — only needed if you're enabling offsite backup (see below), but cheap to generate now regardless |
Then set the app-facing config:
SITE_URL=https://<FRONTEND_DOMAIN>
ADDITIONAL_REDIRECT_URLS=https://<FRONTEND_DOMAIN>/**
API_EXTERNAL_URL=https://<API_DOMAIN>
SUPABASE_PUBLIC_URL=https://<API_DOMAIN>
COMPOSE_PROFILES=backup
(COMPOSE_PROFILES=backup turns on local pg_dump backups — see "Backups"
below for what else you can add here.)
Watch out for: the db-backup service's image tag
(prodrigestivill/postgres-backup-local:<major>) must match the major
version of the db service's Postgres image (currently 17). If a future
upstream bump moves db to Postgres 18 without also bumping db-backup's
tag, pg_dump will refuse to run against a newer server and every backup
will silently fail — check docker compose logs db-backup after any
Postgres version bump to make sure backups are actually succeeding, not
just that the container is running.
Leave everything else (GitHub token, SMTP, S3, OAuth, etc.) at the
.env.example defaults unless you're using those features — see "Optional
extras" below.
cd /giftamizer/backend
chmod +x run.sh reset.sh utils/*.sh scripts/*.sh
./run.sh prod up # core stack + whatever's in COMPOSE_PROFILES
docker compose up -d urlmetadata # see note belowWhy urlmetadata (if you want it) is started separately: urlmetadata
and smtp2graph share the same giftamizer-extras Compose profile.
Activating giftamizer-extras in COMPOSE_PROFILES starts both — if you
want link-preview metadata (urlmetadata) but haven't set up an Azure AD
app registration for the email relay (smtp2graph), start urlmetadata by
naming it explicitly on the command line instead. Compose allows starting a
profiled service by name even when its profile isn't active in
COMPOSE_PROFILES; this creates and runs just that one service, and
smtp2graph is never created.
Consequence: a bare docker compose up -d (no service names) won't restart
urlmetadata if it's ever removed — always include it by name, or add
giftamizer-extras to COMPOSE_PROFILES once/if smtp2graph credentials
are added too (which will also start smtp2graph).
docker compose ps
# all services should show "healthy": studio, kong, auth, rest, realtime,
# storage, imgproxy, meta, functions, db, supavisor, plus db-backup/urlmetadata
# if you enabled them
curl -H "apikey: $(grep '^ANON_KEY=' .env | cut -d= -f2-)" http://localhost:8000/auth/v1/health
curl -H "apikey: $(grep '^ANON_KEY=' .env | cut -d= -f2-)" http://localhost:8000/rest/v1/Studio dashboard: http://<SERVER_IP>:8000/ (basic auth: DASHBOARD_USERNAME
/ DASHBOARD_PASSWORD from .env).
| Port | Service | Notes |
|---|---|---|
| 8000 | Kong (API gateway / Studio) | forward <API_DOMAIN> here |
| 8443 | Kong (HTTPS) | only relevant if Kong itself terminates TLS |
| 5432 | Postgres (via Supavisor) | direct DB access |
| 6543 | Supavisor pooler (transaction mode) | |
| 5500 | urlmetadata | internal use by the app, if enabled |
5432/6543 are bound to all interfaces by default — nothing in the stock
compose file restricts them to localhost. If they're reachable from
somewhere they shouldn't be (e.g. the whole LAN, or the public internet),
firewall them (ufw, security group, etc.) to just the hosts that actually
need direct Postgres access; nothing in the stack itself needs them exposed
beyond the Docker network.
prodrigestivill/postgres-backup-local:17(match this to yourdbimage's major version — see the callout above), enabled via thebackupCompose profile- Schedule:
BACKUP_SCHEDULE(default@daily; also runs once on container start) - Retention:
BACKUP_KEEP_DAYS/_WEEKS/_MONTHS(defaults 7/4/6) - Writes to
<install-dir>/backend/backups/{daily,weekly,monthly,last}
Note: a raw host reboot (power loss, kernel panic, etc.) restarts containers
via Docker's own restart policy, not docker compose up, so depends_on
health-ordering isn't honored — db-backup may log one failed connection
attempt against db right after a crash/reboot before succeeding on its
next scheduled run. Harmless, but worth knowing so it doesn't look like a
real failure the first time you see it.
- Generate the rclone remote config:
(or install
docker run --rm -it \ -v /giftamizer/backend/volumes/rclone:/root/.config/rclone \ mazzolino/restic:1.8.2 rclone config
rcloneon the host directly and runrclone config) - In
.env:Do not loseRESTIC_PASSWORD=<generated above> RCLONE_REMOTE=<remote-name>:<bucket>/giftamizerRESTIC_PASSWORD— it's the only way to decrypt any restic snapshot, local or offsite. COMPOSE_PROFILES=backup,restic-backup(needsbackuptoo, so there's something in./backupsfor restic to pick up)cd /giftamizer/backend && docker compose up -d restic-backup restic-prune
Schedule defaults: nightly backup at 3:30am, prune at 4am, in whatever TZ
is set to (defaults to UTC if TZ isn't set in .env).
Restore procedure is documented in the backend repo's README.md under "Offsite backup" / "Restoring".
- Create a GitHub PAT (fine-grained or classic) with Issues read/write on the target repo.
- Set
GITHUB_TOKEN,GITHUB_OWNER,GITHUB_REPOin.env. docker compose up -d functions(recreates just the edge-functions container to pick up the new env vars).
Leave GITHUB_TOKEN blank to disable — the frontend hides the Support nav
item automatically when it's unset.
Needs an Azure AD app registration (SMTP2GRAPH_CLIENT_ID /
SMTP2GRAPH_CLIENT_SECRET / SMTP2GRAPH_TENANT_ID in .env) with
Mail.Send on a mailbox. Enable by adding giftamizer-extras to
COMPOSE_PROFILES (this also starts urlmetadata if it wasn't already
running — see the note above).
If you don't configure real SMTP (or smtp2graph) at all,
ENABLE_EMAIL_AUTOCONFIRM=true (the .env.example default) means sign-up
still works without a confirmation click. Password-reset and invite emails
will simply fail to send until real SMTP credentials are in place.
The frontend Dockerfile is a multi-stage build: a node:20-alpine stage
runs npm ci && npm run build, with REACT_APP_SUPABASE_URL /
REACT_APP_SUPABASE_ANON_KEY passed in via --build-arg and baked into the
JS bundle at build time (CRA inlines REACT_APP_* vars when it builds —
they can't be changed later at container-run time without rebuilding). The
final stage is nginx:1.27-alpine, serving the static build with an
SPA-fallback nginx.conf (try_files $uri /index.html, so client-side
routes don't 404 on refresh) and a .dockerignore that excludes
node_modules/build/.git from the build context. If your checkout of
the frontend repo doesn't have Dockerfile / nginx.conf / .dockerignore
set up this way, fix that before building — this doc assumes it's already
in place.
REACT_APP_SUPABASE_ANON_KEY here is Supabase's public anon key — it's
meant to be client-visible by design (protected by RLS on the backend, not
a secret), and ends up in the built JS bundle regardless of how it's passed
in.
From your dev machine (or CI), logged into your registry:
docker build \
--build-arg REACT_APP_SUPABASE_URL=https://<API_DOMAIN> \
--build-arg REACT_APP_SUPABASE_ANON_KEY=<ANON_KEY from backend/.env> \
-t <DOCKERHUB_NAMESPACE>/giftamizer-frontend:latest .
docker push <DOCKERHUB_NAMESPACE>/giftamizer-frontend:latest/giftamizer/frontend/docker-compose.yml:
services:
frontend:
image: <DOCKERHUB_NAMESPACE>/giftamizer-frontend:latest
container_name: giftamizer-frontend
restart: unless-stopped
ports:
- "8081:80"cd /giftamizer/frontend
docker compose pull
docker compose up -dPick a host port other than 8080 if you might ever enable smtp2graph
(its default SMTP2GRAPH_HTTP_PORT is 8080) — 8081 avoids that
collision.
Backend (non-schema changes — new image versions, compose config, etc.):
cd /giftamizer/backend
git pull
docker compose pull
docker compose up -d
docker compose up -d urlmetadata # if you're using the explicit-start approach aboveSchema changes ship as new volumes/db/giftamizer/00NN-*.sql files upstream
and must be applied to the running prod DB by hand (via Studio's SQL editor
or psql) — see the backend repo's README "Updating production" section.
Frontend: rebuild the image (see "Build & push" above) whenever the frontend repo changes or the backend's public URL/keys change, push, then on the server:
cd /giftamizer/frontend
docker compose pull
docker compose up -dIf you're standing up a new server to replace an existing one (version upgrade, hardware move, etc.), here's the general approach for bringing users/data/files over. Treat the old instance as read-only throughout — everything below only ever reads from it.
Before copying anything, compare the old and new instances' schemas — don't assume they match just because it's "the same app":
psql "postgresql://postgres:<password>@<OLD_HOST>:5432/postgres" -At -c "
select table_schema||'.'||table_name||'.'||column_name||':'||data_type||(case when is_nullable='NO' then ' NOT NULL' else '' end)
from information_schema.columns
where table_schema in ('public','auth','storage')
order by table_schema, table_name, ordinal_position;" > old_schema.txt
# same query against the new instance, then:
diff old_schema.txt new_schema.txtIn practice, the public (Giftamizer app) schema tends to be stable across
versions — the differences show up in auth/storage, which evolve with
the GoTrue/storage-api versions pinned in docker-compose.yml. Known
examples seen between older and current versions of this stack:
auth.identities: older schemas have atextPK column namedid(the provider's own identifier); newer ones rename that toprovider_idand add a new surrogateid uuid default gen_random_uuid(). Map columns explicitly during import (old.id→new.provider_id) and let the newidauto-generate.auth.usersgains columns over time (e.g.is_anonymous) — as long as they have defaults, a data-only copy that omits them is fine.storage.bucketsgainedowner_id/type, and older installs may have had buckets setpublic=truewhere a newer install's init scripts correctly default them topublic=false(private, RLS-gated) — don't overwrite the new instance's bucket rows with the old ones; only importstorage.objects(file metadata), and leavestorage.bucketsas the new install's init scripts created it.storage.objectsmay drop columns between versions (e.g.level) — exclude anything that doesn't exist in the target from your column list.- Watch for buckets on the old instance that aren't part of the current app's bucket set (e.g. a leftover bucket used for static email-template assets) — these usually aren't user data and can be excluded.
The general pattern: COPY (SELECT ... explicit columns ...) TO stdout
against the old instance (read-only), piped into COPY <table> (...) FROM stdin against the new one, all wrapped in a single transaction with
SET session_replication_role = replica. This matters because the
handle_new_user trigger on auth.users auto-creates a profile + default
list on every insert — without disabling triggers, importing auth.users
would create duplicate profiles/lists alongside the real ones you're about
to import right after.
BEGIN;
SET session_replication_role = replica;
COPY auth.users (...) FROM stdin;
...
\.
COPY auth.identities (provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at) FROM stdin;
...
\.
COPY public.profiles FROM stdin;
...
\.
-- (repeat for groups, group_members, lists, lists_groups, items, items_lists,
-- items_status, notifications, external_invites, link_domains, system, user_roles)
COPY storage.objects (...) FROM stdin;
...
\.
SET session_replication_role = default;
COMMIT;Run it against the new instance inside a single psql session (e.g.
docker exec -i <db-container> psql -U postgres -v ON_ERROR_STOP=1 < restore.sql) so a bad statement rolls back the whole thing instead of
leaving a half-imported database — fix and re-run rather than trying to
patch a partial import.
After importing, verify: row counts match the source exactly, and there
are no orphaned foreign keys (profiles→auth.users, group_members→
groups, items→profiles, storage.objects→storage.buckets, etc.).
Stream files directly between hosts rather than staging them locally:
ssh <OLD_HOST> "tar --xattrs --xattrs-include='*' -cf - -C <old-storage-path> avatars groups items lists" | \
ssh <NEW_HOST> "tar --xattrs --xattrs-include='*' -xf - -C <new-storage-path>"Critical: always use --xattrs --xattrs-include='*' on both the
archive and extract side. Plain tar extracts the files fine but silently
drops the extended file attributes storage-api relies on — files copied
without --xattrs will 500 with "The extended attribute does not exist"
when fetched through the Storage API, even though they look present and
correct on disk. This applies to any file-level copy of the storage volume
(migrations, restic restores to a different host, etc.), not just this one
scenario.
Also confirm GLOBAL_S3_BUCKET and STORAGE_TENANT_ID in .env match
between old and new instances (or adjust the target path if they don't) —
the on-disk layout is <storage-root>/<GLOBAL_S3_BUCKET>/<STORAGE_TENANT_ID>/<bucket>/<object name>/<version>,
and changing either value without moving the files to match will break
every existing file reference.
After copying, spot-check a random sample of storage.objects rows through
the actual Storage API (not just checking the files exist on disk) — the
real object path includes the version column
(<bucket>/<object name> maps internally to
<bucket>/<object name>/<version> on disk), so a naive path check without
version will report false negatives.
If the old instance ever ran a third-party auth-migration middleware (e.g.
a Firebase-to-Supabase migration proxy), some encrypted_password values
may be in that middleware's custom format rather than standard bcrypt.
Those rows will copy over fine, but GoTrue's standard bcrypt verification
won't recognize them — affected users won't be able to log in with their
existing password and will need to use "forgot password" once real SMTP is
configured. Users who signed up normally (bcrypt hashes) are unaffected.
Worth checking for before assuming a 100% successful login rate post-migration.
- Point your reverse proxy at the server's frontend/backend ports for
<FRONTEND_DOMAIN>/<API_DOMAIN>, with TLS terminated there (or on this box, if you added a local reverse proxy). - If replacing an existing production instance, cut over DNS only once you've verified the new instance end-to-end.
- Set up offsite backup (
rclone config+restic-backupprofile) if you haven't already. - Add a
GITHUB_TOKENif you want the Support page's issue tracker. - Add real SMTP (or
smtp2graph+ Azure AD creds) before relying on password-reset/invite emails, or before migrated users with legacy password hashes need to recover access. - Consider firewalling ports 5432/6543 (direct Postgres) to just the hosts that actually need them.
General procedure for changing anything in backend/.env once the stack is
already running:
- Edit
/giftamizer/backend/.env. cd /giftamizer/backend && docker compose up -d— Compose diffs each service's resolved config against what's running and recreates any container whose environment actually changed; services with no relevant change are left alone (no unnecessary restarts).- If a service was started outside the normal profile flow (like
urlmetadataabove), you may need to name it explicitly:docker compose up -d urlmetadata. - To force a specific service to pick up a change you're not sure Compose
noticed, add
--force-recreate:docker compose up -d --force-recreate <service>. - Verify with
docker compose psanddocker compose logs <service>.
A few variables need more than a routine restart:
JWT_SECRET/ANON_KEY/SERVICE_ROLE_KEY— changing these invalidates every existing user session immediately (all previously issued JWTs stop validating). The frontend also hasANON_KEYbaked into its build (see below), so rotating these requires rebuilding, pushing, and redeploying the frontend too, not just restarting the backend.REACT_APP_*frontend variables (REACT_APP_SUPABASE_URL,REACT_APP_SUPABASE_ANON_KEY) — these are not read frombackend/.envat all; they're compiled into the frontend's JS bundle at Docker build time (see "How the Dockerfile works" above). Editingbackend/.envhas zero effect on the frontend. To change them, rebuild the frontend image with new--build-argvalues, push, and redeploy (docker compose pull && docker compose up -din/giftamizer/frontend).COMPOSE_PROFILES— adding a profile doesn't retroactively touch services outside it, and removing a profile from this variable doesn't stop the now-out-of-scope container either;docker compose up -donly reconciles what's currently in scope. To actually stop a service you just removed fromCOMPOSE_PROFILES, stop it explicitly:docker compose stop <service>. Remember theurlmetadata/smtp2graphshared-profile quirk described earlier if you touchgiftamizer-extras.KONG_HTTP_PORT/KONG_HTTPS_PORT(or any other host-port variable) —docker compose up -dwill recreate the container with the new port binding, but you'll also need to update any reverse proxy pointing at the old port, and this causes a brief outage for that service while it recreates.GLOBAL_S3_BUCKET/STORAGE_TENANT_ID— these determine the on-disk path undervolumes/storagefor every stored file (see the migration section above). Changing either after the instance already has uploaded files will make storage-api look in the wrong place for everything that was already uploaded; don't change these post-install without also relocating the files to match the new path.POSTGRES_PASSWORD— changing this in.envalone does not change the actual Postgres role's password inside the running database; you'd need to change it in both places (ALTER ROLE postgres WITH PASSWORD '...'in Postgres, and the new value in.env) and recreate every service that connects to the database, or you'll get authentication failures.
After any secret rotation, double-check chmod 600 .env is still in effect
— some editors/tools reset file permissions on save.