Will your stack fit on a small machine?
It will if you measure first. Run the stack on your laptop, use it the way visitors would, and read docker stats --no-stream. Add up the MEM USAGE column and pick a size that leaves real room above that total, because Ubuntu and the disk cache need memory too. No universal figure is honest here: a Go API and a Java app differ enormously.
Two habits keep a small Cloud VPS calm. Build images elsewhere and pull them, since a build can want far more memory than the running app. And set mem_limit on anything that might leak: when a Linux host runs out of memory the kernel starts killing processes, and you want that to land on one container, not the database. Moving up a size later means one restart and keeps your files and addresses, but a machine can't shrink, so start small.
| Start from | Pick it when |
|---|---|
| The free Docker one-click app | You're happy on SSH and want Docker and Compose already there. That's the route this page assumes. |
| The Portainer one-click app | You'd rather start and watch containers from a web page. Our Portainer page goes further. |
| The Coolify one-click app | You deploy from Git and don't want to write compose files. |
| Plain Ubuntu | You want every step in your own hands. Add Docker's apt repository as its Ubuntu guide shows. |
Close the door before the first container
Run this as root, before anything is listening. It turns ufw on while keeping SSH open, stops if a firewall step doesn't finish, refuses to go further on a Docker older than Engine 28, and makes local the default log driver. Docker's docs recommend that driver to prevent disk exhaustion: it keeps about 100 MB per container, where the default json-file doesn't rotate at all. The setting only reaches containers created afterwards, so it comes first.
if sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw &&
ufw allow OpenSSH && ufw --force enable &&
ufw allow 80,443/tcp && ufw allow 443/udp; then
v=$(docker version 2>/dev/null | grep -A1 'Engine:' | grep -oE '[0-9]+\.[0-9.]+' | head -1)
if [ -z "$v" ]; then
echo "STOP - Docker is not answering"
elif [ "${v%%.*}" -lt 28 ]; then
echo "STOP - Engine $v is older than 28, update Docker first"
elif [ -e /etc/docker/daemon.json ]; then
echo "STOP - daemon.json exists, add log-driver to it by hand"
else
printf '{\n "log-driver": "local"\n}\n' > /etc/docker/daemon.json &&
systemctl restart docker && echo "Docker $v ready"
fi
else
echo "STOP - the firewall steps did not finish, nothing else ran"
fi
Changed your SSH port? Allow that port in place of OpenSSH. Locked out anyway? The machine has a console in your browser.
The trap: published ports walk past ufw
A line like 5432:5432 publishes the port on every host address (0.0.0.0 and [::]). Docker's firewall page says that traffic is diverted before ufw's rules see it, and it doesn't treat IPv4 and IPv6 separately. IPv6 also depends on Docker's settings: on a default IPv4-only network, a userland proxy maps the host's IPv6 addresses to the container. So ufw status can look perfect while your database faces the internet. Don't rely on ufw for either.
The fix is a habit, not a firewall rule, and it is the same for both:
- Public: only the proxy publishes ports, and only 80 and 443.
- Private to the stack: no
ports:line at all. Containers on the same compose network reach each other by service name, soappfindsdbwithout anything being published. - Yours alone:
127.0.0.1:8080:8080, reached through an SSH tunnel. That address is local-only on Engine 28 and newer, which is why the block above checks the version.
Check it once the stack is up. In the PORTS column of docker ps, only Caddy's row should show a public address (0.0.0.0 or [::]). Other rows should show a bare port such as 5432/tcp, nothing at all, or 127.0.0.1. From your laptop, nc -zv -w3 YOUR_IP 5432 should not connect.
Put one app behind HTTPS
The example is a web app, a Postgres database and Caddy, which fetches and renews its own certificate. Before you paste it:
- Point your domain's A record at the VPS. Caddy can't get a certificate without DNS in place and ports 80 and 443 open.
- Change
app.example.com, the image and port 8080 to your own. Use an exact version tag on your app, notlatest, so an update is your decision.caddy:2andpostgres:17are deliberately pinned to a major version, sopullbrings their fixes but not a new major.
if [ -e /opt/app/.env ] || [ -e /opt/app/compose.yaml ]; then
echo "STOP - /opt/app already has a stack, nothing changed"
else
install -d -m 750 /opt/app /opt/app/conf && cd /opt/app &&
s=$(openssl rand -hex 24) && [ -n "$s" ] && install -m 600 /dev/null .env &&
echo "POSTGRES_PASSWORD=$s" > .env &&
cat > conf/Caddyfile <<'EOF' &&
app.example.com {
reverse_proxy app:8080
}
EOF
cat > compose.yaml <<'EOF' &&
services:
caddy:
image: caddy:2
restart: unless-stopped
ports: ["80:80", "443:443", "443:443/udp"]
volumes:
- ./conf:/etc/caddy:ro
- caddy_data:/data
- caddy_config:/config
app:
image: ghcr.io/you/app:1.4.2
restart: unless-stopped
mem_limit: 512m
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db/app
db:
image: postgres:17
restart: unless-stopped
env_file: .env
environment:
POSTGRES_USER: app
POSTGRES_DB: app
volumes:
- db_data:/var/lib/postgresql/data
volumes:
caddy_data:
caddy_config:
db_data:
EOF
echo "Files written" || echo "STOP - files not written"
fi
That only writes files, and it leaves a folder that already holds a stack untouched. Starting is a separate step, and which one depends on your app. The first command below refuses to run while something else holds port 80 or 443: if it prints a line, nothing has started, and that service has to go before Caddy can have the port.
- Most apps:
! ss -ltnp | grep -E ':(80|443) ' && grep -q POSTGRES_PASSWORD=. /opt/app/.env && docker compose -f /opt/app/compose.yaml up -d - Apps that make the first visitor the admin: don't start Caddy yet. Add
ports: ["127.0.0.1:8080:8080"]to theappservice, rungrep -q POSTGRES_PASSWORD=. /opt/app/.env && docker compose -f /opt/app/compose.yaml up -d db app, create the account throughssh -L 8080:127.0.0.1:8080 you@YOUR_IP, and only then run the command above.
unless-stopped brings everything back after a reboot, except what you stopped on purpose. Reboot once now and confirm the site returns by itself. The volume path suits Postgres 17 and older; the image moved it from version 18.
Keep the data when the machine goes wrong
Named volumes outlive containers, but not a mistake: docker compose down -v deletes them, and a reinstall from the dashboard wipes the disk. Copies have to leave the machine. Save this as /opt/app/backup.sh. It stops at the first problem, covers the database and everything needed to rebuild the stack, and leaves out Caddy's certificates, which can be issued again. Add any volume that holds uploads. Each file keeps a hidden temporary name until it has been checked, so a dump that dies halfway never reaches the other host.
set -euo pipefail
umask 077
d=/var/backups/app; t=$(date +%F-%H%M); mkdir -p "$d"
docker compose -f /opt/app/compose.yaml exec -T db pg_dump -U app app | gzip > "$d/.db-$t.tmp"
gzip -t "$d/.db-$t.tmp"
mv "$d/.db-$t.tmp" "$d/db-$t.sql.gz"
tar -czf "$d/.stack-$t.tmp" -C /opt/app .env compose.yaml conf backup.sh
tar -tzf "$d/.stack-$t.tmp" > /dev/null
mv "$d/.stack-$t.tmp" "$d/stack-$t.tgz"
rsync -a --exclude='.*' "$d/" backup@OTHER_HOST:/backups/app/
find "$d" -type f -mtime +7 -delete
Give root an SSH key that the other host accepts, run the script once by hand, then schedule it: echo '15 3 * * * root bash /opt/app/backup.sh > /var/log/app-backup.log 2>&1' > /etc/cron.d/app-backup. Check the newest file on the other host every week, or have the script's last line ping a monitoring URL, because a failed cron job won't tell you. On the other host, prune with its own job.
To restore on the same machine, as root, in this order. Practise first on a spare machine, with the new-machine steps below:
- Run the backup script, so the present state is saved under its own name. Don't continue unless it finishes cleanly. If the script can't finish because the database itself is broken, don't empty anything here. Leave this machine as it is and rebuild on a new one with the steps below.
- Stop the app:
docker compose -f /opt/app/compose.yaml stop app - Empty the database:
docker compose -f /opt/app/compose.yaml exec -T db psql -U app -d postgres -c 'DROP DATABASE app WITH (FORCE)' -c 'CREATE DATABASE app' - Load the dump. Local copies last seven days, so fetch an older one from the other host first:
gunzip -c /var/backups/app/db-DATE.sql.gz | docker compose -f /opt/app/compose.yaml exec -T db psql -U app -d app -v ON_ERROR_STOP=1 - Start the app and check it. If it looks wrong, repeat steps 2 to 4 with the dump from step 1.
A new machine has no stack to save or empty, so it starts differently. If you're only practising, stop after step 5, check the data with psql, and leave the A record alone. As root:
- Run the firewall block from earlier, and give root an SSH key that the other host accepts.
- Fetch your copies:
install -d -m 750 /opt/app /var/backups/app && rsync -a backup@OTHER_HOST:/backups/app/ /var/backups/app/ - Unpack the stack before the database first starts, because Postgres takes its password from
.envonly when it creates its data:tar -xzf /var/backups/app/stack-DATE.tgz -C /opt/app - Start the database alone and wait for it:
docker compose -f /opt/app/compose.yaml up -d db && until docker compose -f /opt/app/compose.yaml exec -T db pg_isready -h 127.0.0.1 -U app; do sleep 2; done. Keep the-h 127.0.0.1: during first-time setup the image listens only on its local socket, so a plain check can pass too early. - Load the dump with step 4 of the same-machine restore.
POSTGRES_DBhas already made the empty database, so you skip that list's first three steps. - Point the A record at the new address, then start everything:
docker compose -f /opt/app/compose.yaml up -d - Turn backups back on, or this machine has none: run
bash /opt/app/backup.shonce by hand, then add the cron line from above.
What to do when the disk fills
Look before you delete. df -h / gives the total and docker system df splits it into images, containers, volumes and build cache. Then go from safest to riskiest:
docker image pruneremoves dangling images, left behind when a tag moves on.docker builder pruneclears build cache, if you build on the machine.docker image prune -aremoves every image no container uses. A rollback then has to download its image again.- Check
/var/backups/app. Local backups add up on a small disk. - Still full? Move up a size rather than experiment on a database with no room.
Leave volumes alone unless you know what's in them. docker system prune skips volumes by default. Adding --volumes removes unused anonymous volumes. docker volume prune --all also removes unused named ones, and that includes db_data if the stack has been taken down with docker compose down. What a Cloud VPS downloads doesn't count toward its monthly traffic, so pulling images again costs nothing against it.
Update without surprises
Run a backup, change the tag in compose.yaml, then docker compose -f /opt/app/compose.yaml pull && docker compose -f /opt/app/compose.yaml up -d. To roll back, restore the old tag and repeat. That undoes the code, not a database change the new version made, which is why the backup comes first. Postgres is different: a new major version can change the storage format, so 17 to 18 means a dump and restore, not a tag change.
When one VPS is no longer the right box
A VPS shares a physical server, processor time included. If a busy database or heavy processing starts to suffer, a whole server for containers gives you every core and takes the same compose file. Outgrown one machine altogether? Look at managed Kubernetes on bare metal.
Questions people ask
- Do I need swap on a 1 GB machine?
- It can soften a brief spike, but it won't rescue a stack that doesn't fit, and Docker's docs note that swap is slower than memory. If
docker statssits near the limit, take a larger size. - Can my containers send email?
- Yes, through ports 465 and 587, which work from day one. Port 25 is closed on new machines until you ask us to open it, so point your app at a mail service on 587.
- Why Caddy and not nginx?
- Only because it gets and renews certificates with a three-line config, which suits a first stack. Any reverse proxy works if it is the one thing publishing 80 and 443. If you prefer a web interface, we also have a page on Nginx Proxy Manager.