Deploy a Docker Compose stack on a fresh Ubuntu VPS
This guide assumes nothing about your stack beyond “it's defined in a docker-compose.yml” and nothing about your VPS beyond “it's a fresh Ubuntu box with root access.” That covers the overwhelming majority of small self-hosted deployments, whether you're running it on a $5 Hetzner box or something pricier.
We'll go from a brand-new server to a running stack reachable at your own domain over HTTPS, with the process set up to survive a reboot without you having to remember to restart anything.
Prerequisites
Before you start
- A VPS from any provider — we're assuming Ubuntu 24.04 LTS here, but the steps are nearly identical on 22.04. (See our VPS comparison if you haven't picked one yet; Hetzner, DigitalOcean, and Vultr all work fine for this.)
- A domain name, with access to its DNS settings.
- SSH access to the server as root or a sudo-capable user.
- Basic comfort with the command line — we explain each command, but we don't explain what a terminal is.
- A
docker-compose.ymlfile for your stack. If you don't have one yet, the example in Step 4 is a working starting point.
Step 1: Update the server and create a non-root user
SSH into the box as root, update it, and create a dedicated user rather than running everything as root going forward:
apt update && apt upgrade -y
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
Log out and back in as deploy from here on. Disabling root SSH login entirely (in /etc/ssh/sshd_config, set PermitRootLogin no) is worth doing now while you have a clean session to fall back on.
Step 2: Install Docker and the Compose plugin
Ubuntu's own repositories ship an outdated Docker version, so use Docker's official install script instead:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
Log out and back in once more for the group change to take effect, then confirm both Docker and the Compose plugin are present:
docker --version
docker compose version
Step 3: Point your domain at the server
In your domain's DNS settings, add an A record pointing your chosen subdomain (or the bare domain) at the VPS's public IPv4 address:
Type: A
Name: app (or @ for the bare domain)
Value: 203.0.113.42 # your VPS's IP
TTL: 300
DNS propagation is usually minutes, occasionally hours. You can move on to the next steps while you wait — just confirm with dig app.example.com before Step 5, since the reverse proxy's automatic certificate request will fail if the domain doesn't resolve yet.
While you're in the provider's dashboard, open ports 80 and 443 in any firewall or security-group settings if they're not already open — this is the step people most often forget and then spend twenty minutes debugging a stack that's actually running fine.
Step 4: Write the compose file
Here's a minimal but real example — a web app and its database, with no reverse proxy yet (that's next):
services:
app:
image: your-registry/your-app:latest
restart: unless-stopped
environment:
- DATABASE_URL=postgres://app:app@db:5432/app
depends_on:
- db
db:
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=app
- POSTGRES_DB=app
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
Put real secrets in a .env file next to the compose file, not committed anywhere, and reference them with ${POSTGRES_PASSWORD}-style variables instead of hardcoding them as shown above.
Step 5: Add a reverse proxy and bring the stack up
Caddy is the easiest option here because it handles HTTPS certificates automatically with no extra configuration. Add it to the compose file:
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
volumes:
caddy_data:
And a one-line Caddyfile:
app.example.com {
reverse_proxy app:3000
}
Then bring everything up in detached mode:
docker compose up -d
docker compose ps
Caddy will request and renew a Let's Encrypt certificate automatically the first time it sees a request for that domain, as long as DNS is already pointing there and ports 80/443 are reachable from the internet.
Step 6: Make it survive a reboot
The restart: unless-stopped lines in the compose file already cover container restarts, but Docker itself also needs to start on boot:
sudo systemctl enable docker
sudo reboot
SSH back in after it comes up and confirm the stack rejoined on its own:
docker compose ps
If everything shows "Up," you're done — this server will now bring your stack back after a reboot, a provider maintenance restart, or a power blip, without you touching it.
When something doesn't come up
docker compose logs -f is the first command to run for any of these problems — resist the urge to guess before you've read what the container actually said when it failed.
- Caddy can't get a certificate: almost always DNS hasn't propagated yet, or port 80/443 is blocked by a cloud firewall rule outside the OS itself. Check both before assuming Caddy is broken.
- The app container can't reach the database: confirm both services are on the same Compose network (the default, unless you've overridden it) and that you're using the service name (
db) rather thanlocalhostas the hostname — this is the single most common mistake in a first Compose deployment. - Everything was fine until the reboot: check
systemctl status docker— if Docker itself didn't start on boot, nothing it manages will either. - The stack works but disk space keeps climbing: old images and unused volumes accumulate quietly with every redeploy.
docker system prune -af(and--volumesif you're sure nothing important is sitting in an orphaned volume) is worth running after any update, and worth automating with a weekly cron entry rather than remembering it only once the disk is already full.
Once the stack is stable, it's worth spending ten more minutes on two things that aren't strictly required but will save you a bad night eventually: setting up log rotation for the containers (docker's default json-file log driver has no size cap unless you configure one, and a noisy container can fill a disk with its own logs), and pointing the monitoring tool of your choice at the new domain so you find out about the next outage from an alert instead of from a user.