Docker Compose on a VPS: Fix the Common Pitfalls
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"
| Policy | Behaviour |
|---|---|
no | Never restarts automatically (the default) |
on-failure | Restarts only if the container exits with a non-zero code |
unless-stopped | Restarts on crash or reboot, unless you manually stopped it |
always | Restarts 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 at127.0.0.1:8080as 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-stoppedon 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
.envandenv_filestraight, and recreate (not just restart) after changing values. - Run
docker compose configbefore 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.
Frequently asked questions
Should I use `docker-compose` (with a hyphen) or `docker compose`?
Use `docker compose` (the space-separated form). It's the current V2 plugin built into the Docker CLI and what's installed on new Ubuntu/AlmaLinux VPS images. The hyphenated `docker-compose` is the older standalone Python tool — it still works if installed separately, but it's no longer getting new features and isn't preinstalled on most fresh servers.
Do I still need a firewall if my services are only reachable through Docker's internal network?
Yes. Any port you publish with `ports:` in your compose file is exposed to the whole internet by default, and Docker manages this through iptables rules that sit ahead of UFW — a plain `ufw deny` on that port won't actually block it. Bind sensitive ports to `127.0.0.1` only, or route them through a reverse proxy, rather than relying on the host firewall alone.
How do I see logs for a specific service without wading through all of them?
Run `docker compose logs -f servicename` — the `-f` follows new output, and naming the service filters out the noise from everything else in the stack.
My compose file works locally but fails on the VPS with 'no such image'. Why?
Locally you likely built the image with `docker compose build`, but the image only exists in your laptop's local Docker registry. On the VPS, either push the image to a registry (Docker Hub, GHCR, or a private one) and reference it by tag, or copy the project over and run `docker compose build` on the server itself before `up`.
Is it safe to run `docker compose down -v` to 'clean up' on a live server?
No — the `-v` flag deletes named volumes along with the containers, which means your database and any persisted uploads go with it. Use plain `docker compose down` for a normal stop-and-remove, and only reach for `-v` on a throwaway dev environment where losing the data is fine.