← Back to Articles DevOps & CI/CD

Docker Compose From Dev to Production (and Backups)

Oselio Candido · Jul 2026 · 9 min read

The problem: keeping dev and prod consistent

The real risk isn't that development and production run on different machines — it's that their infrastructure quietly stops being the same thing. A Postgres version gets bumped in one place and not the other, a healthcheck interval gets tuned in dev and never carried over, and nobody notices until a bug only reproduces in one environment. Docker Compose already solves consistency at the container level; the missing piece is making sure the definitions stay consistent too, instead of hand-maintaining near-duplicate files that are free to drift apart.

Backups fall out of the same idea. If the backup routine is just another service in the same compose file, versioned and reviewed the same way as everything else, restoring or auditing it doesn't require separate infrastructure or a bespoke script living outside the stack — running a backup is the same docker compose command as running anything else.

Development and production need the same underlying engines — the same database, the same cache — but almost nothing else the same:

  • Development needs the database reachable directly from a laptop while application code runs natively, with hot reload and a debugger attached.
  • Production needs the application fully containerized, tightly locked down, network-isolated, and backed up on a schedule.

Writing two fully separate compose files — one per environment — guarantees drift: a Postgres version gets bumped in dev and forgotten in prod. Three months later, "works in dev" and "works in prod" are quietly different claims.

How Compose file layering works

Docker Compose solves this natively with the -f flag. When multiple files are passed, Docker merges them key by key, in the order listed — a later file can add a new service, add a key to an existing one, or override a value, without restating everything that didn't change. The strategy follows directly from that mechanism: one base file holds what every environment agrees on, and a small override file per environment holds only the delta.

Here, the base file (docker-compose.yml) defines exactly two services — Postgres and Redis — pinned to specific patch versions, on a shared bridge network, each with a healthcheck. It contains no application code and exposes no host ports:

services:
  db:
    image: postgres:15.13-alpine3.22
    container_name: db
    restart: unless-stopped
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      backend_network:
        aliases:
          - db
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 20s
      timeout: 5s
      retries: 3
      start_period: 20s

  redis:
    image: redis:7.4.9-alpine3.21
    container_name: redis
    restart: unless-stopped
    volumes:
      - redis_data:/data
    networks:
      backend_network:
        aliases:
          - redis
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 20s
      timeout: 5s
      retries: 3
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
This file is pure, immutable infrastructure. It stays identical no matter where or how the stack is running.

The dev override: native code, containerized dependencies

In development the application itself is never containerized — rebuilding an image on every minor code change ruins the feedback loop. Instead the app runs natively on the host, and Docker is used only for its dependencies. The dev override adds exactly two things to the two existing services: an env file, and host ports so tooling running directly on the laptop (a database client, the app's own connection string) can reach the containers:

# docker-compose.dev.yml
services:
  db:
    env_file:
      - ../env/dev.env
    ports:
      - "5432:5432"

  redis:
    env_file:
      - ../env/dev.env
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "devpassword"]
    ports:
      - "6379:6379"

Notice what isn't here: no API service, no frontend service. Containers handle what should behave identically everywhere — the database and cache; native execution handles what changes several times an hour — the application code. Bringing dev up is two files and two services, nothing else running:

docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db redis

The production override: the full stack and its security details

Production merges over the same base file, but its override is doing more work: it points db at a different env file, and it adds the services dev doesn't need at all — the API, the frontend, and a backup job:

# docker-compose.prd.yml
services:
  db:
    env_file:
      - ../env/prd.env

  api:
    build:
      context: ../
      dockerfile: infra/docker/backend.Dockerfile
    command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
    env_file:
      - ../env/prd.env
    ports:
      - "127.0.0.1:8000:8000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - backend_network
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"]
      interval: 20s
      timeout: 5s
      retries: 3

  frontend:
    build:
      context: ../
      dockerfile: infra/docker/frontend.Dockerfile
    volumes:
      - /var/www/frontend:/usr/share/nginx/html
    env_file:
      - ../env/prd.env
  • Loopback binding (127.0.0.1:8000:8000) — a security decision disguised as a port binding. The API is never exposed publicly; it's bound exclusively to the host's loopback interface, so only an on-host reverse proxy can reach it directly — in our case, nginx.
  • Decoupled frontend assets — the frontend service doesn't serve anything itself. It builds static assets straight into a volume the edge server reads from, with no runtime container in the request path.

One-shot tasks: Compose profiles for backups

Database backups need to be version-controlled, tested, and reviewed alongside the rest of the infrastructure — but they shouldn't run continuously as part of the core stack. A Compose profile solves this cleanly: the service is defined in the file like everything else, but it doesn't start on a plain up:

  backup_postgres:
    image: postgres:15.13-alpine3.22
    profiles: ["backup"]
    env_file:
      - ../env/prd.env
    depends_on:
      db:
        condition: service_healthy
    environment:
      DAY: ${DAY}
      MONTH: ${MONTH}
      YEAR: ${YEAR}
    entrypoint: ["/bin/sh", "-c", "
      apk add --no-cache aws-cli &&
      pg_dump -h db -U $$POSTGRES_USER -F c $$POSTGRES_DB | gzip > /backups/$$POSTGRES_DB_$$YEAR-$$MONTH-$$DAY.dump.gz &&
      aws s3 cp /backups/$$POSTGRES_DB_$$YEAR-$$MONTH-$$DAY.dump.gz s3://$${AWS_S3_BUCKET_NAME}/postgres/$$YEAR/$$MONTH/$${DAY}/$$POSTGRES_DB.dump.gz"]

It only runs when explicitly requested by cron or a manual command:

docker compose -f docker-compose.yml -f docker-compose.prd.yml --profile backup run backup_postgres

That's the right primitive for anything that should be defined once and tested like the rest of the stack, but shouldn't be part of the steady-state footprint of every deploy — one-shot jobs, backups, migrations.

The image contains code, not configuration — promoting a build is re-pointing the same containers at a different env file, not rebuilding them.

How the backup itself works

The backup_postgres service runs a disposable Postgres container — same image and version as db, so pg_dump is always talking to itself across matching client/server versions — that reaches db over the internal network, dumps in custom format, compresses it, and ships it out:

  • Custom format, not plain SQLpg_dump -F c produces Postgres's own compressed archive format, which pg_restore can replay selectively (one table, one schema) instead of forcing an all-or-nothing restore of a giant SQL file.
  • Date-partitioned object keys$YEAR/$MONTH/$DAY/ in the S3 key means listing or expiring backups by age is a prefix operation, not something that requires parsing filenames.
  • Runs where the data already is — the container is on the same internal network as db, so the dump never crosses a public network boundary before it's compressed and encrypted in transit to object storage.
  • Disposable by design — the container installs its own tooling (aws-cli) on start and exits when the dump finishes; there's no long-running backup agent to patch or babysit.

Restoring is the same idea in reverse: pull the dated object back from storage and run pg_restore against a target database — routine enough, given the format, that it doesn't need its own bespoke tooling beyond what shipped the dump in the first place.

Why not just one file with everything in it

A single compose file with conditionals or duplicated service blocks per environment was the naive alternative, and it was rejected for the same reason two full copies would be: nothing forces the shared parts — the Postgres version, the network name, the healthcheck intervals — to actually stay identical once environment-specific logic is interleaved with them. Splitting into a base file plus overrides makes that impossible to get wrong by construction:

  • Infrastructure parity — bumping the Postgres or Redis version in docker-compose.yml updates both dev and prod on their next deploy, since the database definition exists in exactly one place.
  • Clean secret separation — the compose files live in version control and contain no secrets at all; every credential is referenced through env_file, never written into the file itself. In dev that's an uncommitted dev.env on disk, which is fine for a laptop only one person touches. Production currently runs the same way: this is a single VPS, so today that means SSH-ing in and hand-editing prd.env directly on the box — a manual step that works, but is also the one part of this setup that doesn't get the audit trail or review the rest of the stack gets. The target shape is CI/CD writing that env file at deploy time, from secrets and variables held by the git provider, so production credentials never sit on disk between deploys, never get hand-edited over SSH, and never appear in a diff. That's a known gap, not an oversight — worth naming even though the manual version is what's actually running today.
  • Artifact promotion — the image itself never changes between environments; promoting a build is pointing the same containers at a different env file, not rebuilding.

The outcome

A deploy is the same handful of commands regardless of environment — the difference is only which -f flags and which env file are passed:

Environment Files Command Purpose
Development docker-compose.yml + .dev.yml up -d db redis Fast feedback, exposed host ports, native app debugging
Production docker-compose.yml + .prd.yml up -d Locked-down network, containerized app, reverse-proxy in front
Maintenance docker-compose.yml + .prd.yml --profile backup run backup_postgres On-demand database dump to object storage

Dev gets a database and cache it can hit from a laptop with a debugger attached to native code; production gets the exact same infrastructure plus the containerized app, a loopback-only API, and a backup job that only runs when asked — all without a single line of shared configuration duplicated between them.