Most self-hosting trouble shows up months after the first docker compose up: an image pull brings in a PostgreSQL version that cannot read your data, container logs fill the disk, or a backup you never tested will not restore. This guide builds a small stack with Caddy for HTTPS, Wiki.js as the application, and PostgreSQL for data. It then sets the stack up so updates can be rolled back and restores are tested before you need them.
Install Docker Engine and the Compose plugin
Install Docker Engine from Docker's own apt repository by following the install page for Ubuntu or Debian on docs.docker.com. The packages are docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, and docker-compose-plugin. Confirm Compose v2 is present:
docker compose version Compose v2 is a Docker CLI plugin invoked as docker compose, with a space. The older Python docker-compose is no longer maintained, so translate commands from older guides. Compose v2 also ignores the top-level version: key and warns that it is obsolete. Delete it from any file you inherit.
The commands below assume your user is in the docker group. Membership in that group is equivalent to root, so grant it only to admins. Otherwise prefix commands with sudo.
Lay out one directory per stack
Give each stack its own directory. Compose uses the directory name as the project name and prefixes volumes and networks with it, so this stack's database volume becomes wiki_db_data.
/srv/stacks/wiki/ compose.yaml Caddyfile .env secrets/ Keep secrets in .env, which Compose reads automatically for variable substitution. Generate a password and lock the file down:
printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 32)" > /srv/stacks/wiki/.env chmod 600 /srv/stacks/wiki/.env Pin image tags. latest points at whatever was pushed most recently, so a routine pull can bring in a new major version. For PostgreSQL that is a real problem: a new major version cannot start on the old data directory. Pin at least the major version (postgres:16). For exact reproducibility, look up the digest of the image you tested:
docker image inspect --format '{{index .RepoDigests 0}}' postgres:16 You can write image: postgres:16@sha256:<digest> in the compose file. When a digest is present, Docker uses it and ignores the tag.
Write the compose file
This compose.yaml publishes only Caddy's ports. The database sits on an internal network with no published port and no route out.
services: caddy: image: caddy:2 restart: unless-stopped ports: - "80:80" - "443:443" - "443:443/udp" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config networks: - proxy wiki: image: ghcr.io/requarks/wiki:2 restart: unless-stopped environment: DB_TYPE: postgres DB_HOST: db DB_PORT: 5432 DB_USER: wikijs DB_PASS: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} DB_NAME: wiki depends_on: db: condition: service_healthy networks: - proxy - backend db: image: postgres:16 restart: unless-stopped environment: POSTGRES_DB: wiki POSTGRES_USER: wikijs POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} volumes: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U wikijs -d wiki"] interval: 10s timeout: 5s retries: 5 start_period: 30s networks: - backend networks: proxy: backend: internal: true volumes: caddy_data: caddy_config: db_data: The Caddyfile sends one hostname to the Wiki.js container on its internal port:
wiki.example.com { reverse_proxy wiki:3000 } Caddy requests a certificate automatically once the DNS record points at this server and ports 80 and 443 are reachable. For a LAN-only host, use tls internal or a DNS challenge instead. The home lab networking guide covers how local DNS and the proxy fit together.
Start the stack and check its state:
cd /srv/stacks/wiki docker compose up -d docker compose ps The db service should show (healthy) in its status column.
A few choices in that file do most of the reliability work. restart: unless-stopped brings containers back after a crash or a reboot, as long as the Docker service is enabled, and leaves alone anything you stopped by hand. depends_on with condition: service_healthy makes Compose wait for pg_isready to pass before starting Wiki.js. It only affects startup order, so the app still has to retry if the database drops later.
Named volumes such as db_data are managed by Docker and live under /var/lib/docker/volumes/. Find the exact path with docker volume inspect wiki_db_data --format '{{ .Mountpoint }}'. Bind mounts like ./Caddyfile are host paths you choose, which suits config files you edit. docker compose down keeps named volumes, while docker compose down -v deletes them, database included.
Keep ports and secrets private
Docker publishes ports by writing its own iptables rules. Traffic to a published port is rewritten and sent through the FORWARD chain, so it never passes the INPUT rules where ufw applies your allow and deny rules. ufw deny 5432 does not protect a published database port. Do not publish database ports at all. Other containers reach db by service name over the shared network. If a host-only tool needs a port, bind it to loopback:
ports: - "127.0.0.1:8080:3000" Check what is actually listening:
sudo ss -tlnp You should see docker-proxy on ports 80 and 443 and nothing on 5432 or 3000. Then test from outside your network with the port checker. The same rules apply to a database outside Docker, and the PostgreSQL security guide covers roles, TLS, and pg_hba.conf.
Values from .env end up as container environment variables, which anyone who can run docker inspect can read. Compose secrets: mounts a file at /run/secrets/<name> instead, which works with images that read a _FILE variable:
services: db: environment: POSTGRES_PASSWORD_FILE: /run/secrets/db_password secrets: - db_password secrets: db_password: file: ./secrets/db_password.txt The file is bind-mounted with its host ownership and mode, so the user inside the container must be able to read it. Check your application image's documentation for its own _FILE variable before switching it over.
Rotate logs and update with a rollback path
The default json-file log driver has no size limit, so a noisy container can fill the disk. Set limits in /etc/docker/daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } } A syntax error in this file stops Docker from starting, so check that it parses first:
python3 -m json.tool /etc/docker/daemon.json If Docker does fail to come back, sudo journalctl -u docker -n 50 shows the parse error.
Restart Docker, which briefly stops every container, then recreate containers, because log options apply only to new containers:
sudo systemctl restart docker docker compose up -d --force-recreate To set limits for a single service instead, add a logging: block with the same driver and options to that service.
Before updating, record the exact images you are running now. This loop appends each image digest to a history file:
date >> image-history.txt for img in $(docker compose config --images); do docker image inspect --format '{{index .RepoDigests 0}}' "$img" done >> image-history.txt Save the rendered config with docker compose config > compose-before.yaml. After editing, render it again and compare the two in the diff viewer so an unintended change does not slip through. Take a database dump (next section), then update:
docker compose pull docker compose up -d Verify with docker compose ps and by loading the site. To roll back, change the image: line to the digest recorded in image-history.txt, for example ghcr.io/requarks/wiki@sha256:<digest>, and run docker compose up -d. If the new version migrated the database schema, the old version may refuse to start, so restore the pre-update dump as well.
Back up data and prove you can restore it
Copying a running PostgreSQL data directory can produce an inconsistent backup. Dump it through the container instead:
sudo install -d -o "$USER" -m 700 /srv/backups/wiki docker compose exec -T db pg_dump -U wikijs -Fc wiki > /srv/backups/wiki/wiki-$(date +%F).dump -T stops Compose from allocating a TTY, which can corrupt binary output. For other volumes, stop the service that writes to them (docker compose stop caddy), archive the volume with a throwaway container, and start the service again:
docker run --rm -v wiki_caddy_data:/data:ro -v /srv/backups/wiki:/backup alpine:3.20 tar czf /backup/caddy_data-$(date +%F).tar.gz -C /data . Those files still sit on the same disk. restic sends encrypted, deduplicated snapshots to another machine or to object storage. Run the following as root (sudo -i):
apt install restic export RESTIC_REPOSITORY=sftp:backup@nas.lan:/srv/restic/wiki export RESTIC_PASSWORD_FILE=/root/restic-password restic init restic backup /srv/stacks/wiki /srv/backups/wiki restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune restic check Store the restic password somewhere off the server as well. Without it, the repository cannot be decrypted.
Then test a restore. Pull the latest snapshot:
restic restore latest --target /tmp/restore-test Restored files keep root ownership, so stay in the root shell and run the rest from /srv/stacks/wiki:
docker compose exec -T db createdb -U wikijs restore_test docker compose exec -T db pg_restore -U wikijs -d restore_test < /tmp/restore-test/srv/backups/wiki/wiki-2026-09-17.dump Run a query against a table you know, such as SELECT count(*) FROM pages;, compare it with production, and drop restore_test afterwards.
Monitor the stack
docker compose ps shows healthcheck status, and docker inspect --format '{{.State.Health.Status}}' wiki-db-1 returns it for one container. Plain Docker does not restart a container that turns unhealthy. The status is only information, so something has to watch it.
Uptime Kuma is a simple choice. Add an HTTP monitor for https://wiki.example.com, which exercises Caddy, the certificate, and the app together, and turn on certificate expiry alerts. Run it on a different machine, or it goes down with the server it watches. Its Docker container monitor needs the Docker socket, which gives root-level access to the host, so prefer HTTP and TCP checks.
Troubleshooting common failures
A container in a restart loop shows Restarting in docker compose ps. Read its output:
docker compose logs -f --tail=100 wiki A common cause is a password mismatch. The postgres image applies POSTGRES_PASSWORD only when it initializes an empty data directory, so changing .env later does not change the password stored in the database.
Permission denied on a bind mount usually means the container runs as a UID that does not own the host directory. Check the UID the image uses, then compare with ls -ln on the host:
docker compose run --rm --no-deps --entrypoint id wiki Change ownership of the host directory to that UID with sudo chown -R, or use a named volume.
A 502 from Caddy means it could not reach the upstream. docker compose logs caddy shows the dial error. The upstream must be the service name and the container's internal port (wiki:3000, not a host port), and both services must share a network. Test from inside Caddy:
docker compose exec caddy wget -S --spider http://wiki:3000 Checklist before you trust the stack
For a complete application that follows this pattern, the Filux Docker and native setup adds public sharing, guest uploads, storage quotas, and its own backup procedure.
- No
version:key, and every image is pinned with its digest recorded. - Only the proxy publishes ports, and
ss -tlnpplus an external port check agree. -
.envis mode 600 and not committed to git. - Healthchecks pass and log limits are set.
- Dumps and volume archives go to restic, and a restore has been tested.
- An external monitor alerts you when the site is down.