{"id":159,"date":"2026-08-07T16:00:00","date_gmt":"2026-08-07T13:00:00","guid":{"rendered":"https:\/\/john-nessime.com\/blog\/?p=159"},"modified":"2026-08-04T12:34:34","modified_gmt":"2026-08-04T09:34:34","slug":"docker-compose-in-production","status":"publish","type":"post","link":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/","title":{"rendered":"Docker Compose in Production: What Works and What Quietly Burns You"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">The stack had been up for months. Nobody had touched it, nobody had needed to. Then the disk alert fires at an awkward hour, Postgres flips to read-only because it cannot write WAL, and you SSH in to find that one chatty container has written tens of gigabytes into a single JSON log file that nothing was ever going to rotate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That is the shape of most Docker Compose incidents. Not a dramatic architectural failure. A default that was fine on your laptop and wrong on a server, sitting quietly for half a year until it wasn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Running Docker Compose in production is a perfectly reasonable choice for a lot of workloads. The tooling is stable, the file format is readable, and a single host with a handful of services does not need a control plane. What it does need is that you go through the defaults deliberately, because Compose was designed for a developer machine and inherits assumptions from that world.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This post walks the failure families I look for first when reviewing a production Compose setup: the deploy gap, disk exhaustion, state you did not mean to keep, published ports that walk past your firewall, secrets handling, and health checks that report without acting. Then the troubleshooting commands, the mistakes I see repeatedly, and an honest read on when to stop using Compose.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What Docker Compose genuinely gets right<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Worth stating the case before pulling it apart, because a lot of writing on this topic is really Kubernetes marketing.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The whole system fits in one file you can read.<\/strong> Onboarding somebody onto a Compose stack takes minutes. That is not a small thing when you are the only person on call.<\/li>\n\n<li><strong>Dev and prod can share a base file.<\/strong> Override files let you keep one source of truth and layer the production differences on top, rather than maintaining two drifting definitions.<\/li>\n\n<li><strong>No control plane to operate.<\/strong> A Kubernetes cluster is a system that itself needs upgrading, monitoring and debugging. On a single VPS from a provider like Hetzner, DigitalOcean or InterServer, that overhead buys you very little.<\/li>\n\n<li><strong>Recovery is comprehensible.<\/strong> When something breaks at 2am, <code>docker compose ps<\/code> and <code>docker compose logs<\/code> tell you nearly everything. There is no scheduler making decisions you have to reverse-engineer.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">If your workload is one machine, one team, and downtime measured in seconds rather than zero, Compose is a defensible answer. The rest of this post is about making that answer survive contact with a real server.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family one: the deploy gap<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the one that surprises people who came from a platform that did rolling updates for them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>docker compose up -d<\/code> is not a rolling update. When a service&#8217;s image or config has changed, Compose stops the old container and then starts the new one. Between those two events the service does not exist. If the container takes twenty seconds to boot a JVM or run migrations, that is twenty seconds of connection refused.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>deploy.update_config<\/code> block with <code>order: start-first<\/code> exists in the Compose file specification, but it describes behaviour for orchestrators like Swarm. Do not assume it gives you overlap on a plain Compose host. Test it on your own setup before you rely on it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What to do instead<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Three options, in increasing order of effort:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Accept the gap and shrink it.<\/strong> Pull images before you cut over so the restart is not waiting on a network transfer, and make the container boot fast. For an internal tool, a five-second gap is fine and you should not build machinery to avoid it.<\/li>\n\n<li><strong>Put a reverse proxy in front and run two slots.<\/strong> Traefik, Caddy or plain Nginx in a container, with <code>app-blue<\/code> and <code>app-green<\/code> services. Start the new one, wait for it to pass its health check, move the proxy, stop the old one. This is real zero-downtime and it is maybe forty lines of config.<\/li>\n\n<li><strong>Drain at the edge.<\/strong> If you already sit behind Cloudflare or a load balancer, take the host out of rotation, deploy, put it back. Simplest when you have more than one host anyway.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Whichever you pick, the deploy itself should pull first and then block on health rather than returning immediately:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Fetch new images while the old containers are still serving traffic.\ndocker compose pull\n\n# Recreate changed services, then block until every service with a\n# healthcheck reports healthy. Non-zero exit if something never gets there.\ndocker compose up -d --wait --wait-timeout 120\n\n# --wait only knows about services that define a healthcheck.\n# Services without one are treated as ready the moment they start.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That last line matters more than it looks. A service with no health check is invisible to <code>--wait<\/code>, so a deploy script can report success while your API is still crash-looping.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family two: the disk fills up and nothing tells you<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Docker&#8217;s default logging driver is <code>json-file<\/code>, and by default it performs no rotation at all. The docs are explicit that this default exists for backward compatibility, and that the <code>local<\/code> driver is the recommended alternative because it rotates out of the box and uses a more compact format.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the failure mode is: a service starts logging every request, or starts emitting a stack trace in a loop, and the log file grows without limit until the filesystem is full. Everything else on that host dies at the same moment, which makes the root cause harder to see, not easier.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Fix it once at the daemon level so it applies to every container on the host, including ones you spin up by hand:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ \/etc\/docker\/daemon.json\n{\n  \"log-driver\": \"local\",\n  \"log-opts\": {\n    \"max-size\": \"10m\",\n    \"max-file\": \"3\"\n  },\n  \"default-address-pools\": [\n    { \"base\": \"10.40.0.0\/16\", \"size\": 24 }\n  ]\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two things to know before you restart the daemon. Log options must be strings in this file, quotes included, or Docker will refuse to start. And the change only affects containers created afterwards; existing containers keep whatever config they were created with, so you need to recreate them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You can also set limits per service, which is worth doing for a known-chatty component. A YAML anchor keeps it from being copy-pasted six times:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>x-logging: &amp;default-logging\n  logging:\n    driver: local\n    options:\n      max-size: \"10m\"\n      max-file: \"3\"\n\nservices:\n  api:\n    image: registry.example.com\/api:1.4.2\n    &lt;&lt;: *default-logging\n\n  worker:\n    image: registry.example.com\/worker:1.4.2\n    &lt;&lt;: *default-logging<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Logs are only one of three things eating the disk. The others are old images, which accumulate every time you deploy, and dangling volumes. Put a scheduled cleanup on the host and monitor free space with whatever you already run, whether that is Prometheus and Grafana, a hosted agent, or a plain cron job piping into Healthchecks.io.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Images not used by any container and older than a week.\n# -a includes untagged parents, not just dangling layers.\ndocker image prune -a --filter \"until=168h\" --force\n\n# Where the space actually went.\ndocker system df -v<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family three: state you did not mean to keep<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two related traps here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first is anonymous volumes. If an image declares a <code>VOLUME<\/code> in its Dockerfile and your Compose file does not map that path to a named volume, Docker creates an anonymous one. Your data is real and it is on disk, but it has a hash for a name, it is not in your backup script, and the next person who runs a cleanup command has no way to know it matters. Always name your volumes explicitly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second is <code>docker compose down<\/code>. On its own it removes containers and the project&#8217;s networks but leaves named volumes alone, which is the behaviour you want. Add <code>-v<\/code> and it deletes those volumes too. There is no confirmation prompt and no undo. I have seen that flag reach production because somebody had it in a local teardown alias and pasted the alias into a runbook.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The practical rule: on a production host, restarting services is <code>docker compose up -d<\/code> or <code>docker compose restart<\/code>. <code>down<\/code> belongs in a maintenance procedure with a fresh backup, not in a daily habit.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Bind mounts deserve a mention too. They are convenient and they tie the container to a specific host path with specific host permissions. That is fine for config files you want to edit in place. For database data, a named volume gives you something Docker manages and something you can back up as a unit.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family four: ports that bypass your firewall<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the one that turns into a security incident rather than an outage.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When you write <code>ports: - \"5432:5432\"<\/code>, Docker publishes that port on all host interfaces and inserts its own rules into the kernel&#8217;s NAT table to forward traffic to the container. Those rules are evaluated before the chains that a host firewall such as ufw or firewalld typically manages. The result is a database that is reachable from the internet even though your firewall config says otherwise, and a <code>ufw status<\/code> output that looks perfectly reassuring.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is to stop publishing what does not need publishing:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>services:\n  db:\n    image: postgres:16\n    # No ports: block at all. Other services on the same Compose\n    # network reach it as db:5432 by service name.\n    networks: [backend]\n\n  api:\n    image: registry.example.com\/api:1.4.2\n    # Bound to loopback only. The reverse proxy on this host can\n    # reach it; the internet cannot, regardless of firewall state.\n    ports:\n      - \"127.0.0.1:8080:8080\"\n    networks: [backend, edge]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two habits follow from this. Publish a port only when something outside the host genuinely needs it, and when you do, bind it to a specific interface. Everything else talks over the Compose network by service name. Then verify from somewhere else entirely, because checking from the host itself proves nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">While you are in daemon config: set <code>default-address-pools<\/code> as shown earlier. Compose creates a bridge network per project from a default range, and if that range overlaps your office LAN or your VPN subnet, you get routing that fails only for some people, only sometimes. It is a miserable thing to debug and a one-line thing to prevent.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family five: secrets in environment variables<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Environment variables are the default way to pass configuration to a container, and they are a mediocre way to pass secrets. They show up in <code>docker inspect<\/code>, they are readable by anything that can enumerate the process environment, they get inherited by child processes, and they land in crash dumps and error reporters.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Compose specification supports file-based secrets without any orchestrator. Declare them at the top level and grant them per service; they appear inside the container as files under <code>\/run\/secrets\/<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>services:\n  db:\n    image: postgres:16\n    environment:\n      # Official Postgres, MySQL and Redis images support the _FILE\n      # convention: read the value from this path instead of the env var.\n      POSTGRES_PASSWORD_FILE: \/run\/secrets\/db_password\n    secrets:\n      - db_password\n\nsecrets:\n  db_password:\n    file: .\/secrets\/db_password.txt<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The file still lives on the host, so this is not a vault. What it buys you is that the value is not in the container&#8217;s environment, not in <code>docker inspect<\/code> output, and not in your Compose file. Set the file to mode 600 owned by root and keep it out of Git.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One more thing that trips people up: <code>.env<\/code> and <code>env_file<\/code> are different mechanisms. The <code>.env<\/code> file in the project directory feeds variable interpolation <em>inside<\/em> the Compose file. <code>env_file<\/code> passes variables <em>into<\/em> the container. Confusing them produces a service that starts fine with an empty config value and fails somewhere far from the cause.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family six: health checks that report but never act<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A container health check marks a container healthy, unhealthy, or starting. On a plain Docker host, that status is a label. Nothing restarts an unhealthy container. The restart policy only reacts to the process exiting.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the classic silent failure is an app that has deadlocked, or lost its database pool, or wedged a worker thread. The process is alive, so the restart policy sees nothing to do. The health check goes red. Your monitoring, if it only checks that the container is running, sees nothing wrong. The service is down and every automated signal says it is fine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Health checks are still worth defining, because they gate startup ordering and they gate <code>--wait<\/code>. Just be clear about what they do not do.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>services:\n  db:\n    image: postgres:16\n    restart: unless-stopped\n    healthcheck:\n      # pg_isready exits non-zero until Postgres accepts connections.\n      test: [\"CMD-SHELL\", \"pg_isready -U postgres\"]\n      interval: 10s\n      timeout: 5s\n      retries: 5\n      # Grace period. Failures during start_period do not count\n      # toward retries, so a slow first boot is not marked unhealthy.\n      start_period: 30s\n\n  api:\n    image: registry.example.com\/api:1.4.2\n    restart: unless-stopped\n    depends_on:\n      db:\n        # Waits for db to report healthy before creating api.\n        # This is a startup-order guarantee only, not a runtime one.\n        condition: service_healthy\n    healthcheck:\n      test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http:\/\/localhost:8080\/healthz\"]\n      interval: 15s\n      timeout: 5s\n      retries: 3\n      start_period: 20s<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note <code>restart: unless-stopped<\/code> rather than <code>always<\/code>. The difference shows up after a host reboot: <code>always<\/code> will start a container you had deliberately stopped, <code>unless-stopped<\/code> respects that you stopped it. If you have ever taken a service down for maintenance and found it running again after a kernel update, this is why.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To close the gap, point an external check at the health endpoint. Whatever you already use for uptime monitoring is fine. The requirement is that something outside the host asks the application whether it is working, rather than asking Docker whether a process exists.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Troubleshooting a Compose stack that is misbehaving<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The commands I reach for, roughly in order:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># The fully resolved config: overrides merged, variables substituted,\n# anchors expanded. This is what Compose is actually going to run.\n# Careful, it prints secret VALUES from interpolation.\ndocker compose config\n\n# Health state and exit codes, not just up\/down.\ndocker compose ps --all\n\n# Why did it die? Look at the last lines before the restart.\ndocker compose logs --tail=200 --timestamps api\n\n# The health check's own output, which is where the real error usually is.\ndocker inspect --format '{{json .State.Health}}' &lt;container&gt;\n\n# Live resource use. Sudden memory growth before a restart means OOM.\ndocker stats --no-stream\n\n# Did the kernel OOM-killer take it? Exit code 137 is the hint.\ndmesg --ctime | grep -i \"out of memory\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two patterns worth recognising. A container that restarts every few minutes with exit code 137 was almost certainly killed for memory, either by a limit you set or by the host running out. And a service that works from inside the network but not from outside is nearly always a published-port or reverse-proxy problem, not an application problem, so check the plumbing before you read application code.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Common mistakes<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Deploying <code>:latest<\/code>.<\/strong> You cannot tell what is running, and a rebuild elsewhere silently changes what you get on the next pull. Pin a version tag, or pin a digest if you want it to be genuinely immutable.<\/li>\n\n<li><strong>Keeping the <code>version:<\/code> key at the top of the file.<\/strong> Compose v2 ignores it and warns that it is obsolete. Delete it from your base file and from every override.<\/li>\n\n<li><strong>No resource limits anywhere.<\/strong> One leaking service takes down every other service on the host. Limits turn a total outage into one restarting container.<\/li>\n\n<li><strong>Running <code>docker compose up<\/code> from an SSH session and walking away.<\/strong> Without <code>-d<\/code> the stack is tied to your terminal. Use detached mode, and put the stack behind a systemd unit if you want it managed like other services on the box.<\/li>\n\n<li><strong>The Compose file lives only on the server.<\/strong> If your production definition is not in Git, you have no history, no review, and no recovery when the disk dies.<\/li>\n\n<li><strong>Pulling from Docker Hub anonymously in a deploy script.<\/strong> Anonymous pulls are rate limited per IP, so the deploy that worked all week fails on a busy afternoon. Authenticate, or mirror the images you depend on into your own registry.<\/li>\n\n<li><strong>Automatic image updates on production.<\/strong> Tools that watch a registry and redeploy on their own are excellent for a homelab. On a system that matters, you want the update to happen when you are watching.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Best practices for Docker Compose in production<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>One base file plus a production override.<\/strong> Keep shared service definitions in the base, and put published ports, resource limits, logging and restart policy in the override. Then <code>docker compose -f compose.yaml -f compose.prod.yaml up -d<\/code> is your deploy, and the diff between environments is a file you can read.<\/li>\n\n<li><strong>Give every service a health check.<\/strong> It gates startup order, it makes <code>--wait<\/code> meaningful, and it gives you a status worth alerting on.<\/li>\n\n<li><strong>Set memory and CPU limits on everything.<\/strong> Watch <code>docker stats<\/code> under real load first, then set the limit above observed peak with headroom. A limit set from a guess causes the outage it was meant to prevent.<\/li>\n\n<li><strong>Segment your networks.<\/strong> An edge network for anything the proxy touches, a backend network for datastores. Only services that need to reach the database should be able to.<\/li>\n\n<li><strong>Back up volumes, and restore one.<\/strong> A backup you have never restored is a hypothesis. Restore into a scratch stack on a schedule you actually keep.<\/li>\n\n<li><strong>Make the deploy a script, not a memory.<\/strong> Pull, up with <code>--wait<\/code>, verify, prune. Six lines in the repo beats six commands somebody half-remembers.<\/li>\n\n<li><strong>Run <code>docker compose config<\/code> in CI.<\/strong> It catches a malformed override before it reaches the server, and it costs nothing.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">When to stop using Compose<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Being straight about the limits is more useful than defending the tool. Compose is a single-host tool. The moment your requirement is &#8220;survive the loss of this machine&#8221;, you have outgrown it, and no amount of configuration closes that gap.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The signals I treat as a real trigger, rather than as an excuse to rewrite everything:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>You need more than one machine for availability, not just for capacity.<\/li>\n\n<li>You need automatic rescheduling when a host dies, without somebody logging in.<\/li>\n\n<li>You are scaling components independently and often enough that doing it by hand is a real cost.<\/li>\n\n<li>Several teams deploy to the same infrastructure and need isolation from each other.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">If none of those are true, migrating to Kubernetes buys you a second system to operate and a longer list of ways to be paged. Plenty of profitable software runs on one well-configured box. The trade-off is real in both directions, and the honest answer depends on what your availability target actually is when written down.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Is Docker Compose production ready?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">For a single host, yes, provided you change the defaults that were chosen for development machines: log rotation, resource limits, port binding, named volumes, pinned image tags. Compose is not production ready in the sense of surviving a host failure, because it has no concept of a second host. That is a capability boundary, not a maturity problem.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I get zero-downtime deployments with Docker Compose?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not from Compose alone. Put a reverse proxy in front, run two instances of the service under different names, start the new one, wait for its health check to pass, switch the proxy, then stop the old one. Traefik does the switching automatically based on labels; Nginx or Caddy need you to reload config. If the service is not customer-facing, a few seconds of downtime is usually the cheaper answer.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Does <code>docker compose down<\/code> delete my database?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not by itself. Plain <code>down<\/code> removes containers and the project&#8217;s networks and leaves named volumes in place. Adding <code>-v<\/code> removes those volumes, and there is no prompt and no recovery. Anonymous volumes are the dangerous case, because you may not realise data is living in one until it is gone.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does my container keep restarting with exit code 137?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">137 means the process received SIGKILL, and in containers that almost always means it was killed for memory. Either it hit a limit you configured, or the host ran out and the kernel OOM-killer chose it. Check <code>docker stats<\/code> for growth over time and <code>dmesg<\/code> for OOM entries. Raising the limit is the fix only if the memory use is legitimate; if it grows without bound, you have a leak and a higher limit just delays the restart.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I still write <code>version:<\/code> at the top of my Compose file?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Compose v2 validates against the current specification regardless of what that key says, and it emits a warning telling you the key is obsolete. Remove it from the base file and from every override file, otherwise the warning follows you around.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I use Docker secrets without Swarm?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, using file-based secrets. Declare a top-level <code>secrets<\/code> block with a <code>file:<\/code> source, grant it to a service, and the content appears at <code>\/run\/secrets\/&lt;name&gt;<\/code> inside the container. It is not a secrets manager, since the plaintext still sits on the host, but it keeps credentials out of the environment and out of <code>docker inspect<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How many services is too many for one Compose file?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">There is no hard limit, and the count matters less than the coupling. The signal to split is when a change to one service forces you to think about services that have nothing to do with it, or when one team&#8217;s deploy restarts another team&#8217;s containers. Compose profiles let you group optional services within a file before you commit to splitting into separate projects.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The one thing worth remembering<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Running Docker Compose in production does not fail because Compose is a toy. It fails because the defaults are development defaults, and every one of them is fine right up until the day it isn&#8217;t. Unrotated logs, unlimited memory, published ports, anonymous volumes, health checks nobody watches. None of these announce themselves. They wait.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Go through your compose file once, deliberately, and ask of every service: where do its logs go, what is its memory ceiling, what happens to its data on <code>down<\/code>, who can reach its ports, and what tells you when it is unhealthy. That review takes an afternoon. It is the cheapest reliability work available to you, and it is most of the distance between a Compose stack that quietly burns you and one that just keeps running.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Need a second pair of eyes on your Compose setup?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">I work with teams running containerised workloads on their own servers. Things I am usually brought in for:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Reviewing a production Compose file and daemon config against the failure modes above, with a prioritised list of what to change first<\/li>\n\n<li>Building a zero-downtime deploy path with a reverse proxy and two service slots, wired into your existing CI<\/li>\n\n<li>Fixing disk exhaustion for good: log driver, rotation, image pruning, and alerting that fires before the filesystem does<\/li>\n\n<li>Sorting out network segmentation and published ports so the firewall config on the host is actually the firewall<\/li>\n\n<li>Volume backup and restore that has been tested by restoring, not just by running<\/li>\n\n<li>An honest assessment of whether you should move to Kubernetes or stay where you are, with the reasoning written down<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">If something in your stack is behaving oddly, send me the compose file, the output of <code>docker compose ps --all<\/code>, or the logs from whatever restarted last night. Easier to talk about a real config than a hypothetical one.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/www.upwork.com\/freelancers\/~01f15a912ad84a6620\" target=\"_blank\" rel=\"noreferrer noopener\">Work with me on Upwork<\/a><\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Running Docker Compose in production is a reasonable choice for a single host, but the defaults were chosen for a laptop. A walk through the failure families that actually bite: the deploy gap, unrotated logs filling the disk, anonymous volumes, published ports that bypass your firewall, secrets in environment variables, and health checks that report without acting.<\/p>\n","protected":false},"author":1,"featured_media":160,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[24,23,52],"tags":[9,133,3,8,7,245,21,116,10,17,22,72,4,12,138],"class_list":["post-159","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-docker","category-technical-guides","tag-containers","tag-deployment","tag-devops","tag-docker","tag-docker-compose","tag-health-checks","tag-infrastructure","tag-logging","tag-production","tag-reverse-proxy","tag-self-hosting","tag-sysadmin","tag-troubleshooting","tag-volumes","tag-vps","entry","has-media"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.2 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Docker Compose in Production: What Works, What Burns<\/title>\n<meta name=\"description\" content=\"Docker Compose in production works fine until it does not. The failure modes that actually bite on a real server, and the config that prevents them.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Docker Compose in Production: What Works, What Burns\" \/>\n<meta property=\"og:description\" content=\"Docker Compose in production works fine until it does not. The failure modes that actually bite on a real server, and the config that prevents them.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/\" \/>\n<meta property=\"og:site_name\" content=\"John Nessime\" \/>\n<meta property=\"article:published_time\" content=\"2026-08-07T13:00:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/docker-compose-in-production.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1200\" \/>\n\t<meta property=\"og:image:height\" content=\"627\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"John Nessime\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"John Nessime\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"15 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/\"},\"author\":{\"name\":\"John Nessime\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"headline\":\"Docker Compose in Production: What Works and What Quietly Burns You\",\"datePublished\":\"2026-08-07T13:00:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/\"},\"wordCount\":3311,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/docker-compose-in-production.png\",\"keywords\":[\"Containers\",\"Deployment\",\"DevOps\",\"Docker\",\"Docker Compose\",\"Health Checks\",\"Infrastructure\",\"Logging\",\"Production\",\"Reverse Proxy\",\"Self Hosting\",\"Sysadmin\",\"Troubleshooting\",\"Volumes\",\"VPS\"],\"articleSection\":[\"DevOps\",\"Docker\",\"Technical Guides\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/\",\"name\":\"Docker Compose in Production: What Works, What Burns\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/docker-compose-in-production.png\",\"datePublished\":\"2026-08-07T13:00:00+00:00\",\"description\":\"Docker Compose in production works fine until it does not. The failure modes that actually bite on a real server, and the config that prevents them.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/#primaryimage\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/docker-compose-in-production.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/docker-compose-in-production.png\",\"width\":1200,\"height\":627,\"caption\":\"Iceberg diagram illustrating Docker Compose in production: a small visible tip labelled \\\"It came up. It stayed up.\\\" above the waterline, and a much larger submerged mass listing the hidden failure modes including unrotated container logs, stop-then-start deploys, published ports that bypass the host firewall, and anonymous volumes.\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/docker-compose-in-production\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Docker Compose in Production: What Works and What Quietly Burns You\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\",\"name\":\"John Nessime\",\"description\":\"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained\",\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\",\"name\":\"John Nessime\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"width\":512,\"height\":512,\"caption\":\"John Nessime\"},\"logo\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\"},\"description\":\"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.\",\"sameAs\":[\"https:\\\/\\\/john-nessime.com\\\/blog\",\"https:\\\/\\\/www.linkedin.com\\\/in\\\/john-m-nessime\"],\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/author\\\/johnnessime\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Docker Compose in Production: What Works, What Burns","description":"Docker Compose in production works fine until it does not. The failure modes that actually bite on a real server, and the config that prevents them.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/","og_locale":"en_US","og_type":"article","og_title":"Docker Compose in Production: What Works, What Burns","og_description":"Docker Compose in production works fine until it does not. The failure modes that actually bite on a real server, and the config that prevents them.","og_url":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/","og_site_name":"John Nessime","article_published_time":"2026-08-07T13:00:00+00:00","og_image":[{"width":1200,"height":627,"url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/docker-compose-in-production.png","type":"image\/png"}],"author":"John Nessime","twitter_card":"summary_large_image","twitter_misc":{"Written by":"John Nessime","Est. reading time":"15 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/#article","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/"},"author":{"name":"John Nessime","@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"headline":"Docker Compose in Production: What Works and What Quietly Burns You","datePublished":"2026-08-07T13:00:00+00:00","mainEntityOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/"},"wordCount":3311,"commentCount":0,"publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/docker-compose-in-production.png","keywords":["Containers","Deployment","DevOps","Docker","Docker Compose","Health Checks","Infrastructure","Logging","Production","Reverse Proxy","Self Hosting","Sysadmin","Troubleshooting","Volumes","VPS"],"articleSection":["DevOps","Docker","Technical Guides"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/","url":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/","name":"Docker Compose in Production: What Works, What Burns","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/#primaryimage"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/docker-compose-in-production.png","datePublished":"2026-08-07T13:00:00+00:00","description":"Docker Compose in production works fine until it does not. The failure modes that actually bite on a real server, and the config that prevents them.","breadcrumb":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/#primaryimage","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/docker-compose-in-production.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/docker-compose-in-production.png","width":1200,"height":627,"caption":"Iceberg diagram illustrating Docker Compose in production: a small visible tip labelled \"It came up. It stayed up.\" above the waterline, and a much larger submerged mass listing the hidden failure modes including unrotated container logs, stop-then-start deploys, published ports that bypass the host firewall, and anonymous volumes."},{"@type":"BreadcrumbList","@id":"https:\/\/john-nessime.com\/blog\/devops\/docker-compose-in-production\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/john-nessime.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Docker Compose in Production: What Works and What Quietly Burns You"}]},{"@type":"WebSite","@id":"https:\/\/john-nessime.com\/blog\/#website","url":"https:\/\/john-nessime.com\/blog\/","name":"John Nessime","description":"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained","publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/john-nessime.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105","name":"John Nessime","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","width":512,"height":512,"caption":"John Nessime"},"logo":{"@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png"},"description":"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.","sameAs":["https:\/\/john-nessime.com\/blog","https:\/\/www.linkedin.com\/in\/john-m-nessime"],"url":"https:\/\/john-nessime.com\/blog\/author\/johnnessime\/"}]}},"_links":{"self":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/159","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/comments?post=159"}],"version-history":[{"count":1,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/159\/revisions"}],"predecessor-version":[{"id":161,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/159\/revisions\/161"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media\/160"}],"wp:attachment":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media?parent=159"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/categories?post=159"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/tags?post=159"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}