Introduction

Here's a habit a lot of us fall into: you write one docker-compose.yaml, get it working locally, and then just... ship that same file to production. It runs, right? Ship it.

It runs. It's just not safe. A dev compose file is optimized for one thing: getting you iterating fast. A production compose file has a completely different job — staying up, staying secure, and not taking the rest of your server down with it when something goes wrong.

The good news is that the gap between "works on my machine" and "production-ready" isn't actually that big. It's about eight settings. In this guide, we'll walk through a real dev file and its production counterpart side by side, and I'll explain exactly what each change buys you — not just what to add, but why it matters when things go sideways at 2 AM.


The Two Files

We'll use Arcane, a Docker container manager, as our example app. The concepts here apply to basically any containerized service.

Dev (docker-compose-dev.yaml)

services:
  arcane:
    image: ghcr.io/getarcaneapp/manager:latest
    container_name: arcane-dev
    ports:
      - '0.0.0.0:3552:3552'
    environment:
      - APP_URL=http://127.0.0.1:3552
      - PUID=1000
      - PGID=1000
      - ENCRYPTION_KEY='add your 64 long key text'
      - JWT_SECRET='add your 64 long key text'
    volumes:
      - arcane_data:/app/data

volumes:
  arcane_data:

Nothing wrong with this for local dev — it's fast to spin up, fast to tear down, and you can see your env vars at a glance while debugging.

Production (docker-compose.yaml)

services:
  arcane:
    image: ghcr.io/getarcaneapp/manager:latest
    container_name: arcane
    ports:
      - '0.0.0.0:3552:3552'
    volumes:
      - arcane_data:/app/data
    env_file:
      - .env
    healthcheck:
      test: ['CMD', '/app/arcane', 'health']
      interval: 30s
      timeout: 5s
      retries: 3
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 128M
    logging:
      driver: json-file
      options:
        max-size: '10m'
        max-file: '3'

volumes:
  arcane_data:
    external: true

Same image, same port, same volume mount — but eight things changed. Let's go through each one.


The 8 Differences That Matter

1. Secrets: Inline vs env_file

Dev:

environment:
  - ENCRYPTION_KEY='add your 64 long key text'
  - JWT_SECRET='add your 64 long key text'

Production:

env_file:
  - .env

Inline environment variables sit in plain text right in your docker-compose.yaml — which means anyone who can read the file (including your git history if you're not careful) can read your secrets. Move them to a .env file, add that file to .gitignore, and keep it off version control entirely. This is the single most common Docker misconfiguration I see, and it's a one-line fix.

2. Healthcheck

Dev: none configured.

Production:

healthcheck:
  test: ['CMD', '/app/arcane', 'health']
  interval: 30s
  timeout: 5s
  retries: 3

Docker's default view of your container is binary: is the process running, yes or no. That's not the same as "is the app actually working." If your database connection pool exhausts or the app deadlocks, the process is technically still alive — Docker will happily report it as healthy while it serves nothing but errors. A healthcheck gives Docker a real signal to check, and lets orchestration tools (or your own restart logic) know when something needs a kick.

3. Restart Policy

Dev: none — a crashed container just... stays crashed.

Production:

restart: unless-stopped

unless-stopped means the container comes back automatically after a crash or a server reboot, but respects you if you deliberately ran docker stop. Without this, a crash at 3 AM stays down until you notice and manually restart it — not a fun way to find out about an incident.

4. Read-Only Filesystem

Dev: container can write anywhere.

Production:

read_only: true
tmpfs:
  - /tmp

Setting read_only: true makes the container's filesystem immutable — great, because it means a compromised process can't drop a webshell, patch a binary, or write a backdoor to disk. The tmpfs mount gives the app a writable, in-memory scratch space (like /tmp) for anything that genuinely needs write access, and it's wiped clean every time the container stops.

5. Security Options

Dev: default Docker security context.

Production:

security_opt:
  - no-new-privileges:true

This one blocks a classic privilege-escalation trick: processes gaining extra permissions via setuid/setgid bits on executables. Even if an attacker manages to execute code inside the container, they're capped at the privileges the container already had — they can't climb any higher.

6. Resource Limits

Dev: unlimited CPU and memory.

Production:

deploy:
  resources:
    limits:
      cpus: '1.0'
      memory: 512M
    reservations:
      cpus: '0.25'
      memory: 128M

Without limits, one runaway container (memory leak, infinite loop, whatever) can starve every other service on the box — including things that have nothing to do with it. limits caps what the container is allowed to use; reservations guarantees a minimum it's always allowed to use, even under contention.

Heads up: the deploy: key is technically a Swarm construct. Plain docker compose up on a single host will silently ignore it unless you're running Compose v2 with the right engine support — check your Compose version, or consider Swarm/Kubernetes if you need this enforced strictly. It's still worth documenting in your compose file even if enforcement varies by environment.

7. Logging Configuration

Dev: default json-file logging, no limits.

Production:

logging:
  driver: json-file
  options:
    max-size: '10m'
    max-file: '3'

Left unchecked, container logs grow forever and will eventually fill your disk — usually at the worst possible time. This config caps each log file at 10MB and keeps 3 rotated files, so total log disk usage tops out around 30MB per container.

8. External Volumes

Dev:

volumes:
  arcane_data:

Production:

volumes:
  arcane_data:
    external: true

Without external: true, running docker compose down deletes the volume — and your data with it. That's fine in dev, where you want a clean slate. In production it's a disaster waiting to happen. Marking the volume external tells Compose "this already exists, don't touch its lifecycle," so a routine down doesn't accidentally wipe your database.


Quick Reference

Feature Dev Production
Secrets Inline in YAML env_file (.env)
Healthcheck None Enabled
Restart policy None unless-stopped
Filesystem Writable Read-only + tmpfs
Security Default no-new-privileges
Resources Unlimited CPU + memory limits
Logging Default Rotated (10MB × 3)
Volume Managed External

Bookmark this table — it's a solid checklist to run through before anything goes live.


Conclusion

None of these eight changes are individually complicated. What they add up to, though, is defense in depth — each one closes off a different failure mode:

  1. env_file → keeps secrets out of plain sight
  2. Healthcheck → catches failures Docker alone would miss
  3. Restart policy → recovers automatically from crashes
  4. Read-only filesystem → shrinks the attack surface
  5. Security options → blocks privilege escalation
  6. Resource limits → stops one container from starving the rest
  7. Log rotation → keeps your disk from filling silently
  8. External volumes → prevents accidental data loss

The pattern to internalize: dev optimizes for convenience, production optimizes for containment — assuming things will eventually go wrong, and making sure the blast radius stays small when they do.

Got a production compose setup with a trick I didn't cover here? Drop it in the comments — always curious what other folks are running.