8 hours ago8 hr When someone hands us a self-hosted app running in Docker, the trouble is almost never Docker itself. It's a .env full of passwords anyone on the box can read, data living somewhere nobody can point to, an update that quietly pulled a breaking major version, or backups nobody ever took. Compose fits this kind of work well because one YAML file describes the whole stack, so we use the same layout and routine on every Compose host. That way whichever tech gets the call already knows where everything is. You'll need Docker Engine with the Compose plugin (docker compose, not the old docker-compose), and root or sudo. One directory per app under /opt /opt/wiki/ compose.yaml .env data/ # bind-mounted application data db/ # bind-mounted database data Everything about the app lives in one place, so backing it up, moving it or rebuilding the host means dealing with one directory. Compose also uses the directory name as the project name, so containers and networks get predictable names. Secrets go in .env, locked down DB_PASSWORD=long-random-string APP_SECRET=another-long-random-string chmod 600 /opt/wiki/.env chown root:root /opt/wiki/.env Compose reads .env from the project directory automatically for variable substitution, so compose.yaml can reference ${DB_PASSWORD} and still be safe to share or commit. Never commit the .env itself. The compose file services: db: image: postgres:16 restart: unless-stopped environment: POSTGRES_DB: wiki POSTGRES_USER: wiki POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - ./db:/var/lib/postgresql/data app: image: ghcr.io/example/wiki:2.5 restart: unless-stopped depends_on: - db environment: DB_HOST: db DB_PASSWORD: ${DB_PASSWORD} APP_SECRET: ${APP_SECRET} ports: - "127.0.0.1:8080:3000" volumes: - ./data:/app/data A few things in there are on purpose. unless-stopped brings containers back after a crash or reboot but respects a deliberate docker compose stop. With no restart policy, nothing comes back after a reboot, and you find out when someone calls. The image is pinned to :2.5 rather than :latest, so an update is a decision and not a surprise. I bump the tag after reading the release notes. The app is published on 127.0.0.1 only because a reverse proxy on the same host fronts it. Docker's port publishing bypasses typical host firewall rules, so don't count on those. The app reaches the database as db on the project's default network, which means the database port never gets published at all. Bind mounts or named volumes? For self-hosting I prefer bind mounts (./data:/app/data) inside the project directory. The data sits in a directory you chose, you can see it, back it up with ordinary tools, and it moves with the project. Named volumes are managed by Docker under /var/lib/docker/volumes/ and avoid some file-ownership headaches, but they're easier to lose track of, and docker compose down -v deletes them. If your data is in named volumes, that's your data gone. Bind mounts aren't touched. We only use named volumes where an image's documentation strongly recommends them. The cost of bind mounts is permissions. Containers that run as a non-root user need the host directory owned by that UID. Check the image docs, then chown -R the host directory to match before first start. Day to day cd /opt/wiki docker compose up -d # create or update and start everything docker compose ps # status docker compose logs -f --tail=100 app docker compose restart app docker compose down # stop and remove containers (data stays) Updating without surprises cd /opt/wiki # 1. back up first (Step 6) # 2. change the image tag in compose.yaml if moving versions docker compose pull docker compose up -d docker compose logs -f --tail=100 docker image prune -f pull fetches new images and up -d recreates only the containers whose image or configuration changed. Watch the logs until migrations finish before you call it done. Backups For a consistent copy, stop the stack, archive the directory and start it again: cd /opt docker compose -f /opt/wiki/compose.yaml stop tar -czf /var/backups/wiki-$(date +%F).tar.gz wiki docker compose -f /opt/wiki/compose.yaml start If you can't take the downtime, dump the database with its own tool instead (for example docker compose exec -T db pg_dump -U wiki wiki) and archive the data directory alongside it. Either way, copy the result off the host. Cap the logs before they fill the disk The default json-file log driver has no size limit. Set one in /etc/docker/daemon.json: { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } } Restart Docker afterwards. It only applies to newly created containers, so run docker compose up -d --force-recreate in each project to pick it up. Don't docker cp your config in We once needed to customise a config file inside a running container, so we used docker cp to drop it in and restarted. The change disappeared, and on another image the container wouldn't start at all. Many images run an entrypoint script on every start that does a recursive chown of the data directory, regenerates config from environment variables, or runs under set -e so any unexpected state aborts startup. Whatever you copied in gets overwritten, has its ownership changed, or trips the script. And it's lost entirely the next time an update recreates the container. Put the file in the project directory and bind-mount it instead, read-only where you can: volumes: - ./data:/app/data - ./config/custom.ini:/app/config/custom.ini:ro Now the change is part of the project, survives updates and gets backed up with everything else. Before I call a host done docker compose ps shows all services running (and healthy, if they define health checks) Reboot the host and everything comes back without anyone touching it ls -l /opt/wiki/.env shows -rw------- A backup archive restored to a test host comes up with docker compose up -d If you only have time for one of those, do the restore test. It's the one that tells you whether the rest of this was worth it.
Create an account or sign in to comment