← All posts

Running Docker Compose in production on a single server: a practical setup

When one well-run Linux server is enough for your product, and how to set it up with Docker Compose, a reverse proxy, backups, deploys and monitoring so it stays boring.

Author
Elie Chaaban
Published
Reading time
6 min
Topics
Docker, DevOps, Linux, Infrastructure

Most early-stage products do not need Kubernetes, a managed container platform or a multi-region setup. They need one server that is set up properly, deploys predictably, backs itself up and tells someone when it is unwell. Docker Compose on a single Linux machine covers that surprisingly far, and it is cheap enough that the budget goes into the product instead of the platform.

This is the setup I reach for when a startup or small business needs a web app, an API, a database and a few background jobs running reliably. It is not the only valid approach, but every piece here exists because I have seen what happens without it.

When a single server is the right call

A single server is a good fit when:

It is the wrong call when you have strict uptime commitments in a contract, regulatory requirements for redundancy, or workloads that genuinely spike beyond what one machine can handle. Be honest about which group you are in. "We might go viral" is not a capacity plan; a load test is.

The important part is that a well-structured Compose setup does not trap you. Each service is already a container with its configuration in environment variables, so moving to a bigger machine, a second server or a managed platform later is a migration, not a rewrite.

The shape of the stack

A typical layout looks like this:

Only the proxy publishes ports to the internet. Everything else talks over an internal Docker network:

services:
  caddy:
    image: caddy:2
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
  api:
    image: registry.example.com/app/api:${API_TAG}
    env_file: .env.api
    depends_on: [db]
  db:
    image: postgres:16
    env_file: .env.db
    volumes:
      - pg_data:/var/lib/postgresql/data
volumes:
  caddy_data:
  pg_data:

Note what is not there: no ports: on the database. A Postgres port exposed to the internet with a weak password is one of the most common ways small projects get compromised. If you need to reach the database from your laptop, use an SSH tunnel.

Harden the host before anything else

The container setup is only as safe as the machine under it. The minimum I apply on a fresh server:

  1. SSH with keys only; password login and root password login disabled.
  2. A firewall that allows 22, 80 and 443 and nothing else. Remember that Docker manipulates iptables directly, so ports you publish in Compose can bypass a host firewall like ufw. The safe habit is simply not publishing ports you do not want public.
  3. Unattended security updates for the OS.
  4. A non-root user for day-to-day work, and the Docker socket treated as root-equivalent, because it is.
  5. Time sync, a sensible hostname and swap configured, so memory spikes slow things down instead of killing the database.

None of this is exotic. It is just easy to skip when the goal is "get it live by Friday".

Configuration and secrets

Keep configuration in environment files that live on the server, outside the repository, with tight file permissions. Commit an .env.example that lists every variable with a placeholder so nobody has to guess what a service needs.

Pin image versions. postgres:latest will eventually upgrade a major version underneath you on a routine pull, and a database major upgrade is something you want to do deliberately, with a backup in hand.

Deploys that are boring

The goal is a deploy you can run on a Friday afternoon without thinking about it. A simple, reliable flow:

  1. CI builds the image, runs tests and pushes it to a registry with an immutable tag (the commit SHA, not just latest).
  2. The deploy step connects to the server, updates the tag in the environment, then runs docker compose pull and docker compose up -d for the changed services.
  3. A health check confirms the new version answers correctly; if not, you set the previous tag and run the same command again.

Because tags are immutable, rollback is the same operation as a deploy. That symmetry matters more than any tool choice. Database migrations are the one part that is not automatically reversible, so write them to be backwards compatible: add columns before the code uses them, and remove old ones in a later release.

Add health checks to your containers and restart: unless-stopped so a crashed process comes back on its own. Docker restarting a service is not a substitute for knowing why it crashed, but it buys you time.

Backups you have actually restored

A backup strategy has three parts, and most setups only have the first:

Write down how long a restore takes and how much data you could lose between backups. Those two numbers are what a client or investor actually wants to know when they ask "are we backed up?".

Monitoring and logs

On one server, you need to know four things quickly: is the site up, is the machine running out of disk or memory, are errors increasing, and what happened just before something broke.

Hosting several projects on one machine

A single server can host several small sites and apps safely if you keep them separated: one Compose project per application, each with its own network and volumes, and one shared reverse proxy that owns ports 80 and 443. Adding a site then means adding a block to the proxy config and reloading it, without touching the other projects. Always validate the proxy configuration before reloading, because a syntax error there takes every site down at once.

Knowing when to move on

Signs you have outgrown one server: you are scaling the machine up repeatedly, deploys cause noticeable downtime that matters to users, the database and app compete for resources, or you now have uptime commitments. The usual next steps, in order, are moving the database to a managed service or its own machine, then running two app servers behind a load balancer. Because everything is already containerized and configured by environment, each step is contained.

A short checklist

This is how the products in my Pro Clean+ and NeonTask case studies are built and run: containerized services, CI/CD and a setup one person can operate with confidence. If you want a second pair of eyes on your current server or a setup like this built for you, get in touch.

More from the blog

Let's work
together