Key takeaways
- Install Docker Engine and the docker compose plugin from Docker's own apt repository, not from Ubuntu's docker.io package or the snap.
- Publish the app port as 127.0.0.1:3000:3000, because Docker-published ports bypass UFW and a plain 3000:3000 is open to the internet.
- Run Nginx on the host in front of the container and let Certbot's Nginx plugin issue and renew the certificate.
- Give every long-running service restart: unless-stopped, a healthcheck and a log size limit.
- Tag each image with its commit, so a rollback is starting the previous tag with --no-build instead of rebuilding old code.
- On a small VPS, build the image elsewhere and ship it with docker save and docker load over SSH, or through a registry.
To deploy a Docker Compose app to a VPS, install Docker Engine and the Compose plugin from Docker's own apt repository, put your project and its .env file on the server, run docker compose up -d --build, and put Nginx with a Certbot certificate in front of an app port that is published on 127.0.0.1 only. That last detail matters more than it looks: a port published the default way is reachable from the whole internet even when UFW says it is closed. This guide walks through the full setup on Ubuntu 24.04, then covers the five ways it usually breaks.
What do you need before you start?
- A VPS running Ubuntu 24.04 LTS. Debian 12 works with the same commands if you replace
ubuntuwithdebianin Docker's two repository URLs. - SSH access as a non-root user with sudo, and key-based login.
- A domain with an A record (and an AAAA record if the server has IPv6) pointing at the server.
- A project with a
Dockerfileand acompose.yaml. The olderdocker-compose.ymlname still works. - Ports 22, 80 and 443 open in your provider's network firewall, if it has one in front of the server.
Memory matters mainly for building. Running a small web app and a database is comfortable on 1 GB of RAM, but building a Node or Next.js image on the same machine can exhaust it. If the server is small, plan to add swap or build somewhere else; both are covered below.
If the server is brand new, do the basic hardening first: a sudo user, key-only SSH, UFW allowing SSH. The Node.js on a VPS guide covers those steps, and none of them change for Docker.
How does a Compose app fit together on one server?
The layout in this guide keeps TLS and domain routing on the host and everything application-specific inside Compose. Nginx runs on the host and owns ports 80 and 443. It forwards requests to the app container through a port that only exists on the loopback interface. The database container publishes no port at all; the app reaches it by its service name on the network Compose creates for the project.
| Piece | Where it runs | What it does |
|---|---|---|
| Nginx | Host, ports 80 and 443 | Terminates HTTPS, routes the domain, proxies to the app |
| Certbot | Host, systemd timer | Issues the certificate and renews it |
| web container | Docker, published on 127.0.0.1:3000 | Your application |
| db container | Docker, no published port | Database, reachable only on the Compose network |
| Named volume | Docker storage under /var/lib/docker | Database files that survive rebuilds |
Why Nginx on the host instead of in a container? Certbot's Nginx plugin works on it directly, one Nginx can front several Compose projects, and restarting or rebuilding one project never takes the proxy down with it. Running Caddy or Traefik as a container is a valid alternative; the port-publishing rules below apply to it just the same.
Step-by-step: deploy a Docker Compose app on Ubuntu 24.04
Step 1: Remove conflicting Docker packages (2 minutes)
Ubuntu has its own docker.io package, and the Ubuntu Server installer offers a Docker snap. Both are packaged by third parties, trail Docker's own releases, and are not what Docker's documentation assumes. The snap also keeps its data under /var/snap/docker rather than /var/lib/docker, which confuses every guide you will read later. Remove them first:
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
sudo apt-get remove -y $pkg
done
sudo snap remove docker 2>/dev/null || trueOn a fresh server apt will report that most of these are not installed. That is fine.
Step 2: Install Docker Engine and the Compose plugin from Docker's repository (5 minutes)
Add Docker's signing key and repository, then install the engine, the CLI, containerd, Buildx and the Compose plugin. These are the commands from Docker's Ubuntu install page:
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginConfirm the daemon starts at boot and that it runs a container:
sudo systemctl enable --now docker containerd
sudo docker run --rm hello-world
sudo docker compose versionThe plugin gives you docker compose, with a space. The old Python docker-compose with a hyphen is end-of-life; scripts that still call it need updating.
Step 3: Add your deploy user to the docker group, knowing what that grants (2 minutes)
Without this, every Docker command needs sudo:
sudo usermod -aG docker $USER
exit # log out completely, then SSH back in
docker psGroup membership is read at login, so the change only applies to new sessions. newgrp docker applies it to the current shell if you cannot reconnect.
Understand what you just did: membership of the docker group is equivalent to root. Anyone in it can run docker run -v /:/host and get a root shell on the host filesystem. Only add an account you would trust with sudo anyway, never one that runs untrusted code, and treat that account's SSH key like a root key. If that is unacceptable, Docker's rootless mode runs the daemon as an ordinary user; it has its own setup and networking limits, and this guide assumes the standard daemon.
Step 4: Put the project on the server (5 minutes)
Give the app a directory owned by the deploy user. /srv/myapp is used throughout this guide.
sudo mkdir -p /srv/myapp
sudo chown $USER:$USER /srv/myapp
git clone git@github.com:acme/myapp.git /srv/myappCloning needs the server to authenticate to GitHub; a read-only deploy key added to the repository is the narrowest way. If you would rather not give the server repository access, copy the files from your machine instead:
rsync -az --delete --exclude .git --exclude node_modules --exclude .env ./ deploy@203.0.113.10:/srv/myapp/Excluded files are protected from --delete, so the server's own .env survives every sync.
Whichever way the code arrives, give the project a .dockerignore so the build context stays small and secrets stay out of it:
.git
node_modules
.env
*.logStep 5: Create the .env file and keep it out of the image (3 minutes)
Compose uses a .env file in two separate ways, and mixing them up is a common source of "my variable is empty" bugs:
- A
.envfile next tocompose.yamlis read by Compose itself, to fill in${VARIABLE}placeholders in the compose file. Those values do not reach the container unless the compose file passes them. env_file:on a service loads a file into that container's environment at runtime.
One file can do both jobs. Create it on the server and lock it down:
cd /srv/myapp
openssl rand -hex 24 # use the output as the database password
nano .env
chmod 600 .envPOSTGRES_PASSWORD=paste-the-random-value-here
DATABASE_URL=postgres://app:paste-the-random-value-here@db:5432/app
NODE_ENV=productionThe host in DATABASE_URL is db, the service name; Compose's internal DNS resolves it to the database container. Never COPY the .env file into an image or pass secrets as build arguments. Both end up in image layers or history, where anyone who has the image can read them. docker compose config prints the fully resolved file when you need to check what Compose sees, secrets included, so do not paste its output anywhere public.
Step 6: Write the compose file with a localhost-only port (5 minutes)
services:
web:
build: .
image: myapp-web:${APP_TAG:-latest}
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:3000:3000"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
pgdata:The lines that matter:
"127.0.0.1:3000:3000"publishes the app on the loopback interface only. Nginx on the same host can reach it; nothing on the internet can. Docker publishes ports by writing its own firewall rules, and traffic to a published port is redirected to the container before UFW's rules are consulted. A plain"3000:3000"is therefore open to the world no matter whatufw statussays. Docker's packet filtering page documents this behaviour.- The database has no
ports:entry. The app reaches it on the Compose network, and it never needs to be reachable from outside. image: myapp-web:${APP_TAG:-latest}names the image Compose builds. Tagging each build with its commit gives you something to roll back to.healthcheckruns inside the container, so the command has to exist there. Alpine images have BusyBoxwget, slim Debian images have neithercurlnorwget, and a Node image can always usenodeitself, as above. Use127.0.0.1rather thanlocalhost, which can resolve to IPv6 where the app is not listening. Your app needs a/healthroute that returns 200 when it can serve requests.depends_onwithcondition: service_healthymakes Compose wait for Postgres to accept connections before it starts the app.loggingcaps each container's log at three files of 10 MB. Docker's defaultjson-filedriver has no limit at all.pgdatais a named volume, so the database files survivedocker compose downand every rebuild.
Step 7: Build and start the stack (5 minutes)
cd /srv/myapp
export APP_TAG=$(git rev-parse --short HEAD)
docker compose up -d --build --wait
docker compose ps
curl -fsS http://127.0.0.1:3000/health--wait makes the command return only when every service is running and healthy, and exit with an error if one never gets there, which is what you want in a script. If you copied the files with rsync, use something like APP_TAG=$(date +%Y%m%d%H%M) instead of the commit.
Now prove the port is private. On the server, sudo ss -tlnp | grep 3000 should show 127.0.0.1:3000, not 0.0.0.0:3000. From your own computer, curl -m 5 http://203.0.113.10:3000 should fail to connect.
Step 8: Put Nginx in front as a reverse proxy (5 minutes)
Install Nginx and open only SSH, HTTP and HTTPS in UFW:
sudo apt-get install -y nginx
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableCreate /etc/nginx/sites-available/myapp:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name app.example.com;
client_max_body_size 25m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}The map block lets WebSocket upgrades through without forcing Connection: upgrade on every request. It may only be defined once across all of Nginx, so when you add a second app, move it to its own file in /etc/nginx/conf.d/. The Nginx config generator produces this block for other ports and domains.
Enable the site, test the configuration, and reload:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginxStep 9: Add HTTPS with Certbot (3 minutes)
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com
sudo certbot renew --dry-runCertbot proves you control the domain over port 80, then adds the certificate and an HTTPS listener to the server block. It can also add the redirect from HTTP to HTTPS; pass --redirect to be explicit about it. The Ubuntu package installs a systemd timer that attempts renewal twice a day, and the dry run proves renewal will work. The Certbot site recommends its snap package instead; either is fine, but use one, not both.
Open https://app.example.com. If you get a 502, the app is not answering on 127.0.0.1:3000; check docker compose ps before you touch Nginx.
Which restart policy should you use?
The restart policy decides what Docker does when a container's process exits, and whether the container comes back when the Docker daemon starts after a reboot.
| Policy | What it does | Use it for |
|---|---|---|
| no | Never restarts the container. This is the default. | One-off tasks such as migrations |
| on-failure | Restarts only when the process exits with a non-zero code. Accepts a retry limit, such as on-failure:5. | Workers that should stay stopped after a clean exit |
| always | Restarts whenever the process stops. A container you stopped by hand comes back when the daemon restarts, including after a reboot. | Rarely what you want |
| unless-stopped | Like always, but a container you stopped by hand stays stopped across daemon restarts and reboots. | Web apps, databases, anything long-running |
Two details trip people up. A restart policy only takes effect once a container has stayed up for about ten seconds, which stops a container that cannot start at all from looping forever. And outside Swarm, Docker does not restart a container because its health check reports it unhealthy: the status changes, depends_on and --wait read it, and nothing else happens. If the process hangs rather than exits, you need to notice the unhealthy status and restart it yourself.
How do you read logs and check container health?
docker compose ps # state and health of each service
docker compose logs -f --tail=200 web # follow one service
docker compose logs --since 30m # every service, last half hour
docker inspect --format '{{json .State.Health}}' myapp-web-1
docker stats --no-stream # CPU and memory per containerCompose names containers <project>-<service>-<number>, and the project name defaults to the directory name, so the web container here is myapp-web-1. The health output includes the last few check results with their output, which usually tells you why a container is marked unhealthy.
Where does a Docker Compose deploy break?
The app is reachable on its port even though UFW blocks it
Symptom: ufw status shows only 22, 80 and 443 allowed, yet http://your-ip:3000 loads the app, bypassing Nginx and HTTPS. A database published as "5432:5432" is exposed the same way, and internet-wide scanners look for exactly that.
Cause: Docker writes NAT rules that redirect traffic for published ports to the container before it reaches the chains UFW manages. UFW is not broken; it never sees those packets.
Fix: publish on loopback, as in the compose file above, and recreate the container with docker compose up -d. If the database only serves this app, remove its ports: entry entirely. As a safety net for the whole server, set the daemon's default bind address in /etc/docker/daemon.json so a forgotten prefix publishes on loopback instead of every interface:
{
"ip": "127.0.0.1"
}Restart Docker after changing it (sudo systemctl restart docker), then check with sudo ss -tlnp that nothing you did not intend is listening on 0.0.0.0.
Containers do not come back after a reboot
Check three things. The container needs a restart policy: docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' myapp-web-1 must not print no. The Docker service must be enabled: systemctl is-enabled docker. And with unless-stopped, a container you stopped by hand before the reboot stays stopped by design.
There is a subtler case. After a reboot it is the Docker daemon that starts containers again, not Compose, so depends_on ordering is not applied. If the app crashes when the database is not ready yet, the restart policy will retry it, but an app that retries its database connection on its own is more robust. Test the whole thing once with sudo reboot before you need it.
The disk fills up with images, build cache and logs
df -h shows the root filesystem full, and docker system df shows where it went. Three things grow without limit by default: old images from every rebuild, the build cache, and container logs. Container logs live in /var/lib/docker/containers, one JSON file per container, and a chatty app can write gigabytes of them.
Set a default log limit for every container in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}Merge it with the ip setting above if you use both. Daemon-level log options apply only to containers created after the change, so restart Docker and recreate the stack with docker compose up -d --force-recreate. Then prune old images and build cache, as shown in the maintenance section below.
The build is slow or gets killed on a small VPS
Symptoms: the build crawls, the SSH session freezes, or it fails with exit code: 137 or Killed during npm ci or next build. journalctl -k | grep -i oom confirms the kernel's out-of-memory killer ended it.
The quick fix is swap, which turns a crash into a slow build:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstabThe better fix is to stop building on the server. Build the image on a bigger machine, for the server's architecture, and ship it. uname -m on the server prints x86_64 (build for linux/amd64) or aarch64 (build for linux/arm64). A laptop with an ARM chip builds ARM images by default, and running one on an x86 server fails with exec format error.
# on your machine, in the project directory
docker buildx build --platform linux/amd64 -t myapp-web:a1b2c3d --load .
docker save myapp-web:a1b2c3d | gzip | ssh deploy@203.0.113.10 'gunzip | docker load'
# on the server
cd /srv/myapp
APP_TAG=a1b2c3d docker compose up -d --no-build --wait--no-build tells Compose to use the image you loaded rather than build its own. If you already use a container registry, docker push from your machine and docker compose pull on the server does the same job, with the registry holding every version.
"permission denied while trying to connect to the Docker daemon socket"
The full message ends with unix:///var/run/docker.sock. Either your user is not in the docker group, or it was added but your session predates the change. Run groups: if docker is missing from the list, log out completely and back in. A terminal multiplexer such as tmux, or an SSH connection kept open with ControlMaster, can keep the old session alive, so close those too.
Do not fix it with chmod 666 /var/run/docker.sock. That gives every account on the machine root access through Docker. A different message, "Cannot connect to the Docker daemon ... Is the docker daemon running?", means the daemon itself is down: sudo systemctl status docker and journalctl -u docker say why.
Should you build images on the server or somewhere else?
| Approach | Server RAM needed | Extra setup | Good for |
|---|---|---|---|
| docker compose up --build on the server | Enough for the build, plus swap | None | Small images, servers with room to spare |
| Build locally, docker save and load over SSH | Only enough to run the app | Buildx on your machine | Small VPSes, no registry wanted |
| Push to a registry, pull on the server | Only enough to run the app | A registry and credentials on the server | Several servers, or CI already producing images |
The first is the simplest to start with. Move to one of the other two the first time a build slows down, or kills, the app it is replacing.
How Shipown deploys Docker and Compose apps
Shipown is a desktop app for Windows and Linux that does the steps above over SSH, with no agent installed on the server. What it does for a Docker project:
- You point it at a GitHub repository and branch, or a folder on your computer, that contains a Dockerfile or a Compose file. It shows the plan (commands, port, domain and target) and every value can be edited before anything runs.
- It builds on the server, or builds images for the server's platform with Linux Docker on your PC and sends them over SSH for
docker load, the same idea as the save-and-load route above, without a remote rebuild. PC-built artifacts are checksum-verified, and the build output streams from whichever machine is doing the work. - Containers are published behind Nginx. For the domain it writes the virtual host, tests the configuration before reloading, puts back any change Nginx rejects, and asks Certbot for the certificate. A Docker-only server can get domains and HTTPS without installing Node.js or PM2.
- Each image is labelled with the commit it came from. The previous container is kept until the new one passes its health check, a failed release restores the previous container or Compose release, and retained images let you roll back to an earlier image or release.
- A server without Docker can have it installed from the app.
- The container view lists every container on the server, not only the ones it deployed: image tags, state, published versus internal ports, Nginx routing, CPU and memory, restart policy and restart count, volumes, a shell inside the container and its logs. Crash-looping containers are listed first with their last log lines, and start, stop and restart run over SSH.
- Docker events stream over the same SSH connection, so a container that exits, runs out of memory or turns unhealthy shows up in the live feed in about a second.
What stays yours: the Dockerfile and compose file, the DNS record, what your health endpoint actually checks, backups of the data in your volumes, and the decision to deploy. Shipown tells you how many commits a branch is ahead of what a server runs, but a person presses deploy; it does not deploy on push. Docker's own housekeeping, such as log limits in daemon.json, is worth setting exactly as described above. The Docker module docs cover the container screens in detail, and the app is on the download page.
What should you do after the first deploy?
Ship updates
cd /srv/myapp
git pull
export APP_TAG=$(git rev-parse --short HEAD)
docker compose up -d --build --waitCompose recreates only the services whose image or configuration changed, so the database keeps running. If you build elsewhere, replace the build with docker compose pull or a docker load, then docker compose up -d --no-build --wait.
Expect a short gap while the web container is replaced: the old one has to stop before the new one can take 127.0.0.1:3000. Removing that gap means running the new version on a second port, checking it, and switching Nginx's upstream, the same idea as the release-directory pattern in the guide to rolling back a bad deploy.
Roll back by tag
Because every image is tagged with its commit, the previous version is still on disk. List them, then start the one you want without building:
docker image ls myapp-web
APP_TAG=e4f5a6b docker compose up -d --no-build --waitRolling back the image does not roll back the database. If the bad release ran a migration, read the section on schema changes in the rollback guide before you switch.
Prune old images and build cache
docker system df
docker image prune -f # dangling layers only
docker image prune -a -f --filter "until=336h" # unused images older than 14 days
docker builder prune -f --filter "until=336h"prune -a removes every image no container is using, which includes the tags you would roll back to, so the age filter is what keeps your recent history. Run it weekly from cron; the cron expression generator helps with the schedule. Never run docker compose down -v or docker volume prune --all on a server with a database volume unless you mean to delete the data.
Back up the volume
A named volume survives rebuilds, not disk failures. Dump the database on a schedule and copy the dump off the server:
mkdir -p ~/backups
docker compose exec -T db pg_dump -U app app | gzip > ~/backups/app-$(date +%F).sql.gzThe -T flag disables the pseudo-terminal, which a pipe needs. Restore a dump to a scratch database once, so you know the backup works before you need it.
Watch it
Check docker compose ps for unhealthy services, keep an eye on docker stats for memory creeping toward the server's limit, and add an external uptime check on the HTTPS URL. A container that is restarting every few minutes shows up as a climbing restart count in docker inspect long before users report it.
Frequently asked questions
Why is my Docker container reachable even though UFW blocks the port?
Docker publishes ports with its own NAT rules, and that traffic is redirected to the container before UFW's rules are checked. A port published as 3000:3000 is therefore open on every interface whatever ufw status says. Publish it as 127.0.0.1:3000:3000 and put Nginx in front, or set "ip": "127.0.0.1" in /etc/docker/daemon.json as a server-wide default.
Should I use docker-compose or docker compose?
Use docker compose, the Compose V2 plugin installed by the docker-compose-plugin package. The hyphenated docker-compose is the old Python tool and is end-of-life. Most compose files written for it work unchanged with the plugin, but scripts that call the old command need updating.
Do I need Nginx in front of a Docker Compose app?
Not strictly, but on a VPS it is the simplest way to get HTTPS, serve several apps on ports 80 and 443, and keep the app's own port private. Nginx on the host with Certbot is the setup this guide uses; a reverse proxy container such as Caddy or Traefik is a valid alternative.
How do I update a Docker Compose app without losing data?
Pull the new code and run docker compose up -d --build. Compose recreates only the services whose image or configuration changed, and data in named volumes survives the container being replaced. Never run docker compose down -v on a production stack, because -v deletes the named volumes.
How do I make Docker containers start again after a reboot?
Give each service restart: unless-stopped and make sure the Docker service is enabled with systemctl enable docker. After a reboot it is the Docker daemon, not Compose, that starts the containers, so depends_on ordering is not applied and the app should retry its database connection.
Is it safe to add my user to the docker group?
It is equivalent to giving that user root, because any member of the docker group can start a container that mounts the host's filesystem. Only add accounts you would trust with sudo. If that is not acceptable, use Docker's rootless mode or keep running Docker commands with sudo.
How much memory does a VPS need to build Docker images?
Running a small app and its database fits in 1 GB, but building a Node or Next.js image on the same server can run out of memory and be killed with exit code 137. Add a swap file, or build on another machine for the server's architecture and transfer the image with docker save and docker load or through a registry.
