Docker Compose on a VPS: Fix the Common Pitfalls

· 7 min read · 20 views · Getwebup

Docker Compose looks simple until you put it on a real VPS next to a real web stack. The docker-compose.yml that worked fine on your laptop suddenly loses data on reboot, fights with Nginx over port 80, or writes files your web server user can't read. None of that is Docker being flaky — it's a handful of predictable gaps between a dev laptop and a production box. Here's what actually goes wrong and how to fix each one.

Issue 1: Containers don't come back after a reboot

Symptom: You reboot the VPS for a kernel update, come back an hour later, and the app is down. docker ps shows nothing running. No crash, no error in the logs — the containers just never started.

Cause: Compose's default restart policy is no. Docker only restarts a container automatically if you told it to. A reboot stops the Docker daemon along with everything it was running, and without a restart policy nothing tells those containers to come back up when the daemon does.

Fix: Set an explicit restart policy on every service in your compose file:

services:
  app:
    image: myapp:latest
    restart: unless-stopped
  db:
    image: postgres:16
    restart: unless-stopped

Then make sure the Docker daemon itself is enabled to start on boot:

sudo systemctl enable docker
sudo systemctl is-enabled docker   # should print "enabled"
PolicyBehaviour
noNever restarts automatically (the default)
on-failureRestarts only if the container exits with a non-zero code
unless-stoppedRestarts on crash or reboot, unless you manually stopped it
alwaysRestarts no matter what, even if you stopped it and didn't restart it before a reboot

For almost every production service, unless-stopped is the right call. always can fight you when you deliberately stop a container for maintenance and reboot before starting it again.

Issue 2: "port is already allocated" — Docker vs. your existing web server

Symptom: docker compose up fails with something like:

Error response from daemon: driver failed programming external connectivity on endpoint app:
Bind for 0.0.0.0:80 failed: port is already in use

Cause: Most VPS boxes we see this on already have Apache, Nginx, or a cPanel-managed web server bound to port 80/443 from before Docker was installed. Compose doesn't know or care — it just tries to grab the port and loses.

Fix: Decide which side owns port 80/443. Two clean options:

  • Let the host web server be the front door. Bind your container to an internal-only port and reverse-proxy to it: ports: ["127.0.0.1:8080:80"], then point Nginx or Apache at 127.0.0.1:8080 as an upstream.
  • Let Docker own the port. Stop and disable the host web server (sudo systemctl disable --now apache2) and let a container — often an Nginx or Traefik container — bind 80/443 directly.

Trying to run both on the same port at the same time is the one combination that never works. Check what's actually holding a port before you assume it's Docker's fault:

sudo ss -tulpn | grep ':80 '

Issue 3: Data disappears every time you run docker compose down

Symptom: You redeploy, run docker compose down && docker compose up -d, and the database is empty again. Uploads, sessions, everything reset.

Cause: The data was only ever living inside the container's writable layer, not in a volume. down removes containers (and their layers) by design; docker compose down -v is even worse and deletes named volumes too. If your compose file has no volumes: section for the database, there was never anywhere for the data to survive a container recreate.

Fix: Give stateful services a named volume, and never run -v against production:

services:
  db:
    image: mysql:8
    volumes:
      - db_data:/var/lib/mysql

volumes:
  db_data:

Confirm the volume actually persists across a recreate:

docker compose down
docker compose up -d
docker exec -it db_container mysql -u root -p -e "SHOW DATABASES;"

If you need the data on the host filesystem for your own backup tooling, use a bind mount instead of a named volume — but that brings you straight to the next issue.

Issue 4: "Permission denied" on bind-mounted volumes

Symptom: The app container can't write to a mounted folder, throws EACCES or Permission denied, even though the directory looks fine when you ls -la it from the host.

Cause: Most base images run as a non-root user with a UID that has nothing to do with the host account that owns the mounted folder. If your host directory is owned by ubuntu (UID 1000) but the container runs as UID 82 (common for PHP/www-data images) or a randomly assigned UID, the container's user simply isn't allowed to write there — permissions are enforced by UID number, not by username, and those don't line up across the host/container boundary.

Fix: Match ownership to whatever UID the container actually runs as. Find it first:

docker compose run --rm app id
# uid=82(www-data) gid=82(www-data) groups=82(www-data)

Then chown the host directory to match:

sudo chown -R 82:82 ./uploads

Or, if the image supports it, pin the container to run as your host user instead:

services:
  app:
    user: "1000:1000"

Do this once when you first wire up the volume, not after you've already lost a few hours to a mystery 500 error.

Issue 5: One service can't reach another by hostname

Symptom: Your app container throws could not translate host name "db" to address or connects to localhost and gets refused, even though both containers are clearly running.

Cause: Compose puts every service in a project on its own bridge network and registers each service name as a DNS entry on that network. localhost inside the app container refers to the app container itself, not the db container next to it — that's a different machine as far as networking is concerned. The service name is the hostname to use, and it only resolves for containers on the same compose network.

Fix: Point connection strings at the service name, not localhost or an IP:

DB_HOST=db
DB_PORT=3306

If two compose files need to talk to each other (a common setup when the app and a monitoring stack are deployed separately), put them on a shared external network:

networks:
  shared_net:
    external: true

and reference shared_net in both files. Without that, each docker compose up gets its own isolated network by default and the two stacks simply can't see each other.

Issue 6: .env values aren't showing up in the container

Symptom: You update a value in .env, redeploy, and the app is still using the old setting — or an entirely blank one.

Cause: Two separate mechanisms get confused for one. A top-level .env file next to your compose file is used for variable substitution inside the YAML itself (things like ${IMAGE_TAG}). It does not automatically become environment variables inside the container unless you also list them under environment: or env_file: for that service. And even when it's wired up correctly, Compose only re-reads changed values on a recreate, not on a plain restart.

Fix: Be explicit about which file feeds the container:

services:
  app:
    env_file:
      - .env

After changing values, force a recreate rather than a restart:

docker compose up -d --force-recreate app

A plain docker compose restart reuses the existing container as-is, environment and all.

Prevention checklist

  • Set restart: unless-stopped on every long-running service before you forget.
  • Decide up front whether Docker or the host web server owns ports 80/443 — don't let it get decided by whichever one starts first.
  • Put a named volume on anything that stores data you'd be upset to lose.
  • Check the container's actual UID before wiring up a bind mount, not after a permissions error.
  • Use service names for inter-container hostnames, never localhost.
  • Keep .env and env_file straight, and recreate (not just restart) after changing values.
  • Run docker compose config before deploying — it prints the fully resolved config and catches most of these mistakes before they hit production.

If you're moving an existing Compose stack onto a Getwebup VPS, our support team can review the compose file with you before go-live and flag exactly this kind of gap — it's a five-minute check that saves a much longer incident later.

Questions people actually ask