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. Plaindocker compose upon 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:
env_file→ keeps secrets out of plain sight- Healthcheck → catches failures Docker alone would miss
- Restart policy → recovers automatically from crashes
- Read-only filesystem → shrinks the attack surface
- Security options → blocks privilege escalation
- Resource limits → stops one container from starving the rest
- Log rotation → keeps your disk from filling silently
- 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.