Production deployment
Run Archetype in production as a single Docker Compose stack: nginx terminates TLS and reverse-proxies the Next.js frontend, the Django API, and the SIPI IIIF image server, backed by PostgreSQL, Meilisearch, Redis, and a Celery worker. Everything lives in the infrastructure repository, whose compose.yaml and just recipes are the only staging/production stack. The backend’s own compose.yaml is for local development and CI only; it runs under a separate Compose project (archetype-dev, versus archetype here), so the two never share containers or volumes.
This guide uses placeholder hosts like
archetype.example.org. Substitute your own domain everywhere. The public beta instance runs this stack at beta.archetype-pal.ink.
Topology
Section titled “Topology”infrastructure/compose.yaml defines these services:
| Service | Image | Role | Host port |
|---|---|---|---|
nginx | nginx:1.31-alpine | TLS termination + reverse proxy | 80, 443 |
frontend | ghcr.io/archetype-pal/frontend (FRONTEND_IMAGE) | Next.js site | internal :3000 |
api | ghcr.io/archetype-pal/backend (BACKEND_IMAGE) | Django + DRF | internal :80 |
celery | ghcr.io/archetype-pal/backend (BACKEND_IMAGE) | Async task worker (search sync, image-upload ingest) | — |
image_server | daschswiss/sipi:v5.0.1 | IIIF image delivery | internal :1024 |
postgres | postgres:18.6-bookworm (POSTGRES_IMAGE) | Application database | internal :5432 |
meilisearch | getmeili/meilisearch:v1.50.0 | Search | internal :7700 |
redis | redis:8.10.1-alpine | Celery broker/result backend, lock cache | internal :6379 |
pg_backup | postgres:18-alpine | Nightly pg_dump sidecar | — |
certbot | certbot/certbot:v5.8.0 | Let’s Encrypt issuance/renewal | — |
Only nginx publishes host ports. Postgres, Meilisearch, Redis, and SIPI are reachable only over the internal Compose network — publishing them would expose unauthenticated read/write. nginx (nginx.conf) routes /static and /api/v1/ to the API, serves /media/ straight from the media mount (Django only serves media with DEBUG on), sends /sipi to the image server (read-only methods, control routes denied), and passes everything else — including the frontend’s own /api/* route handlers — to the frontend.
The image corpus is bind-mounted into api, celery, image_server and nginx from MEDIA_HOST_PATH (default ./storage/media), so you can keep a large corpus outside the infrastructure checkout.
Configure the environment
Section titled “Configure the environment”Copy env_file.example to env_file and set production values. Run all recipes from infrastructure/.
DEBUG=FalseSECRET_KEY=<long-random-string>DOMAIN=archetype.example.orgALLOWED_HOSTS=archetype.example.org,apiCORS_ALLOWED_ORIGINS=https://archetype.example.orgCSRF_TRUSTED_ORIGINS=https://archetype.example.orgIIIF_HOST=https://archetype.example.org/sipiINTERNAL_API_URL=http://api
POSTGRES_PASSWORD=<db-password>DATABASE_URL=postgresql://postgres:<db-password>@postgres:5432/localREDIS_PASSWORD=<redis-password>CELERY_BROKER_URL=redis://:<redis-password>@redis:6379/0CELERY_RESULT_BACKEND=redis://:<redis-password>@redis:6379/0CACHE_URL=redis://:<redis-password>@redis:6379/1MEILI_ENV=productionMEILI_MASTER_KEY=<>=16-byte-key>MEILISEARCH_API_KEY=<same-as-MEILI_MASTER_KEY>ALLOWED_HOSTS includes api because the frontend’s server-side fetches reach Django at INTERNAL_API_URL=http://api with Host: api. The api reads the Celery and cache URLs from env_file, and their defaults carry no Redis password, so set them there.
The Postgres credentials, MEILI_ENV and REDIS_PASSWORD are also Compose interpolation variables, so they must be in a .env file next to compose.yaml as well as in env_file. Image pins go in .env too.
Pin the published GHCR images to an immutable tag or digest (never mutable :latest, which makes docker compose pull silently change the running version). CI publishes :latest plus a :sha-<commit> tag for every build:
BACKEND_IMAGE=ghcr.io/archetype-pal/backend:sha-<commit>FRONTEND_IMAGE=ghcr.io/archetype-pal/frontend@sha256:<digest>The frontend inlines
NEXT_PUBLIC_*values at build time, so a frontend image only works on the domain it was built for. The published tags are built for the project’s own hosts (:betafor beta.archetype-pal.ink); for your domain, buildfrontend/Dockerfilewith your ownNEXT_PUBLIC_API_URL,NEXT_PUBLIC_IIIF_UPSTREAM,NEXT_PUBLIC_SITE_URLandCORS_ALLOWED_ORIGINSbuild args.
See Configuration for the full variable reference.
Bring the stack up
Section titled “Bring the stack up”just pull # fetch the pinned GHCR imagesjust up-bg # start the full stack detachedjust migrate # resync id sequences, then apply Django migrationsjust collectstatic # collect static assets (after every deploy/image bump)just setup-upload-storage # create + chown the upload directoriesjust reindex # create Meilisearch schemas + load documentscollectstatic is not optional: with DEBUG=False, a missing static manifest makes every DRF endpoint opened in a browser return 500. setup-upload-storage creates storage/uploads_tmp/ and storage/media/{uploads,carousel,partners,branding}/ owned by the containers’ unprivileged archetype user; without it, backoffice image, carousel, partner and logo uploads fail.
just ps shows service health; just logs follows all logs. The Celery worker (celery -A config worker) starts automatically; verify it with just celery_status.
Upload sessions that never complete leave chunk files in storage/uploads_tmp/. Nothing schedules their cleanup; reap them by hand with docker compose exec api python manage.py cleanup_stale_uploads (default age UPLOADS_STALE_AFTER_DAYS, 7 days).
TLS with Let’s Encrypt
Section titled “TLS with Let’s Encrypt”Point an A record for your domain at the host first. nginx serves the ACME challenge from /.well-known/acme-challenge/, so it must already be running. Then issue the certificate for the DOMAIN in env_file:
just certbot # certonly --webroot for $DOMAINdocker compose restart nginx # load the new certnginx.conf already points at certs/live/${DOMAIN}/fullchain.pem and privkey.pem. Certificates expire after 90 days — re-run just certbot and restart nginx, or wire it into a host cron job.
Alternative: Cloudflare Tunnel
Section titled “Alternative: Cloudflare Tunnel”The beta host publishes no web ports at all. Two overlays replace TLS-at-nginx with a Cloudflare Tunnel: compose.tunnel.yaml switches nginx to plain HTTP (nginx.tunnel.conf), and compose.cloudflared.yaml adds a cloudflared container that reaches nginx:80 over the Compose network and drops nginx’s host ports. Select them with COMPOSE_FILE=compose.yaml:compose.tunnel.yaml:compose.cloudflared.yaml in .env (plus CLOUDFLARE_TUNNEL_ID and CLOUDFLARE_CREDENTIALS_FILE), and every just recipe picks them up. Set SECURE_SSL_REDIRECT=False in env_file, since TLS ends at Cloudflare. The full host build is in infrastructure/docs/beta-deployment.md.
Backups
Section titled “Backups”The pg_backup sidecar runs pg_dump every 24h, gzipped, into ./backups/ (host-mounted), with 14-day retention. For an on-demand dump:
just backupManual dumps (local-manual-…) are reaped on the same 14-day schedule; copy one outside ./backups/ if it must last longer. A failed nightly dump leaves only a FAILED line in docker compose logs pg_backup, so watch the age of the newest dump.
The dump captures rows only. Uploaded media lives on disk under MEDIA_HOST_PATH (default storage/media/) — include that path in your filesystem backup (rsync/restic/S3); for backoffice uploads the converted JP2 is the only copy. Meilisearch is derived: rebuild it with just sync-all-search-indexes from infrastructure/ after any restore. Also rsync ./backups/ off-host for real disaster recovery. Full restore and single-table procedures are in infrastructure/docs/backup-runbook.md.
Upgrades and migrations
Section titled “Upgrades and migrations”Roll forward by bumping the pinned images, then reconciling schema, static files and search:
# edit BACKEND_IMAGE / FRONTEND_IMAGE in .env to the new versionjust pulljust up-bgdocker compose restart nginx # nginx resolves api/frontend once at startupjust migrate # alwaysjust collectstatic # alwaysjust setup-search-indexes # if a release adds facets, filters or sort fieldsjust reindex # instead, if the indexed documents changed shapeRe-run just setup-upload-storage on upgrades too; it is idempotent and newer releases prepare more directories. nginx keeps proxying to the old container IPs after api or frontend is recreated (502s) until it is restarted.
Because pins are immutable, rollback is just reverting them and re-running just up-bg. Upgrading an existing PostgreSQL 17 volume needs a one-time step — follow infrastructure/docs/postgresql-18-upgrade.md (just postgres-upgrade-17-to-18) before starting the PG18 stack.
Continuous deployment (beta)
Section titled “Continuous deployment (beta)”The beta instance deploys itself, from the host. A systemd timer (host/archetype-poll.timer in the infrastructure repo) checks the registry every three minutes and, when the digest behind backend:latest or frontend:beta changes, runs scripts/deploy.sh with both pinned by digest. The repo’s deploy-beta workflow covers the rest: every push to main redeploys the current images with the new config, and a manual run with explicit images is how to roll back (the pin holds until the next image is published). deploy.sh writes the pins to .env, pulls, restarts (including nginx), migrates, collects static, applies the search index settings, then smoke-tests through nginx; on any failure it restores the previous pins and restarts on them.
See also
Section titled “See also”- Configuration — every environment variable
- Load your own corpus — loading and indexing your data
- The API — endpoints served under
/api/v1/