In short
A small server with 1 vCPU and 1 to 2 GB RAM is enough for a small app. Docker Compose starts the app with the web server Caddy in front of it. Caddy gets the HTTPS certificate itself and renews it automatically, so there is no certbot cron job to look after. The key rule: only Caddy publishes ports, the app does not.
This guide takes an app that already runs in Docker to a fresh Ubuntu server (24.04 or 26.04 LTS) and puts it on your own domain with a valid HTTPS certificate.
What you need
- A server (VPS) with 1 vCPU and 1 to 2 GB RAM for a small app. Any provider works; the VPS provider comparison helps you pick one.
- A domain whose DNS records you can edit.
- An app that runs in Docker and listens on a port (
3000here).
Step by step
Point your domain at the server
Create an
Arecord forapp.your-company.comwith the server's IPv4 address, and anAAAArecord if it has IPv6. Do this first: Caddy can only get a certificate once the domain points at the server. Check withdig +short app.your-company.com.Lock down the server
Create your own user, allow SSH login with keys only, limit the firewall to the ports you need.Install Docker
From Docker's official package repository, then checkdocker compose version.Write the Compose file and Caddyfile
The app without published ports, Caddy in front of it on ports 80 and 443.Start it
docker compose up -dand watch the Caddy log while the certificate is issued.
Lock down the server
Log in as root once, create a normal user and give it your SSH key:
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deployThen turn off password and root login. Ubuntu reads the files in /etc/ssh/sshd_config.d/ first, and for most settings the first value found wins. A file whose name starts with 00- therefore also beats defaults that some provider images put there:
printf "PasswordAuthentication no\nPermitRootLogin no\n" | sudo tee /etc/ssh/sshd_config.d/00-hardening.conf
sudo sshd -t
sudo systemctl restart ssh.serviceKeep your current session open and test logging in as deploy in a second terminal before you log out. Otherwise one typo locks you out.
Open only the ports you need:
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enableDocker bypasses ufw
Any port you publish with ports: in Compose is reachable from the internet, even if ufw blocks it. Docker routes the traffic around the ufw rules (Docker
documentation). That is why, below, only Caddy publishes ports and the app does not.
Install Docker
Install Docker from the official package repository as described in Docker's guide for Ubuntu. Docker itself recommends the convenience script from get.docker.com only for testing and development, not for production. Afterwards:
sudo usermod -aG docker deploy # log out and back in afterwards
docker compose versionThe docker group effectively gives a user root rights. Only add users you would trust with that.
The Compose file
Create ~/app/compose.yaml:
services:
app:
image: ghcr.io/your-company/your-app:1.0.0 # or build: .
restart: unless-stopped
environment:
- NODE_ENV=production
# no "ports:" on purpose: only Caddy talks to the app
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./conf:/etc/caddy
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:And ~/app/conf/Caddyfile:
app.your-company.com {
encode
reverse_proxy app:3000
}app is the Compose service name; Docker's internal DNS resolves it. encode without arguments compresses with zstd and gzip. Caddy gets the conf folder instead of the single file because the Caddy image advises against mounting the Caddyfile directly: some editors replace the file when saving, and the container then doesn't see the change.
Start it
cd ~/app
docker compose up -d
docker compose logs -f caddyShortly after, the Caddy log reports that the certificate was issued. Open https://app.your-company.com and the app is live.
The caddy_data volume holds your certificates and keys. Don't delete it: Caddy would have to request everything again, and you can run into Let's Encrypt's rate limits.
Deploying updates
docker compose pull app && docker compose up -d app
docker image prune -fTo deploy on every push, a GitHub Actions job can SSH into the server and run the same two commands.
Backups
The server is now the only copy of everything your app writes. At minimum:
- Keep data in a named volume or a bind mount, never only inside the container.
- Back up the database on a schedule, for Postgres for example with
docker compose exec -T db pg_dump -U postgres app > backup.sql. - Copy backups off the server, for example with restic to S3-compatible storage. Most providers also sell automatic server backups or snapshots for an extra fee; turn them on.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Caddy log: no such host, or the ACME challenge fails | DNS doesn't point at the server yet, or port 80 is blocked in the provider's firewall |
502 Bad Gateway | The app listens only on localhost inside the container instead of 0.0.0.0:3000 |
| HTTP works, HTTPS doesn't | Port 443 isn't open in the provider's cloud firewall (which is separate from ufw) |
Next steps
If you'd rather click "deploy" than write Compose files, Coolify gives you a Heroku- or Vercel-style dashboard on the same server. And Self-hosting n8n shows how to run an automation platform with the same pattern.
[ Anolilab — Blog ]
More hands-on writing on AI, web development and infrastructure.
