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
One VM, one Compose file: everything twelve-factor asks for, nothing more Internet DNS → VM public IP virtual machine · Docker Engine · docker compose up -d Caddy :80 :443, automatic TLS reverse_proxy web:8000 web (gunicorn, 2 workers) stateless app process config from environment worker same image, different command background jobs postgres backing service named volume pgdata .env (not in git) DATABASE_URL, SECRET_KEY stdout / stderr json-file driver, rotated nightly backup pg_dump → restic → S3 443 http :8000 5432 env_file Only Caddy publishes host ports. web, worker and postgres talk over the Compose default network by service name. Processes are disposable: `docker compose up -d --no-deps web` replaces them;the data lives in the volume and in the backup.
Everything inside the dashed box is one docker compose up. Only the proxy is reachable from outside.

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.

Build, release, run (factor V): the stages stay separate even on one machine BUILD RELEASE RUN git tag v1.4.2 one codebase, many deploys docker build (CI) multi-stage Dockerfile image app:v1.4.2 → registry compose.yaml image: ${APP_IMAGE} .env on the VM APP_IMAGE=app:v1.4.2 + secrets release = image + config docker compose pull fetch the new image docker compose up -d recreate only changed services healthcheck passes→ Caddy routes rollback = point APP_IMAGE at the previous tag and `up -d` again The image never contains configuration, and configuration never contains code. Either can change without the other, which is what makes rollback a one-line edit instead of a rebuild.
Build, release and run stay separate stages. Rollback is editing one variable, not rebuilding anything.

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_KEY should 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_data volume 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 certbot container that shares the /etc/letsencrypt and webroot volumes, runs certbot certonly --webroot on first boot, and renews on a schedule, after which nginx needs a reload. 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

  1. Provision the VM and open only ports 22, 80 and 443 in its firewall. Everything else stays closed; the database is never published.
  2. Install Docker Engine with the Compose plugin from Docker's repository, and add the deploy user to the docker group.
  3. Point a DNS A (and AAAA) record at the VM's address. Caddy needs the name to resolve before it can obtain a certificate.
  4. Create /opt/shop, copy compose.yaml, Caddyfile, backup.sh and .env.example into it, and fill in .env with real values (chmod 600 .env).
  5. Log in to the registry and run docker compose pull.
  6. Run docker compose up -d, then docker compose ps until every service is healthy.
  7. Run the admin process for schema setup: docker compose run --rm web python manage.py migrate.
  8. Confirm TLS with curl -I https://shop.example.com/healthz and check that http:// redirects.
  9. Initialise the restic repository, run backup.sh once by hand, and add the nightly cron entry.
  10. 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