Skip to content

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.


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

Terminal window
podman compose --env-file backend/container/.env \
--env-file frontend/container/.env --env-file web/container/.env up --build

Skip 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).

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
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.json to 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’s Accept-Language header, 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).

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.


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.

Terminal window
./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 + up

podman.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).

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 frontend already 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 reads backend/openapi.json (via the openapi build 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 down removes both pods.

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:

backend/container/.env
DATABASE_URL=postgresql+asyncpg://patrimoine:change-me@127.0.0.1:5432/patrimoine
Terminal window
./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 healthcheck

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

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:

backend/container/.env
REDIS_URL=redis://127.0.0.1:6379
Terminal window
./podman.sh build # rebuilds the backend image with the redis extra (arq)
./podman.sh up # starts Redis, then the backend, then the arq worker

This adds two containers to the pod:

  • Redis: a docker.io/library/redis:7-alpine container (change it with REDIS_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=ALL and no-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 same DATA_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.


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)

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


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:

  1. Point the frontend at it with BACKEND_URL in frontend/container/.env, for example https://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.
  2. List the frontend’s address in TRUSTED_PROXIES in backend/container/.env, for example 203.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.
  3. Set FRONTEND_URL in backend/container/.env to the frontend’s public HTTPS URL.
    • It is used for CORS, login redirects and emailed links, and OIDC_REDIRECT_URI follows it.
  4. With compose, the frontend service still waits for the local backend service, so override its depends_on or run the frontend container on its own.
    • If the container’s own DNS does not resolve the backend’s name, set FRONTEND_BACKEND_RESOLVER.
  5. If a reverse proxy sits in front of the frontend, set FRONTEND_TRUSTED_PROXIES to its address so the per-IP limits see the real clients.