Most self-hosting tutorials stop at the moment the application answers on a port. That is roughly a third of the job. This one goes from a compose file to a service that has a certificate, survives a reboot, has backups you have actually restored from, and tells you when it dies.
The example is generic on purpose — substitute whatever you are running. About two hours end to end.
What this guide does not do. It does not give you high availability, zero-downtime deploys, or anything that survives the server itself dying — one machine is a single point of failure and no amount of configuration changes that. It does not cover Kubernetes. And it assumes the application is one you have chosen to trust; nothing here makes untrustworthy software safe.
Before you start
- A server you have already secured — key-only SSH, a non-root user, a firewall.
- Docker and Compose installed.
- A reverse proxy already running and terminating TLS.
- A DNS
Arecord forapp.example.compointing at the server.
1. Read the compose file before you run it
Every project’s README says to pipe something into your shell or copy a compose file. Read it first, looking for four things: which ports it publishes, which volumes hold data, whether it wants to run as root, and whether it mounts anything from the host — particularly /var/run/docker.sock, which hands the container control of your machine.
mkdir -p /srv/myapp && cd /srv/myapp
curl -fsSLo compose.yaml https://example.com/compose.yaml
less compose.yaml
docker compose config # what it actually resolves to, after variablesVerify: docker compose config prints the merged configuration with no errors, and you can name every volume and published port in it.
2. Give it a home and keep the secrets out of the compose file
One directory per application under /srv, holding the compose file, the environment file, and bind-mounted data. That way “back up this application” is one path, and moving it to another machine is one rsync.
cd /srv/myapp
printf 'DB_PASSWORD=%s\n' "$(openssl rand -base64 24)" >> .env
printf 'SECRET_KEY=%s\n' "$(openssl rand -hex 32)" >> .env
sudo chown root:root .env && sudo chmod 600 .env
mkdir -p dataGenerating passwords rather than choosing them takes the same amount of time and produces better ones. Note that these are written by a command with a leading argument, not typed — a password typed on a command line lands in your shell history.
Verify: ls -l .env shows -rw-------, and git status — if this directory is a repository — does not list .env as untracked-and-about-to-be-committed.
3. Bind the port to localhost only
This is the step that matters most, and the one everyone skips. Docker writes its own firewall rules and ufw will not stop it. A compose file saying ports: ["8080:8080"] publishes that port to the internet, firewall or not — your admin interface is reachable from anywhere and you will not know. Prefix every published port with 127.0.0.1: so only the reverse proxy on the same machine can reach it.
# compose.yaml
services:
app:
image: example/myapp:1.8.2 # a real version, never :latest
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:8080:8080" # not "8080:8080"
volumes:
- ./data:/data
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALLdocker compose up -d
ss -tlnp | grep 8080 # should show 127.0.0.1:8080, not 0.0.0.0
curl -sI http://127.0.0.1:8080 | head -1Verify: ss -tlnp shows the port bound to 127.0.0.1 only, and from another machine curl http://your-server-ip:8080 times out or is refused.
4. Put a hostname and a certificate in front of it
With Caddy this is two lines. Add them to your Caddyfile and reload:
app.example.com {
reverse_proxy 127.0.0.1:8080
}sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -sI https://app.example.com | head -1If the certificate does not appear, the cause is almost always DNS not yet pointing at this server, or port 80 being blocked — the certificate authority needs to reach it. journalctl -u caddy -n 50 says which.
Verify: the site loads over HTTPS in a browser with a valid certificate, and curl -sI http://app.example.com returns a redirect to HTTPS.
5. Prove it survives a reboot
restart: unless-stopped in the compose file handles the container. The only way to know it actually works is to do it, and the only good time to find out is now rather than during an unplanned reboot at 3am.
systemctl is-enabled docker
sudo reboot
# wait, reconnect
docker compose -f /srv/myapp/compose.yaml ps
curl -sI https://app.example.com | head -1Verify: after the reboot, with no intervention from you, the container is running and the site answers.
6. Back up the data, and restore it once
Two things need backing up: the data directory, and the compose file plus .env that describe how to bring it back. Both are under /srv/myapp, which is why step 2 put them there.
If the application has a database inside the compose stack, do not just copy its files — a database copied while running restores in an unpredictable state. Dump it first, then back up the dump:
docker compose exec -T db pg_dump -U myapp myapp > /srv/myapp/data/dump.sql
restic backup /srv/myapp --tag myapp
restic snapshots --tag myappThen restore it somewhere harmless and look at what came back. This is the step that separates a backup from a hope.
restic restore latest --target /tmp/restore-test
ls -la /tmp/restore-test/srv/myapp/dataVerify: the restored directory contains the data files and the dump, and the dump is not zero bytes. See Automated Backups for scheduling it.
7. Find out when it breaks before your users do
A container that keeps crashing and restarting looks healthy from the outside — the process exists. Watch for the restart count, and for the URL not answering, rather than for the container being absent.
docker compose ps # look at the STATUS column, not just "Up"
docker inspect -f '{{.RestartCount}}' myapp-app-1
docker compose logs --tail=50 appSet up an external uptime check against https://app.example.com — something outside this machine, so it still alerts when the machine is the problem — and wire the backup job’s failures into the OnFailure= alerting from Monitoring a Server.
Verify: stop the container deliberately and confirm the alert reaches you. An untested alert is worth nothing.
8. Have a way to update, and a way back
cd /srv/myapp
restic backup /srv/myapp --tag pre-update # first
$EDITOR compose.yaml # bump 1.8.2 to 1.9.0, deliberately
docker compose pull && docker compose up -d
docker compose logs -f app # watch the first minutePinning a version in the compose file rather than using :latest is what makes rollback possible: put the old tag back and run up -d again. With :latest you cannot say what you were running before, which turns “undo the update” into an investigation.
Read the release notes before major version bumps. Applications that migrate their database on start will do so the moment the new container comes up, and that is rarely reversible without the backup you took in the first line.
Verify: after updating, the site works and docker compose images shows the version you intended.
The checklist
- Every published port is bound to
127.0.0.1 - Secrets are in a
600environment file, not in the compose file - The image tag is a version, not
latest - It came back on its own after a real reboot
- A backup exists, and you have restored from it
- Something outside the machine will tell you when it stops
- You know which command puts the previous version back
Add the second application and the list is the same, minus the parts you have already built. That is the point at which self-hosting starts being cheap.
Related
- A Reverse Proxy with Automatic TLS — the piece this guide assumes is already running
- Docker Basics — volumes, networks and the commands used above
- Automated Backups — turning step 6 into something that happens without you
- Containers — why the firewall does not stop a published port
