The twelve-factor methodology was written for applications deployed to a platform-as-a-service, and it is often read as an argument for one. It is not. The twelve factors are a description of an application that is easy to operate: config outside the code, processes you can kill and restart, logs you can read from one place, backing services you can swap. All of that is achievable on one virtual machine with Docker Compose, and for a large class of applications that is the right size.
This post lays out the setup I reach for by default: one VM, one Compose file, a reverse proxy that handles TLS on its own, and a backup that has been restored at least once.
The twelve factors, mapped
| Factor | On a single VM with Compose |
|---|---|
| I. Codebase | one repository; the VM runs an image built from a tagged commit |
| II. Dependencies | declared in the Dockerfile and the lock file; nothing installed by hand on the host |
| III. Config | environment variables from a .env file that is not in git |
| IV. Backing services | PostgreSQL is a Compose service today; a managed instance tomorrow is a change to one URL |
| V. Build, release, run | CI builds the image; the VM pulls it and starts it; the two never happen on the same machine |
| VI. Processes | containers are stateless; state lives in the database volume |
| VII. Port binding | the app listens on 8000 inside its network; only the proxy publishes 80 and 443 |
| VIII. Concurrency | web and worker are separate services; scale either with --scale |
| IX. Disposability | restart: unless-stopped, fast startup, graceful shutdown on SIGTERM |
| X. Dev/prod parity | the same Compose file runs locally with a different .env |
| XI. Logs | processes write to stdout; Docker's logging driver collects and rotates |
| XII. Admin processes | docker compose run --rm web python manage.py migrate uses the release's image and config |
The image
A multi-stage Dockerfile keeps build tools out of the runtime image and makes the image the unit of release. This one is for a Python web application, but the shape is the same for Go, Node or Ruby.
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /app
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
FROM python:3.12-slim AS runtime
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=build /venv /venv
COPY --chown=app:app . .
ENV PATH="/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
USER app
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')"
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "app.wsgi"]
Three details matter for operability. PYTHONUNBUFFERED=1 makes log lines appear when they are written rather than when a buffer fills, which is factor XI in one environment variable. The non-root user limits what a compromised process can do. And HEALTHCHECK gives Compose and the proxy a signal that means "ready", not just "running".
The Compose file
name: shop
services:
caddy:
image: caddy:2
restart: unless-stopped
ports: ["80:80", "443:443", "443:443/udp"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
web: { condition: service_healthy }
web:
image: ${APP_IMAGE}
restart: unless-stopped
env_file: .env
depends_on:
db: { condition: service_healthy }
logging: &logging
driver: json-file
options: { max-size: "20m", max-file: "5" }
worker:
image: ${APP_IMAGE}
command: ["python", "-m", "app.worker"]
restart: unless-stopped
env_file: .env
depends_on:
db: { condition: service_healthy }
logging: *logging
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: shop
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: shop
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
interval: 5s
timeout: 3s
retries: 10
logging: *logging
volumes:
pgdata:
caddy_data:
caddy_config:
The same image runs web and worker with different commands (factor VIII). db has no ports: entry, so it is reachable as db:5432 from the other services and from nowhere else. The .env file supplies APP_IMAGE, POSTGRES_PASSWORD and the application's own settings; Compose substitutes ${APP_IMAGE} in the file and env_file passes the rest into the containers. Changing the release is editing APP_IMAGE and running docker compose up -d.
Config: environment, not files in the image
The .env file is the one piece of state on the host besides the volumes:
APP_IMAGE=registry.example.com/shop:v1.4.2
DATABASE_URL=postgresql://shop:${POSTGRES_PASSWORD}@db:5432/shop
POSTGRES_PASSWORD=change-me-on-first-boot
SECRET_KEY=generate-with-openssl-rand-hex-32
ALLOWED_HOSTS=shop.example.com
LOG_LEVEL=info
It is chmod 600, owned by the deploy user, and never committed; the repository carries .env.example with the same keys and placeholder values. docker compose config prints the fully substituted file, which is the fastest way to check what a service will actually see.
TIP
Put the environment variable names, not values, under version control, and make the application fail fast at startup if one is missing. A missing
SECRET_KEYshould stop the container with a one-line error, not start it with a default.
Logs as streams
Nothing in the stack writes a log file. gunicorn logs access and errors to stdout; the worker uses the standard logging module to stderr; PostgreSQL and Caddy do the same. Docker's json-file driver captures each stream, and the max-size/max-file options above cap it at 100 MB per container, so a chatty service cannot fill the disk.
docker compose logs -f --since 10m web worker is the day-to-day interface. When one machine is not enough, the step up is a single agent (Vector, Promtail, Fluent Bit) that tails the Docker log directory and ships it somewhere searchable; nothing in the application changes.
Reverse proxy and TLS
The proxy is the only service with published ports. It terminates TLS, forwards to web:8000, and compresses responses. Caddy is the shortest path to a correct setup because certificate issuance and renewal are built in; nginx is the better-known tool and needs a companion for certificates.
-
shop.example.com { encode zstd gzip reverse_proxy web:8000 { health_uri /healthz health_interval 10s } log { output stdout format json } }That is the complete Caddyfile. Caddy obtains a certificate from Let's Encrypt on first start, serves the HTTP-01 challenge on port 80, renews automatically, redirects HTTP to HTTPS, and enables HTTP/2 and HTTP/3 (hence the UDP 443 mapping in the Compose file). The
caddy_datavolume holds the certificates, so a restart does not re-issue them. -
server { listen 80; server_name shop.example.com; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } } server { listen 443 ssl http2; server_name shop.example.com; ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem; gzip on; gzip_types text/plain text/css application/json application/javascript; location / { proxy_pass http://web:8000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }With nginx, certificates come from a separate
certbotcontainer that shares the/etc/letsencryptand webroot volumes, runscertbot certonly --webrooton first boot, and renews on a schedule, after which nginx needs areload. It is more moving parts for the same result, and the reason I default to Caddy; nginx is still the right choice when you need its ecosystem of modules or already operate it elsewhere.
Backups
Volumes are not backups. A backup is a copy that lives somewhere else and that you have restored. For PostgreSQL the simplest reliable version is a nightly logical dump pushed to object storage with restic, which deduplicates, encrypts, and keeps a retention policy:
#!/bin/sh
# /opt/shop/backup.sh — run nightly from the host's cron
set -eu
cd /opt/shop
docker compose exec -T db pg_dump -U shop -d shop --format=custom \
| restic backup --stdin --stdin-filename shop.dump --tag nightly
restic forget --keep-daily 14 --keep-weekly 8 --keep-monthly 12 --prune
restic check --read-data-subset=5%
RESTIC_REPOSITORY and RESTIC_PASSWORD come from the environment of the cron job, not from the application's .env. The last line verifies a random 5% of the repository's data every night, which catches bit rot and misconfigured storage before you need the backup.
DANGER
A backup you have never restored is a hypothesis. Once a quarter, restore the latest dump into a scratch database (
pg_restore --dbname shop_restore_test), run the application's smoke tests against it, and time the whole thing. That number is your real recovery time, and it is usually longer than anyone guessed.
From a fresh VM to running
- Provision the VM and open only ports 22, 80 and 443 in its firewall. Everything else stays closed; the database is never published.
- Install Docker Engine with the Compose plugin from Docker's repository, and add the deploy user to the
dockergroup. - Point a DNS
A(andAAAA) record at the VM's address. Caddy needs the name to resolve before it can obtain a certificate. - Create
/opt/shop, copycompose.yaml,Caddyfile,backup.shand.env.exampleinto it, and fill in.envwith real values (chmod 600 .env). - Log in to the registry and run
docker compose pull. - Run
docker compose up -d, thendocker compose psuntil every service ishealthy. - Run the admin process for schema setup:
docker compose run --rm web python manage.py migrate. - Confirm TLS with
curl -I https://shop.example.com/healthzand check thathttp://redirects. - Initialise the restic repository, run
backup.shonce by hand, and add the nightly cron entry. - Restore that first backup into a scratch database to prove the loop closes.
A new release is steps 5 and 6 with a new APP_IMAGE; a rollback is the same with the previous tag.
What this costs, and what it does not buy
| Concern | Single VM + Compose | Managed PaaS | Managed Kubernetes |
|---|---|---|---|
| Moving parts you operate | Docker, one Compose file, cron | none below the app | cluster, ingress, storage classes, add-ons |
| Configuration surface | ~100 lines of YAML, one .env | provider dashboard or CLI | hundreds of lines of manifests or Helm values |
| Availability | one machine; minutes of downtime on reboot or failure | provider SLA | multi-node; survives a node failure |
| Scaling | vertical, plus --scale within the VM | automatic within plan limits | horizontal, automatic with HPA |
| Billing shape | one fixed VM bill | per service, per dyno or instance | control plane plus a pool of nodes |
| Time to first deploy | an afternoon | an hour | days, more if you have not done it before |
| Lock-in | none; the Compose file runs anywhere Docker does | moderate | low on the API, high on the surrounding tooling |
The single VM loses on availability, and that is the honest limit of this design: a kernel update is a reboot, and a hardware failure is downtime until the VM is recreated from the image, the Compose file, and the last backup. For many applications a maintenance window at 3 a.m. and a documented ten-minute recovery procedure is an acceptable trade for the simplicity. When it stops being acceptable, the twelve-factor shape is exactly what makes the move easy: stateless images, config in the environment, logs on stdout, and a backing service behind a URL are what every bigger platform expects to receive.
Further reading
- The Twelve-Factor App: the original twelve factors, each one a short page.
- Docker Compose documentation and the Dockerfile reference, for every key used above.
- Caddy: Automatic HTTPS, which explains what the one-block Caddyfile is doing for you.
- Let's Encrypt documentation, including the challenge types and rate limits that matter when you first bring up a domain.
- PostgreSQL manual, pg_dump, and the restic documentation for the backup pipeline.