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

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 variables

Verify: 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 data

Generating 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:
      - ALL
docker 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 -1

Verify: 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 -1

If 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 -1

Verify: 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 myapp

Then 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/data

Verify: 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 app

Set 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 minute

Pinning 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 600 environment 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