A Little Bit Tech
HOME LAB20 min read

Weekend Project: Build a Properly Managed Docker Host

A step-by-step weekend project: set up Ubuntu Server and Docker, then run Wiki.js with PostgreSQL, Beszel monitoring, and Dockhand management, with backups you have actually tested.

By Mark Poulton

Architecture of an Ubuntu Docker host with Dockhand, Beszel, Wiki.js and PostgreSQL, a private Wiki.js database network, a local database volume, and host-mounted NAS storage for selected data and backups

Weekend Project: Build a Properly Managed Docker Host

Lots of home labs start the same way. Someone copies a docker run command from a forum, a service starts, and it works for months. Then the disk fills up, or an update breaks something, or the machine dies. Nobody remembers how it was set up, where the data lives, or whether the backups ever worked.

This project avoids that from the start. By Sunday evening you'll have a small Docker server running four useful tools:

  • Wiki.js: a self-hosted wiki for notes, documentation, and how-tos. Its first page can describe how this server works.
  • PostgreSQL: the database where Wiki.js keeps your pages.
  • Beszel: a lightweight monitoring dashboard for CPU, memory, disk, and container health.
  • Dockhand: a web interface for viewing and managing your containers, so you don't have to do everything in a terminal.

You'll also have the parts that keep it manageable: the whole setup described in one file you can read, passwords kept out of that file, data stored somewhere predictable, and a backup you have actually restored.

If you can follow instructions and paste commands into a terminal, you can do this. Each step explains what a command does and how to tell whether it worked. If you already know Docker, skim the explanations and use the commands and checkpoints.

What you'll need

A machine to use as the server. Any of these will work:

  • An old desktop or laptop, or a mini PC
  • A virtual machine on your current computer (VirtualBox, Hyper-V, Proxmox, and so on)

Aim for at least 2 CPU cores, 4 GB of RAM, and 64 GB of storage. This stack will run on less, but that gives you room to add more services later. Use a machine you can wipe. Don't run this on a computer that already does an important job.

Software and accounts:

  • A USB stick (8 GB or more) for the Ubuntu installer, if you're using physical hardware
  • Another computer on the same network to work from. Windows, macOS, and Linux all include the ssh command.
  • A password manager, or another safe place to store a few generated passwords

Optional: a NAS (network storage box) that can share a folder over NFS. Saving backups to it is a Sunday bonus step. Everything else works without one.

The weekend plan

You'll spend about six to eight hours on this, spread over two days. Nothing has to be done in one sitting. Each block ends with a checkpoint, so you can stop there and come back later.

WhenWhat you'll doRoughly
Saturday morningInstall Ubuntu Server and Docker1.5–2 hr
Saturday middayCreate the project folder and fill in your settings1 hr
Saturday eveningStart Wiki.js, PostgreSQL, Dockhand, and Beszel1.5 hr
Sunday morningConnect monitoring, secure the dashboards, and test a reboot1–1.5 hr
Sunday afternoonBack up the wiki, restore the backup, and schedule backups1.5–2 hr

Five Docker ideas you'll use

You don't need to be a Docker expert, but these five terms come up throughout the guide:

  • Image: a packaged application, downloaded from a registry, similar to an installer. postgres:15-alpine is an image name. The part after the colon is the version, called a tag.
  • Container: a running copy of an image. Containers are meant to be disposable. You can delete one and create a new one from the same image.
  • Volume: storage that outlives the container. Because containers are disposable, anything you want to keep, like your wiki database, goes in a volume.
  • Network: a private connection between containers. Containers on the same network can reach each other by name, so Wiki.js finds the database at the address postgres.
  • Docker Compose: a single file (compose.yaml) that describes every container, volume, and network. Instead of typing long commands, you run docker compose up -d and Docker builds whatever the file describes.

Here is what you're building:

Diagram of the reference Docker host: Ubuntu Server contains Docker Engine, Dockhand and Beszel management services, Wiki.js and PostgreSQL on a private network, a local PostgreSQL volume, and a host-mounted NFS share for selected data and backups.

Figure 1. One Ubuntu server runs Docker. Dockhand and Beszel manage and monitor it. Wiki.js talks to PostgreSQL over a private network, and the database is stored on the server's own disk. A NAS, if you have one, holds backup copies.

Notice that the database has no connection to the outside world. Only Wiki.js can reach it. Small decisions like that are what this guide means by a "properly managed" host.

Saturday morning: install Ubuntu and Docker

Step 1: Install Ubuntu Server

Download the current Ubuntu Server LTS release from ubuntu.com/download/server. LTS stands for Long Term Support, which means about five years of security updates. Write the image to a USB stick with a tool like balenaEtcher or Rufus, or attach the ISO to your virtual machine, then boot from it.

The installer asks a series of questions. The defaults are fine for most of them. Watch for these:

  • Your name, server name, and username. Pick a short server name like docker-host. You'll type it often.
  • SSH setup. Tick Install OpenSSH server. This lets you control the server from your main computer.
  • Featured server snaps. Leave all of these unticked, including Docker. You'll install Docker from Docker's own repository in Step 3, which gets you the current version and the Compose plugin.

When the installer finishes, remove the USB stick and reboot.

Step 2: Connect over SSH and update

Log in at the server's own screen once and find its IP address:

ip -brief address

Look for an address like 192.168.1.50 next to a name like eth0 or enp1s0. Then ask your router to always give the server that same address. This is usually called a DHCP reservation, and you'll find it in your router's settings. A fixed address means your bookmarks and SSH commands keep working after a reboot.

From your main computer, open a terminal (PowerShell on Windows, Terminal on macOS) and connect. Replace the username and address with your own:

ssh yourname@192.168.1.50

You can work from your main computer from now on. First, install all available updates:

sudo apt update && sudo apt full-upgrade -y
sudo reboot

Wait a minute, then reconnect with the same ssh command.

Step 3: Install Docker

Docker publishes its own package repository for Ubuntu. These commands, taken from Docker's official Ubuntu instructions, tell Ubuntu to trust that repository and use it. Paste them as one block:

sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update

Now install Docker and the Compose plugin:

sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

By default, only root can run Docker commands, so you'd need sudo in front of every one. To skip that, add your account to the docker group, then log out and back in so the change takes effect:

sudo usermod -aG docker $USER
exit

Reconnect with ssh.

Worth knowing: anyone in the docker group effectively has full administrator control of the server. On a home server that only you log in to, that's a reasonable trade-off. Don't add other people's accounts casually.

Checkpoint. Run these three commands:

systemctl is-active docker
docker run --rm hello-world
docker compose version

You should see active, then a "Hello from Docker!" message, then a version number. If all three appear, Docker is installed. A good place to stop for a break.

Saturday midday: create the project folder

Step 4: Create one folder for the whole setup

Everything that describes this server lives in one folder:

~/docker-host/
  compose.yaml     # describes every container (safe to share)
  .env.example     # a blank settings template (safe to share)
  .env             # your real settings and passwords (private)
  .gitignore       # keeps private files out of Git
  backup-wiki.sh   # the backup script you'll create on Sunday

Create the folder and move into it:

mkdir -p ~/docker-host && cd ~/docker-host

The important idea here is the difference between the recipe and the ingredients. compose.yaml is the recipe: it says what to run but contains no secrets, so you can share it, save it in Git, or paste it into a forum when you need help. .env holds the private details, like passwords and keys. Keeping them apart means you never have to choose between keeping a record of your setup and keeping your passwords safe.

Configuration flow from a Git repository containing Compose and .env.example, through host-only .env values and deployment validation, into Dockhand or the Compose CLI and running containers with persistent data.

Figure 2. The shareable recipe (compose.yaml and .env.example) is combined with private settings (.env) on the server to create the running containers.

Step 5: Write the Compose file

Create the file with the nano text editor:

nano compose.yaml

Paste in the following, then save with Ctrl+O, Enter, and exit with Ctrl+X:

name: docker-host

services:
  # Wiki.js: the wiki itself
  wiki:
    image: ${WIKI_IMAGE}
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DB_TYPE: postgres
      DB_HOST: postgres
      DB_PORT: 5432
      DB_USER: wiki
      DB_PASS: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
      DB_NAME: wiki
    ports:
      - ${BIND_ADDRESS:-127.0.0.1}:8080:3000
    networks:
      - wiki_web
      - wiki_database

  # PostgreSQL: the wiki's database. Note there is no "ports:" section.
  postgres:
    image: ${POSTGRES_IMAGE}
    restart: unless-stopped
    environment:
      POSTGRES_DB: wiki
      POSTGRES_USER: wiki
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
    volumes:
      - wiki_db_data:/var/lib/postgresql/data
    networks:
      - wiki_database
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  # Dockhand: web interface for managing Docker
  dockhand:
    image: ${DOCKHAND_IMAGE}
    restart: unless-stopped
    group_add:
      - ${DOCKER_GID:?Set DOCKER_GID in .env}
    ports:
      - ${BIND_ADDRESS:-127.0.0.1}:3000:3000
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - dockhand_data:/app/data

  # Beszel hub: the monitoring dashboard
  beszel:
    image: ${BESZEL_IMAGE}
    restart: unless-stopped
    environment:
      APP_URL: http://${BIND_ADDRESS:-127.0.0.1}:8090
    ports:
      - ${BIND_ADDRESS:-127.0.0.1}:8090:8090
    volumes:
      - beszel_data:/beszel_data
      - beszel_socket:/beszel_socket

  # Beszel agent: collects the server's stats and reports to the hub
  beszel-agent:
    image: ${BESZEL_AGENT_IMAGE}
    restart: unless-stopped
    network_mode: host
    volumes:
      - beszel_agent_data:/var/lib/beszel-agent
      - beszel_socket:/beszel_socket
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      LISTEN: /beszel_socket/beszel.sock
      HUB_URL: http://${BIND_ADDRESS:-127.0.0.1}:8090
      KEY: ${BESZEL_AGENT_KEY:-}
      TOKEN: ${BESZEL_AGENT_TOKEN:-}

volumes:
  wiki_db_data:
  dockhand_data:
  beszel_data:
  beszel_agent_data:
  beszel_socket:

networks:
  wiki_web:
  wiki_database:
    internal: true

There's a lot here, but every service follows the same pattern: which image to run, when to restart it, which ports to open, which volumes keep its data, and which networks it joins. Values like ${POSTGRES_PASSWORD} are placeholders that Docker fills in from your .env file.

Here are the decisions built into the file:

  • The database is private. PostgreSQL joins only the wiki_database network, and internal: true means that network has no route to the outside world. PostgreSQL also has no ports: section, so nothing on your home network can connect to it. Wiki.js joins both networks and reaches the database by its service name, postgres.
  • Wiki.js waits for the database. The healthcheck checks whether PostgreSQL is ready to accept connections, and condition: service_healthy holds Wiki.js back until it is. Without this, Wiki.js can start first, fail to connect, and keep restarting.
  • The dashboards are only reachable from the server itself, for now. 127.0.0.1 means "this machine only". You'll reach the dashboards safely from your main computer in Step 8.
  • restart: unless-stopped brings every container back after a reboot or crash, unless you stopped it yourself.

Wiki.js accepts local browser traffic and reaches PostgreSQL by the service name postgres across a private Docker network. PostgreSQL has no published host port and stores data in a local named volume.

Figure 3. Your browser reaches Wiki.js. Only Wiki.js can reach PostgreSQL, and the database is stored in a volume on the server's disk.

Step 6: Fill in your settings

First, create the shareable template. This is a copy of your settings file with no real secrets in it:

nano .env.example
# Which version of each application to run.
WIKI_IMAGE=ghcr.io/requarks/wiki:2
POSTGRES_IMAGE=postgres:15-alpine
DOCKHAND_IMAGE=fnsys/dockhand:latest
BESZEL_IMAGE=henrygd/beszel:latest
BESZEL_AGENT_IMAGE=henrygd/beszel-agent:latest

# Where the web dashboards listen. 127.0.0.1 = only reachable from this server.
BIND_ADDRESS=127.0.0.1

# Group ID of the Docker socket. Find it with: stat -c '%g' /var/run/docker.sock
DOCKER_GID=REPLACE_ME

# Database password. Generate one with: openssl rand -hex 24
POSTGRES_PASSWORD=REPLACE_ME

# Filled in on Sunday from Beszel's "Add System" screen.
BESZEL_AGENT_KEY=
BESZEL_AGENT_TOKEN=

Now copy it to create your real settings file, and make it readable only by you:

cp .env.example .env
chmod 600 .env

Collect the two values you need:

stat -c '%g' /var/run/docker.sock   # prints a number, such as 988
openssl rand -hex 24                # prints a long random password

Open .env with nano .env and replace the two REPLACE_ME values with these results. Save the database password in your password manager now. You'll need it if you ever rebuild the server.

About those version tags. wiki:2 and postgres:15-alpine are the pairing the Wiki.js Docker guide uses. latest is fine for getting started with Dockhand and Beszel, but it means "whatever is newest the next time you download". Once everything works, look up the exact version each container is running and write it into .env. After that, updates happen when you choose, not by surprise. Before you start, check the Dockhand manual and Beszel docs to confirm the image names haven't changed.

Step 7 (recommended): Keep the recipe in Git

Git keeps a history of every change you make to your setup, which makes "what did I change last month?" easy to answer. It's optional, but it's good practice. First tell Git which files to ignore:

nano .gitignore
.env
*.dump
backups/

Then start tracking the folder:

git init
git check-ignore -v .env      # should print the .gitignore rule that matches
git add .
git status                    # .env must NOT appear in this list
git commit -m "Initial Docker host setup"

If .env shows up in git status, stop and check your .gitignore before you commit. If a password ever does get committed, change the password. Deleting the file in a later commit doesn't remove it from Git's history.

Checkpoint. Ask Compose to check your file:

docker compose config --quiet

If this prints nothing, the file is valid. If it prints an error, it will usually name the line or the setting that's missing. Indentation mistakes in YAML are the most common cause. That's the end of Saturday's setup work.

Saturday evening: start the stack

Step 8: Start everything except the monitoring agent

The Beszel agent needs a key from the Beszel dashboard, and the dashboard doesn't exist yet. So start the other four services first by naming them:

docker compose up -d postgres wiki dockhand beszel

Docker downloads the images, which takes a few minutes the first time, and then starts the containers. Check on them:

docker compose ps

Wait until postgres shows healthy and the others show Up. If something keeps restarting, its logs usually explain why:

docker compose logs --tail=50 wiki

Open the dashboards from your main computer. The services only listen on the server itself, so use SSH to create a secure tunnel to them. Open a new terminal on your main computer and run this, using your own username and address:

ssh -L 8080:127.0.0.1:8080 -L 3000:127.0.0.1:3000 -L 8090:127.0.0.1:8090 yourname@192.168.1.50

While that SSH window stays open, these addresses work in your browser:

The tunnel means your dashboards are never exposed on your network. Only someone who can SSH into the server can reach them.

Prefer to skip the tunnel? On a home network you trust, you can set BIND_ADDRESS in .env to the server's own IP address (for example 192.168.1.50) and run docker compose up -d again. Anyone on your network can then reach the login pages, so make sure every service has a strong password first. Don't use 0.0.0.0, and don't forward these ports on your router. Be aware that Docker's published ports bypass Ubuntu's UFW firewall, so turning on UFW won't protect them.

Step 9: Set up Wiki.js

Open http://localhost:8080. Wiki.js shows a one-time setup page:

  1. Enter an administrator email and a strong password, and save both in your password manager.
  2. Set the Site URL to http://localhost:8080 for now.
  3. Click Install, then log in.

Create your first page. A good choice is a "Server notes" page that records the server's name, IP address, and which services it runs. That page is your first piece of documentation.

Checkpoint: prove your data survives. This is the test that tells you persistence works. Delete the Wiki.js container and create a new one:

docker compose up -d --force-recreate wiki

Refresh the browser and log in again. Your page should still be there, because it's stored in PostgreSQL's volume, not inside the Wiki.js container. Now you know your wiki will survive updates and container rebuilds. That's Saturday done.

Sunday morning: monitoring, management, and a reboot test

Step 10: Connect Beszel monitoring

Beszel has two parts. The hub is the dashboard you see. The agent collects stats from the server and reports them to the hub. Here, the agent and hub talk through a shared file called a socket (the beszel_socket volume), which is the method Beszel's docs recommend when both run on the same machine.

  1. Open http://localhost:8090 and create your Beszel admin account.
  2. Click Add System. For the name, enter your server name. For the host, enter /beszel_socket/beszel.sock.
  3. Beszel shows a public key and a token. Copy them into .env as BESZEL_AGENT_KEY and BESZEL_AGENT_TOKEN. Put the key in quotes, because it contains spaces.
  4. Start the whole stack:
docker compose up -d

Within a minute the system should turn green in Beszel, and CPU, memory, disk, and container graphs will start filling in. If it doesn't connect, check the agent's log with docker compose logs beszel-agent and compare the steps against the current Beszel agent guide.

Then set up two alerts in Beszel's settings: disk usage above 80%, and system down. Those two catch most problems before they become disasters. An alert is only useful if it reaches you, so set up a notification method (email, or a phone app like ntfy) and send yourself a test alert.

Step 11: Secure Dockhand

Open http://localhost:3000. Dockhand should show your Docker host and all five containers. Click around: you can see logs, restart containers, and check resource use without opening a terminal.

Before you do anything else, make sure Dockhand requires a login. If it didn't ask you to create an account on first launch, turn on authentication in its settings. This matters because Dockhand connects through /var/run/docker.sock, the Docker socket, which gives full control over every container and, in practice, over the whole server. Anyone who can use Dockhand can do anything to the machine. Treat its password like the server's administrator password. See Dockhand's notes on socket permissions for details.

Why Dockhand and not Portainer? Portainer CE is a popular alternative and works well. They do much the same job, so pick one. Whichever you use, keep the terminal commands in this guide handy. If the dashboard itself breaks, docker compose is how you fix it.

Step 12: Test a reboot

A server isn't ready until you know it recovers from a restart on its own. Reboot it:

sudo reboot

Give it two minutes, reconnect (with the tunnel), and check:

cd ~/docker-host
docker compose ps

Checkpoint. All five containers should be running, with postgres showing healthy. Your wiki page should still be there, and Beszel should show a short gap in its graphs where the server was off. If something didn't come back, docker compose logs <service-name> will show why.

Sunday afternoon: back up and prove you can restore

So far your wiki data is persistent: it survives container rebuilds and reboots. It is not yet backed up. If the server's disk fails, the volume goes with it. This section fixes that.

Step 13: Understand where your data lives

Three storage choices: a named Docker volume is stored under Docker's host-managed data directory, a bind mount maps an explicit host path, and an NFS bind mount passes a mounted host path from a network share into a container.

Figure 4. Three ways a container can store data. This project uses Docker volumes on the server's own disk for live data, and keeps the network share (NFS) for backup copies only.

Kind of storageWhere it livesUsed here for
Docker volumeOn the server's disk, managed by DockerThe wiki database and app settings
Bind mountA folder on the server that you chooseNot needed for this project
Network share (NFS)On your NAS, mounted on the serverBackup copies (optional)

The database stays on the server's own disk on purpose. Databases are sensitive to slow or interrupted storage, and keeping the live data local means a NAS reboot can't damage your wiki. The NAS's job is to hold copies.

Step 14: Write a backup script

You shouldn't copy a database's files while it's running, because you can get a half-written copy. PostgreSQL includes a tool called pg_dump that exports a clean, consistent snapshot of the database while it's running. In a default Wiki.js setup, that snapshot includes your pages and uploaded images, because both are stored in the database.

Create the script:

nano ~/docker-host/backup-wiki.sh
#!/usr/bin/env bash
# Back up the Wiki.js database to a dated file.
set -euo pipefail
cd "$(dirname "$0")"

backup_dir="$HOME/backups/wiki"

# Using a NAS? Uncomment these two lines (see Step 16).
# backup_dir="/mnt/nas/docker-backups/wiki"
# findmnt --mountpoint /mnt/nas/docker-backups > /dev/null || { echo "NAS not mounted, backup skipped" >&2; exit 1; }

mkdir -p "$backup_dir"
backup_file="$backup_dir/wiki-$(date +%F-%H%M).dump"

docker compose exec -T postgres pg_dump -U wiki -d wiki -Fc > "$backup_file"
test -s "$backup_file"   # fail if the backup is empty

# Delete backups older than 14 days.
find "$backup_dir" -name 'wiki-*.dump' -mtime +14 -delete

echo "Backup written: $backup_file"

Make it executable and run it:

chmod +x ~/docker-host/backup-wiki.sh
~/docker-host/backup-wiki.sh
ls -lh ~/backups/wiki

You should see a .dump file a few hundred kilobytes or more in size. If you're using Git, commit the script. It contains no secrets.

Step 15: Restore the backup (the step most people skip)

A backup you have never restored is a hope, not a backup. You'll restore into a separate scratch database next to the real one, so your live wiki is never at risk.

cd ~/docker-host
latest=$(ls -t ~/backups/wiki/wiki-*.dump | head -n 1)

# 1. Create an empty scratch database
docker compose exec -T postgres createdb -U wiki wiki_restore_test

# 2. Load the backup into it
docker compose exec -T postgres pg_restore --exit-on-error --no-owner \
  -U wiki -d wiki_restore_test < "$latest"

# 3. Count the pages in the restored copy
docker compose exec -T postgres psql -U wiki -d wiki_restore_test \
  -c 'SELECT count(*) FROM pages;'

# 4. Clean up the scratch database
docker compose exec -T postgres dropdb -U wiki wiki_restore_test

Checkpoint. Step 2 should finish without errors, and step 3 should show the same number of pages you created in Wiki.js. If both are true, your backup is real.

Never practice a restore over your live database. Always use a scratch copy like this.

Application data and PostgreSQL are captured through separate methods: a database-aware dump and an application-data backup are copied to a separate NFS destination, then restored into an isolated target and checked before service is returned.

Figure 5. Back up with a database-aware tool, store the copy somewhere else, and test it by restoring into a separate database before you rely on it.

Step 16 (optional): Send backups to your NAS

A backup on the same disk as the original won't help if that disk dies. If you have a NAS, create an NFS share on it for backups and allow only your server's IP address to connect. Then, on the server:

sudo apt install -y nfs-common
sudo mkdir -p /mnt/nas/docker-backups
sudo nano /etc/fstab

Add one line at the bottom, replacing the NAS address and share path with your own:

192.168.1.20:/volume1/docker-backups  /mnt/nas/docker-backups  nfs  defaults,_netdev,nofail  0  0

_netdev tells Ubuntu to wait for the network before mounting the share, and nofail lets the server finish booting even if the NAS is off. Mount it and confirm:

sudo systemctl daemon-reload
sudo mount /mnt/nas/docker-backups
findmnt --mountpoint /mnt/nas/docker-backups

findmnt should show your NAS address. If it shows nothing, the share isn't mounted. Check the NAS's share permissions before going further. Some NAS systems ask you to choose between NFSv3 and NFSv4, or need specific mount options, so check its documentation. Ubuntu's NFS guide covers the client side.

Make sure your user can write to the share:

mkdir -p /mnt/nas/docker-backups/wiki && touch /mnt/nas/docker-backups/wiki/test && rm /mnt/nas/docker-backups/wiki/test

If you get Permission denied, the share's permissions don't match your server user's ID, which you can find with the id command. Fix that in the NAS's share settings. Don't use chmod 777 to work around it.

Now uncomment the two NAS lines in backup-wiki.sh. The findmnt line is a safety check, and it's important. If the NAS is ever offline, /mnt/nas/docker-backups is still an empty folder on the server's own disk. Without the check, the script would quietly write backups there, and they would vanish from view once the NAS reconnected. With the check, the script refuses to run and tells you why.

Because only backups live on the NAS, the order things start in after a reboot doesn't matter much. Your wiki doesn't depend on the NAS to start.

Step 17: Schedule daily backups

Open your scheduled tasks:

crontab -e

Add this line to run the backup every night at 2:30am and keep a log:

30 2 * * * $HOME/docker-host/backup-wiki.sh >> $HOME/backups/backup.log 2>&1

Tomorrow, check ~/backups/backup.log to confirm it ran.

Finally, store a copy of your .env file in your password manager or another safe place off the server. If the server dies, your backups, the Compose file, and that .env are everything you need to rebuild.

You're done. Here's what you built

Take a moment to look at what you have now:

  • ✅ A clean, updated Ubuntu server with Docker installed from the official source
  • ✅ Every service described in one readable file, with secrets kept separately
  • ✅ A wiki whose database is private and survives rebuilds and reboots
  • ✅ A monitoring dashboard that alerts you when the disk fills up or the server goes down
  • ✅ A management interface that's locked behind a login
  • ✅ Nightly backups that you've proven you can restore

That covers most of what "properly managed" means. You can explain what's running, where the data is, and how to get it back.

Keeping it healthy

Updating a service safely

Updates change the software you're running, so do them deliberately:

  1. Read the release notes for the new version.
  2. Run ~/docker-host/backup-wiki.sh so you have a fresh backup.
  3. Change the version in .env if you've pinned one, then:
cd ~/docker-host
docker compose pull
docker compose up -d
docker compose ps
  1. Check the logs and click around the app to confirm it works.

Two cautions:

  • Never run docker compose down -v. The -v deletes your volumes, which here means your wiki database. Plain docker compose down is safe.
  • Major PostgreSQL upgrades need extra steps. Moving from postgres:15 to postgres:16 means the existing database files have to be migrated. Changing the version number on its own won't work. Look up PostgreSQL's upgrade procedure before you try it.

When something goes wrong

What you seeWhat to check
permission denied running dockerYou haven't logged out and back in since joining the docker group.
The browser can't reach a dashboardIs your SSH tunnel window still open? Does docker compose ps show the service running?
Wiki.js keeps restartingRun docker compose logs wiki. If you changed POSTGRES_PASSWORD after the first start, that's the cause: PostgreSQL only reads the password the first time it creates the database. Change it back.
Dockhand shows no containersCompare stat -c '%g' /var/run/docker.sock with DOCKER_GID in .env. Never "fix" it with chmod 666 on the socket.
Beszel shows the system as downRun docker compose logs beszel-agent, and check that the key and token in .env match the hub's Add System screen.
The disk is filling upRun df -h and docker system df. Old images often take up the space. Review what's listed before you prune anything.
A NAS folder looks emptyRun findmnt --mountpoint /mnt/nas/docker-backups. The share probably isn't mounted.

A few commands are worth remembering, because they work even if every dashboard is down: docker compose ps (what's running?), docker compose logs --tail=100 <service> (what went wrong?), and docker inspect <container> (how is it actually configured?).

Where to go next

Once this has run smoothly for a week or two, here are good next projects:

  • Pin your image versions. Replace each latest in .env with the exact version you're running, so every update is a deliberate decision.
  • Add an off-site backup. A NAS in the same house doesn't protect against fire or theft. Copy your dumps to a cloud storage bucket or a friend's server.
  • Set up a reverse proxy. This gives you friendly names like wiki.home.lan and HTTPS, and is the safe way to make services reachable beyond the SSH tunnel. It's a separate project with its own security decisions, so don't open router ports in the meantime.
  • Add more services. Your Compose file is now a template. Each new app follows the same pattern of image, restart, ports, volumes, and networks.

To learn more about getting useful alerts rather than noise, see the AI-powered observability guide.

References