Maintenance
1. Update
To keep a Debian/Ubuntu system up to date, we usually do something like this:
apt update
apt upgrade
apt full-upgrade
apt dist-upgrade
What about Docker containers?
Usually it is not convenient to do that kind of update/upgrade inside a docker container. What we do instead is:
- pull an updated version of the base docker image
- rebuild the local image (if needed)
- rebuild the container
- etc.
We can do this kind of update/upgrade (build a new container and throw away the old one) because usually the data and configuration of the application are stored outside the container. So, the newly built container can reuse them. Or, in some cases, we can modify automatically the configuration of the new container (with the help of scripts).
Most of docker-scripts applications can be updated just with a ds make, which will rebuild the container, customize automatically its
configuration (if needed), and reuse the same data and/or configs
(which are stored on the directory of the container).
Some docker-scripts applications may be a bit different, or may
require additional steps. For example we may need to use ds remake
for some of them (instead of ds make), which usually makes a backup
of the application first, then rebuilds it (with ds make), and
finally restores the latest backup.
-
One of the steps of update is to update the code of the docker-scripts. It can be automated with a script like this:
cat << 'EOF' > /opt/docker-scripts/git.sh
#!/bin/bash
options=${@:-status --short}
for repo in $(ls -d */)
do
echo
echo "===> $repo"
cd $repo
git $options
cd -
done
EOF
nano /opt/docker-scripts/git.shcd /opt/docker-scripts/
nano git.sh
chmod +x git.sh
./git.sh
./git.sh pullUpdate (reinstall) docker-scripts:
cd ds/
make install -
Another step is to update the base Docker image (
debian/13):docker image ls
docker pull debian:13
docker pull debian:trixieAfter updating each container, we can clean up old images like this:
docker image prune --force
docker system prune --force -
This script lists all the update steps:
/var/ds/_scripts/update.sh#!/bin/bash -x
# update the system
apt update
apt upgrade --yes
#reboot
# get the latest version of scripts
cd /opt/docker-scripts/
./git.sh pull
# update ds
cd /opt/docker-scripts/ds/
make install
# get the latest version of the debian image
docker pull debian:13
docker pull debian:trixie
# run 'ds make' on these apps
app_list="
sniproxy
revproxy
nsd
wg1
mariadb
postgresql
smtp.user1.fs.al
wordpress1
"
for app in $app_list ; do
cd /var/ds/$app/
ds make
done
docker image prune --force
# nextcloud
cd /var/ds/cloud.user1.fs.al/
ds update
# wordpress
cd /var/ds/wordpress1/
ds site update site1.user1.fs.al
ds site update site2.user1.fs.al
# openldap
cd /var/ds/ldap.user1.fs.al/
ds backup
ds make
ds restore $(ls backup*.tgz | tail -1)
# asciinema
cd /var/ds/asciinema.user1.fs.al/
# https://github.com/asciinema/asciinema-server/releases
ds update # <release>
# clean up
docker image prune --force
docker system prune --forcenano /var/ds/_scripts/update.sh
chmod +x /var/ds/_scripts/update.shimportantIn theory, it is possible to run the script above and update everything automatically, creating also a cron job for it (why not?). And most of the times it would work perfectly. However it is not guaranteed to work every time: when you update, there is always a chance that something gets broken.
For this reason, I usually do the update scripts manually, one by one, checking also that everything is OK after the update, and taking the necessary steps to fix it if something gets broken. I use the script just to remind me of all the steps, and sometimes I also copy/paste commands from it.
Usually it does not take more than a couple of hours, and I update the containers once in a month or two.
-
The same script for the
educontainer looks like this:/var/ds/_scripts/update.sh#!/bin/bash -x
# update the system
apt update
apt upgrade --yes
#reboot
# get the latest version of scripts
cd /opt/docker-scripts/
./git.sh pull
# update ds
cd /opt/docker-scripts/ds/
make install
# get the latest version of the debian image
docker pull debian:13
docker pull debian:trixie
# run 'ds make' on these apps
for app in revproxy mariadb ; do
cd /var/ds/$app/
ds make
done
# run 'ds remake' on these apps
app_list="
vclab.user1.fs.al
mate1
raspi1
"
for app in $app_list ; do
cd /var/ds/$app/
ds remake
done
# moodle
cd /var/ds/edu.user1.fs.al/
ds update
ds remake
# clean up
docker system prune --force
2. Backup intro
2.1 Get more storage space
Let's get another volume on Hetzner. We can use it for backup etc.
Name it Storage, size 50 GB, formatted with ext4, and mounted
automatically.
From inside the VPS, we can change the mount point to /mnt/storage/:
lsblk
umount /dev/sdc
nano /etc/fstab
systemctl daemon-reload
mount -a
lsblk
2.2 Testing Borg
We are going to use BorgBackup, so let's try some tests in order to get familiar with it.
-
Prepare:
apt install borgbackup
mkdir tst
cd tst/ -
Define env variables
BORG_PASSPHRASEandBORG_REPO. They will be used by the commandborg.export BORG_PASSPHRASE='12345678'
export BORG_REPO='/mnt/storage/borg/repo1'
mkdir -p $BORG_REPO -
Initialize repo and export the key:
borg init -e repokey
borg key export > borg.test.repo1.key
cat borg.test.repo1.key -
Create archives:
borg create ::archive1 /etc
borg create --stats ::archive2 /etc
tree /mnt/storage/borgrepo1 -
List archives:
borg list
borg list ::archive1 -
Extract:
borg extract ::archive1
ls
ls etc
rm -rf etc -
Delete archives:
borg delete ::archive1
borg compact
borg list -
Delete borg repository:
borg delete
borg list
ls /mnt/storage/ -
Clean up:
cd ..
rm -rf tst/
See also the Quick Start tutorial of the BorgBackup.
3. Backup scripts
To backup all the installed docker-scripts apps, it is usually enough
to make a backup of the directories /opt/docker-scripts/ and
/var/ds/, since the data of the apps are usually stored on the host
system (in subdirectories of /var/ds/). It is also a good idea to
include /root/ in the backup, since it may contain useful things
(for example maintenance scripts).
We are going to use some bash scripts to automate the backup
process. Let's place these scripts on the directory /root/backup/.
mkdir -p /root/backup/
cd /root/backup/
3.1 The main script
This is the main backup script, that is called to backup everything:
#!/bin/bash -x
cd $(dirname $0)
MIRROR_DIR=${1:-/mnt/storage/mirror}
# mirror everything
./mirror.sh $MIRROR_DIR
# backup the mirror directory
./borg.sh $MIRROR_DIR
# backup the incus setup
./incus-backup.sh
nano backup.sh
chmod +x backup.sh
It has these main steps:
-
Mirror everything (
/root/,/var/ds/and/opt/docker-scripts/) to the directory/mnt/storage/mirror/. This is mainly done byrsync. -
Backup the mirror directory (
/mnt/storage/mirror)using BorgBackup. -
Unrelated to the first two steps, backup also the setup and configuration of Incus, which is located at
/var/lib/incus/. It is commented for the time being, but we will uncomment it later.
3.2 The mirror script
The script mirror.sh mirrors /root/, /var/ds/ and
/opt/docker-scripts/ to the directory /mnt/storage/mirror/.
backup/mirror.sh
#!/bin/bash -x
MIRROR_DIR=${1:-/mnt/storage/mirror}
_rsync() {
local src=$1
local dst=$2
local sync='rsync -avrAX --delete --links --one-file-system'
[[ -f ${src}_scripts/exclude.rsync ]] \
&& sync+=" --exclude-from=${src}_scripts/exclude.rsync"
$sync $src $dst
}
main() {
mirror_host
mirror_incus_containers
mirror_snikket
}
mirror_host() {
local mirror=${MIRROR_DIR}/host
# mirror /root/
mkdir -p $mirror/root/
_rsync /root/ $mirror/root/
# mirror /opt/docker-scripts/
mkdir -p $mirror/opt/docker-scripts/
_rsync \
/opt/docker-scripts/ \
$mirror/opt/docker-scripts/
# backup the content of containers
/var/ds/_scripts/backup.sh
# mirror /var/ds/
stop_docker
mkdir -p $mirror/var/ds/
_rsync /var/ds/ $mirror/var/ds/
start_docker
}
stop_docker() {
local cmd="$* systemctl"
$cmd stop docker
$cmd disable docker
$cmd mask docker
}
start_docker() {
local cmd="$* systemctl"
$cmd unmask docker
$cmd enable docker
$cmd start docker
}
mirror_incus_containers() {
local mirror
local container_list="edu"
for container in $container_list ; do
mirror=$MIRROR_DIR/$container
# mount container
mount_root_of_container $container
# mirror /root/
mkdir -p $mirror/root/
_rsync mnt/root/ $mirror/root/
# mirror /opt/docker-scripts/
mkdir -p $mirror/opt/docker-scripts/
_rsync \
mnt/opt/docker-scripts/ \
$mirror/opt/docker-scripts/
# backup the content of the docker containers
incus exec $container -- /var/ds/_scripts/backup.sh
# mirror /var/ds/
stop_docker "incus exec $container --"
mkdir -p $mirror/var/ds/
_rsync mnt/var/ds/ $mirror/var/ds/
start_docker "incus exec $container --"
# unmount container
unmount_root_of_container
done
}
mount_root_of_container() {
local container=$1
mkdir -p mnt
incus file mount $container/. mnt/ &
MOUNT_PID=$!
sleep 2
}
unmount_root_of_container() {
kill -9 $MOUNT_PID
sleep 2
rmdir mnt
}
mirror_snikket() {
local container=snikket
local mirror=$MIRROR_DIR/$container
# mount container
mount_root_of_container $container
# mirror /root/
stop_docker "incus exec $container --"
mkdir -p $mirror/root/
_rsync mnt/root/ $mirror/root/
start_docker "incus exec $container --"
# unmount container
unmount_root_of_container
}
### call main
main "$@"
wget https://linux-cli.fs.al/apps/part6/backup/mirror.sh
chmod +x mirror.sh
nano mirror.sh
This script is self-explanatory, easy to read and to understand what it is doing. Nevertheless, let’s discuss a few things about it.
-
The script does not only mirror the directories of the host, but also the directories
/root/,/var/ds/and/opt/docker-scripts/in some Incus containers (assuming that we have installed in them Docker and some docker-scripts apps). In this example script, there is only theeducontainer:local container_list="edu"If there are more Incus containers, their names can be added to the list (separated by spaces).
-
Before mirroring
/var/ds/, we run the script/var/ds/_scripts/backup.sh. This script may run the commandds backupon some of the applications. This is needed only for those apps where the content of the app directory is not sufficient for making a successful restore of the application. Usually those are the apps where we would use ads remaketo update, instead of ads make. So, we make these docker-scripts backups, before making a mirror of the directory and a backup to the Storage. This is the backup script on the host:/var/ds/_scripts/backup.sh#!/bin/bash -x
rm /var/ds/*/logs/*.out
# openldap
cd /var/ds/ldap.user1.fs.al/
ds backup
find . -type f -name "backup*.tgz" -mtime +5 -deletechmod +x /var/ds/_scripts/backup.sh
nano /var/ds/_scripts/backup.shOnly for the OpenLDAP container we need to make a backup before mirroring.
We use a similar backup script inside the container
edu:/var/ds/_scripts/backup.sh#!/bin/bash -x
rm /var/ds/*/logs/*.out
# guacamole
cd /var/ds/vclab.user1.fs.al/
ds backup
find . -type f -name "backup*.tgz" -mtime +5 -delete
# linuxmint
cd /var/ds/mate1/
ds users backup
find backup/ -type f -name "*.tgz" -mtime +5 -delete
# raspberrypi
cd /var/ds/raspi1/
ds users backup
find backup/ -type f -name "*.tgz" -mtime +5 -delete
# moodle
cd /var/ds/edu.user1.fs.al/
ds backup
find . -type f -name "backup*.tgz" -mtime +5 -deletechmod +x /var/ds/_scripts/backup.sh
nano /var/ds/_scripts/backup.sh -
Before mirroring
/var/ds/which contains the applications and their data, we make sure to stop Docker, which in turn will stop all the applications. If the data on the disk are constantly changing while we make the mirror, we may get a "mirror" with inconsistent data. -
For the Incus containers, we mount the filesystem of the container to a directory on the host, before mirroring, and unmount it afterwards.
-
For the Incus container
snikket, we mirror only the/root/directory, which also contains/root/snikket/and all the data of the application).
The Incus command incus file mount depends on sshfs, so we should
make sure that it is installed:
apt install sshfs
Let's try it now:
./mirror.sh
tree /mnt/storage/mirror -d -L 2
tree /mnt/storage/mirror -d -L 3
tree /mnt/storage/mirror -d -L 4 | less
du -hs /mnt/storage/mirror
du -hs /mnt/storage/mirror/*
3.3 The borg script
This script makes a backup of the mirror directory to the Borg repository.
backup/borg.sh
#!/bin/bash
export BORG_PASSPHRASE="quoa9bohCeer1ad8bait"
export BORG_REPO='/mnt/storage/borg/mycloud'
### to initialize the repo run this command (only once)
#borg init -e repokey
#borg key export
dir_to_backup=${1:-/mnt/storage/mirror}
# some helpers and error handling:
info() { printf "\n%s %s\n\n" "$( date )" "$*" >&2; }
trap 'echo $( date ) Backup interrupted >&2; exit 2' INT TERM
info "Starting backup"
borg create \
--stats \
--show-rc \
::'{hostname}-{now}' \
$dir_to_backup
backup_exit=$?
info "Pruning repository"
borg prune \
--list \
--glob-archives '{hostname}-*' \
--show-rc \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 6
prune_exit=$?
info "Compacting repository"
borg compact
compact_exit=$?
# use highest exit code as global exit code
global_exit=$(( backup_exit > prune_exit ? backup_exit : prune_exit ))
global_exit=$(( compact_exit > global_exit ? compact_exit : global_exit ))
if [ ${global_exit} -eq 0 ]; then
info "Backup, Prune, and Compact finished successfully"
elif [ ${global_exit} -eq 1 ]; then
info "Backup, Prune, and/or Compact finished with warnings"
else
info "Backup, Prune, and/or Compact finished with errors"
fi
exit ${global_exit}
wget https://linux-cli.fs.al/apps/part6/backup/borg.sh
chmod +x borg.sh
nano borg.sh
The main commands of this script are borg create, borg prune and
borg compact. The last part of the script just shows the status of
these commands.
The script makes sure to keep a backup only for the last 7 days, the last 4 weeks, and the last 6 months (17 backups in total). This prevents the size of the backups from growing without limits. Borg also uses deduplication, compression and compact to keep the size of the backup storage as small as possible.
Borg commands usually need to know on which borg repository they should work, and the passphrase that is used to encrypt that repository. These may be specified on the command line, or with environment variables. We are using environment variables because this is more convenient for a script:
export BORG_PASSPHRASE='XXXXXXXXXXXXXXXX'
export BORG_REPO='/var/storage/borg/mycloud'
-
Before using the borg script, it is necessary to initialize a borg repository for the backup. It can be done like this:
pwgen 20
export BORG_PASSPHRASE='XXXXXXXXXXXXXXXXXXXX'
export BORG_REPO='/mnt/storage/borg/mycloud'
mkdir -p $BORG_REPO
borg init -e repokey -
Save the passphrase and the key.
borg key export > borg.mycloud.keyimportantThe repository data is totally inaccessible without the key and the passphrase. So, make sure to save them in a safe place, outside the server and outside the Storage.
-
Copy the passphrase that was used to initialize the repo to the script
borg.sh, and test it:nano borg.sh
./borg.sh
ls /mnt/storage/borg/
du -hs /mnt/storage/borg/mycloud/
borg list
borg list ::mycloud-2025-04-27T15:04:54 | less
3.4 Run them periodically
We want to run the script backup.sh periodically, but first let's
test it, to make sure that it runs smoothly:
./backup.sh
borg list
du -hs /mnt/storage/*
df -h /mnt/storage/
To run it periodically, let's create a cron job like this:
cat <<'EOF' > /etc/cron.d/backup
30 3 * * * root bash -l -c "/root/backup/backup.sh &> /dev/null"
EOF
It runs the script every night at 3:30. The borg script takes care to keep only the latest 7 daily backups, the latest 4 weekly backups, and the latest 6 monthly backups. This makes sure that the size of the backups (on the Storage) does not grow without limits. Borg also uses deduplication and compression for storing the backups, and this helps to reduce further the size of the backup data.
We are using bash -l -c to run the command in a login
shell. This is because the default PATH variable in the cron
environment is limited and some commands inside the script will fail
to execute because they cannot be found. By running it with bash -l -c we make sure that it is going to have the same environment
variables as when we execute it from the prompt.
We are also using &> /dev/null in order to ignore all the stdout
and stderr output. If the cron jobs produce some output, the cron
will try to notify us by email. Usually we would like to avoid this.