Deployment with Podman Compose
This is the primary deployment guide for Patrimoine. It covers podman compose (the default) and the podman.sh alternative using native Podman pods.
New to Patrimoine? Start with the Quick Start.
docker-compose.yml Services
Section titled “docker-compose.yml Services”Configuration is split into one env file per container, copied from the templates described in Environment Variables. The backend service loads backend/container/.env through its env_file.
The values Compose itself substitutes into docker-compose.yml (FRONTEND_HOST_PORT, SITE_PORT, BACKEND_PORT) cannot come from a root .env any more. Pass one --env-file per file you created, later files winning on conflict (repeating --env-file needs Docker Compose 2.24 or later):
podman compose --env-file backend/container/.env \ --env-file frontend/container/.env --env-file web/container/.env up --buildSkip the flag for a template you did not copy; FRONTEND_HOST_PORT then defaults to 8080 and SITE_PORT to 8082. The optional PostgreSQL and E2E overrides combine with these flags as before (-f docker-compose.yml -f docker-compose.postgres.yml).
backend
Section titled “backend”| Property | Value |
|---|---|
| Image | built from ./backend by default; git.desord.re/eofredj/patrimoine/backend if you opt into the published image (see Updating) |
| Port | ${BACKEND_PORT:-8000} (default 8000, internal only — not exposed to host) |
| Volume | ./data:/data (SQLite database, bind mount) |
| Env file | backend/container/.env |
| Healthcheck | GET /health (process liveness, no DB dependency) |
read_only |
true |
tmpfs |
/tmp |
cap_drop |
ALL |
security_opt |
no-new-privileges:true |
| Memory limit | 512m |
| CPU limit | 1.0 |
frontend
Section titled “frontend”| Property | Value |
|---|---|
| Image | built from the repository root with frontend/container/Containerfile by default; git.desord.re/eofredj/patrimoine/frontend if you opt into the published image (see Updating) |
| Port | ${FRONTEND_HOST_PORT}:${FRONTEND_CONTAINER_PORT} (host port from FRONTEND_HOST_PORT in frontend/container/.env, default 8080) |
| Depends on | backend (condition: service_healthy) |
| Healthcheck | wget --spider http://localhost:${FRONTEND_CONTAINER_PORT}/ (default 8080) |
read_only |
true |
tmpfs |
/tmp, /var/cache/nginx, /var/run, /etc/nginx/conf.d (the entrypoint renders the nginx config into the last one) |
cap_drop |
ALL |
security_opt |
no-new-privileges:true |
| Memory limit | 256m |
| CPU limit | 0.5 |
The frontend container runs nginx, which serves the React SPA (static files) and reverse-proxies /api/, /auth/ and /health to the backend at BACKEND_URL (http://backend:8000 with compose, http://127.0.0.1:8000 with podman.sh).
| Property | Value |
|---|---|
| Image | built from web/container/Containerfile (context .), or git.desord.re/eofredj/patrimoine/web if you opt into the published image; one Starlight build of web/documentation (the landing page, the manuals and the API reference) served by a single nginx vhost — ./backend is passed as the openapi build context so the API reference is generated from backend/openapi.json |
| Port | ${SITE_PORT:-8082}:8080 (host port from SITE_PORT in web/container/.env, default 8082) |
| Depends on | nothing — fully static, no backend/database dependency |
| Healthcheck | wget --spider http://localhost:8080/ |
read_only |
true |
tmpfs |
/tmp, /var/cache/nginx, /var/run, /etc/nginx/conf.d — docker-entrypoint.sh writes the vhost config into the last one at start |
cap_drop |
ALL |
security_opt |
no-new-privileges:true |
| Memory limit | 128m |
| CPU limit | 0.25 |
- Independent of the app: no secrets, no runtime dependency on
backend/frontend. - Its image build does read
backend/openapi.jsonto generate the API reference, so rebuild the docs image after a backend release to refresh it. - Reachable at
http://localhost:${SITE_PORT:-8082}directly, or behind the reverse proxy’s own site block — see Reverse Proxy Configuration.
The container serves one vhost, reachable on SITE_PORT directly or through the reverse proxy’s site block.
- The landing page is at
/{locale}/and the manuals are under/{locale}/docs/. - A URL without a language prefix (
/or any/docs/...path) is redirected to the same path in the language picked from the browser’sAccept-Languageheader, English by default. - The exception is
/docs/developer-manual/api-reference/..., which always redirects to/en/because the API reference only exists in English. - Its links are plain relative links fixed at build time, with no container-start substitution.
- It does not read
DOCS_URL— that stays a backend setting (see Environment Variables).
worker (optional)
Section titled “worker (optional)”Commented out by default in docker-compose.yml. Uncomment to enable the arq background worker (requires Redis). docker-compose.yml does not include Redis: add a Redis service or point REDIS_URL at an existing one. See Worker Setup.
Alternative: podman.sh (Native Pods)
Section titled “Alternative: podman.sh (Native Pods)”The podman.sh script uses native Podman pods instead of podman compose. It runs rootless and maps the container user to the host user via --userns=keep-id:uid=10001,gid=10001.
./podman.sh build # Build backend + frontend + docs images./podman.sh up # Create pod(s) and start containers (waits for backend healthcheck)./podman.sh down # Stop and remove the pod(s)./podman.sh logs # Follow logs (optional service: backend, frontend, docs, postgres, redis, worker)./podman.sh ps # Show pod status./podman.sh restart # Down + uppodman.sh reads backend/container/.env (required), then frontend/container/.env, web/container/.env and data/container/.env when they exist, and hands each container only its own file. No --env-file flags are needed.
The script applies the same hardening flags as docker-compose.yml:
--read-only(read-only filesystem)--tmpfs /tmp(writable temp directory)--cap-drop=ALL(all Linux capabilities dropped)--security-opt=no-new-privileges=true
The frontend and docs containers additionally mount tmpfs at /var/cache/nginx, /var/run and /etc/nginx/conf.d (mode 1777).
docs pod
Section titled “docs pod”The docs site (built from web/documentation/ — see the compose section above) gets its own pod, on ${SITE_PORT:-8082}, separate from the main backend+frontend (+ optional postgres) pod.
- Its nginx always listens on internal port 8080 (baked into the image), the same internal port
frontendalready binds inside the main pod’s shared network namespace. - A second container on the same port in that same pod would conflict, so it runs isolated instead, mirroring
docker-compose.yml’s separate-network-namespace-per-service model. - It has no runtime dependency on
backend/frontend, though its image build readsbackend/openapi.json(via theopenapibuild context) to generate the API reference — rebuild the docs image after a backend release to refresh it. - Its single vhost is exposed on
SITE_PORT. ./podman.sh downremoves both pods.
Optional PostgreSQL container
Section titled “Optional PostgreSQL container”podman.sh detects PostgreSQL purely by parsing DATABASE_URL — the same convention the backend itself uses (app/database.py’s IS_SQLITE = database_url.startswith("sqlite")). There is no separate switch. Set DATABASE_URL in backend/container/.env to a loopback address and rebuild before starting:
DATABASE_URL=postgresql+asyncpg://patrimoine:change-me@127.0.0.1:5432/patrimoine./podman.sh build # rebuilds the backend image with the asyncpg extra./podman.sh up # also starts a postgres container in the pod, # using the user/password/db parsed out of # DATABASE_URL, and waits for its healthcheckThis runs a docker.io/library/postgres:18-alpine container in the same pod, with data persisted at ${DATA_DIR}/postgres (bind mount, same pattern as the backend’s SQLite DATA_DIR). The port must be 5432 — the managed container always listens on the default port inside the pod.
Pointing DATABASE_URL at any other host (e.g. an already-running PostgreSQL elsewhere) is treated as externally managed: podman.sh starts nothing locally and just passes DATABASE_URL through to the backend unmodified. Leaving DATABASE_URL unset (SQLite default) keeps the existing behavior unchanged.
Optional Redis and worker containers
Section titled “Optional Redis and worker containers”podman.sh reads REDIS_URL the same way. While REDIS_URL is set, the backend no longer runs the background jobs itself, so an arq worker has to exist. Set REDIS_URL to a plain redis:// address on the pod’s own loopback and rebuild before starting:
REDIS_URL=redis://127.0.0.1:6379./podman.sh build # rebuilds the backend image with the redis extra (arq)./podman.sh up # starts Redis, then the backend, then the arq workerThis adds two containers to the pod:
- Redis: a
docker.io/library/redis:7-alpinecontainer (change it withREDIS_IMAGE). It listens on the pod’s loopback address only, since the pod publishes just the frontend port, and it writes nothing to disk. After a restart the queue is empty and the job counters on the metrics endpoint start again from zero, which Prometheus reads as a counter reset. Unlike the PostgreSQL container, it runs with--read-only,--cap-drop=ALLandno-new-privileges. A password in the URL (redis://:password@127.0.0.1:6379) is passed to Redis through its environment. - Worker: the backend image running
arq app.worker.WorkerSettings. It starts once the backend is healthy, because the backend runs the database migrations when it starts, and it mounts the sameDATA_DIR. It has no port, so the script only checks that it stays up for a few seconds. Follow it with./podman.sh logs worker.
The port must be 6379, and the managed Redis does not support TLS. Any other REDIS_URL (another host, a rediss:// address or a different port) is treated as externally managed: podman.sh starts nothing locally, prints a reminder that a worker must run against that Redis, and still builds the image with the redis extra. Leaving REDIS_URL unset keeps the asyncio fallback and the previous behavior.
Container Hardening
Section titled “Container Hardening”The backend, frontend, docs, worker and managed Redis containers run with the following security measures (the managed PostgreSQL container is the exception, see above):
| Measure | Description |
|---|---|
| Non-root user | Containers run as UID 10001 (not root) |
| Read-only filesystem | Root filesystem is immutable (read_only: true) |
| All capabilities dropped | cap_drop: ALL — no Linux capabilities |
| No new privileges | no-new-privileges:true prevents privilege escalation |
| tmpfs for /tmp | Writable temp directory in RAM (not persisted) |
Healthchecks
Section titled “Healthchecks”| Service | Check | What it verifies |
|---|---|---|
| backend | GET /health |
Process liveness (status only) — see GET /health/db for DB connectivity (status only, 503 with unhealthy if the database does not answer within 3 seconds) |
| frontend | wget --spider http://localhost:${FRONTEND_CONTAINER_PORT}/ (default 8080) |
nginx is responding |
| docs | wget --spider http://localhost:8080/ |
nginx is responding |
The frontend container depends on the backend being healthy before starting (depends_on: condition: service_healthy).
Backend and frontend on different hosts
Section titled “Backend and frontend on different hosts”The frontend’s nginx proxies /api/ and /auth/ to the backend, so the browser only ever talks to the frontend. To run the backend on another host:
- Point the frontend at it with
BACKEND_URLinfrontend/container/.env, for examplehttps://api.example.com.- Use
https://: the traffic carries sessions and financial data, and the frontend checks the certificate against its CA bundle. - Put a TLS-terminating proxy in front of the backend, or reach it over a private network or VPN.
- Use
- List the frontend’s address in
TRUSTED_PROXIESinbackend/container/.env, for example203.0.113.7.- Without it, anyone who can reach the backend port can forge the client IP used for rate limiting and the audit log.
- Also restrict the backend port with a firewall to the frontend host.
- Set
FRONTEND_URLinbackend/container/.envto the frontend’s public HTTPS URL.- It is used for CORS, login redirects and emailed links, and
OIDC_REDIRECT_URIfollows it.
- It is used for CORS, login redirects and emailed links, and
- With compose, the
frontendservice still waits for the localbackendservice, so override itsdepends_onor run the frontend container on its own.- If the container’s own DNS does not resolve the backend’s name, set
FRONTEND_BACKEND_RESOLVER.
- If the container’s own DNS does not resolve the backend’s name, set
- If a reverse proxy sits in front of the frontend, set
FRONTEND_TRUSTED_PROXIESto its address so the per-IP limits see the real clients.