Skip to content
AchillesBackend developer

Backups that cost less than a coffee a month

202615 min read#docker#linux

ON THIS PAGE

Self-hosted backups with restic for under €1 a month. Databases, files, scheduling, monitoring and how to test a restore you can trust.

I was paying my host for backups that I had never tested.

They took one click to turn on, and they added to the bill every month. So I asked a simple question: how cheap can reliable backups be if I build them myself?

The answer is under €1 a month, less than a coffee. In this guide I show you the whole setup, step by step. At the end, you test a restore, so you know that your backups really work.

What you’ll build

A backup that runs every night, is encrypted, tells you when something is wrong, and is tested. It costs under €1 a month.

My example is a small online shop. It runs Sylius and a Symfony app that syncs the products. Both run with Docker Compose on one server. You can follow the same steps for your own app.

The nightly backup, from the app to off-site storage

Why not the host’s backup button

My server is at Hetzner. There, backups cost 20% of the server price, every month. You get seven daily copies of the whole disk. This is good if the whole server breaks. But you can only restore the whole disk, not one database or one folder. And you cannot take the backups to another machine to test them.

I wanted backups that I can open anywhere and keep for months. And I wanted them to cost less than the button.

The tooling

You need two things: a tool that packs your data, and a place to store it.

restic

restic (opens in a new tab) is a backup program. It is a single file that you copy to the server. Nothing needs to run in the background.

A few words you will see often:

  • Repository: the place where restic stores your backups. It can be a bucket, a folder or a server.
  • Snapshot: the result of one backup run. It is a complete copy of your files at that moment. You can restore any snapshot.
  • Deduplication: restic splits files into small pieces and stores each piece only once. If an image did not change since yesterday, restic does not upload it again. So 100 snapshots take about the same space as one.
  • Encryption: restic encrypts everything on your server before it uploads it. The storage provider cannot read your data.
bash
# download the release; the package in your Linux distribution is often older
curl -fsSL -o restic.bz2 https://github.com/restic/restic/releases/download/v0.19.1/restic_0.19.1_linux_amd64.bz2
bunzip2 restic.bz2
sudo install -m 755 restic /usr/local/bin/restic

Where to keep the backups

restic works with many kinds of storage: S3-compatible services (Cloudflare R2, Backblaze B2, AWS S3, Wasabi), an SFTP server, its own rest-server, or a local disk.

When you compare providers, check four things:

  • Price per GB: what you pay each month to store your data.
  • Egress fees: what you pay to download your data. Some providers charge a lot for this, and you notice it only when you restore.
  • Location: the country where your data is stored, and which laws apply to it.
  • Object lock: the provider can refuse to delete files for a set time. This protects your backups. The security section explains more.

I chose Cloudflare R2 because it has no egress fees. So a test restore costs nothing.

Create the repository

restic reads its settings from environment variables. I keep them in one file that only the backup user can read:

/opt/backups/backups.env
RESTIC_REPOSITORY=s3:https://<ACCOUNT_ID>.r2.cloudflarestorage.com/backups
RESTIC_PASSWORD=<long random password>
AWS_ACCESS_KEY_ID=<R2 access key>
AWS_SECRET_ACCESS_KEY=<R2 secret key>
AWS_DEFAULT_REGION=auto
HC_URL=https://hc-ping.com/<check-uuid>
bash
sudo install -d -m 700 -o "$USER" /opt/backups  # a folder that only you can open
chmod 600 /opt/backups/backups.env
openssl rand -base64 32                          # create a strong repository password

set -a; source /opt/backups/backups.env; set +a  # load the settings
restic init                                      # create the repository

The R2 keys come from an R2 API token. Give the token access to this one bucket only. R2 uses the same API as Amazon S3, so restic connects to it with its s3: backend.

Back up the databases

Do not copy the database files directly. The database changes them while you copy, so the copy can be broken. Often you find this out only when you try to restore.

Make a dump instead. A dump is a file with SQL commands that rebuild the database. The database creates it, so the data is consistent: it shows the whole database at one moment.

  • MySQL: mysqldump --single-transaction reads all data inside one transaction. The dump is consistent, and the shop keeps working while the dump runs. This works for InnoDB tables, which Sylius uses.
  • PostgreSQL: pg_dumpall dumps all databases and the users (roles).

My databases run in Docker containers, so I run the dump tools inside the containers. Use exec -T in scripts, because cron has no terminal:

bash
# MySQL: the password comes from the container's own settings
docker compose -f /opt/shop/compose.yaml exec -T database \
  sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --routines --events --all-databases' \
  > /opt/backups/dumps/shop.sql

# PostgreSQL
docker compose -f /opt/sync/compose.yaml exec -T database \
  sh -c 'exec pg_dumpall -U "$POSTGRES_USER"' \
  > /opt/backups/dumps/sync.sql

Note the single quotes. Because of them, the container reads $MYSQL_ROOT_PASSWORD itself. So the password does not appear in your shell history or in the list of running processes.

My databases are small, so I dump them completely every night. Thanks to deduplication, the parts that did not change take almost no space.

Back up the files

A simple rule: back up only what you cannot create again.

What I back up:

  • the uploaded images, about 22 GB
  • the project folders, with the .env files
  • the keys, such as the JWT keys and the payment encryption key

What I skip goes into an exclude file. restic ignores every path in it:

/opt/backups/excludes.txt
# Thumbnails. The app creates them again when needed
/opt/shop/public/media/cache

# Caches and logs
/opt/shop/var
/opt/sync/var/cache
/opt/sync/var/log

# Product feeds. The app creates them twice a day
/opt/shop/public/feeds

# Supplier XML files. The sync downloads them again every time
/opt/sync/var/feeds

# Packages. npm install downloads them again
node_modules

The thumbnail cache alone is about 19 GB. Without it, the backup is about half the size.

The backup script

One script does all the work each night. Let’s build it step by step.

Stop on errors. If one step fails, the script must stop. Otherwise you get a half-finished backup:

bash
#!/usr/bin/env bash
set -euo pipefail  # stop on any error, unset variable or failed pipe
umask 077          # new files are readable only by you

Set the PATH and load the settings. PATH is the list of folders where the shell looks for programs. The cron section explains why the script sets it. set -a makes every variable in the env file available to restic:

bash
export PATH=/usr/local/bin:/usr/bin:/bin
set -a; source /opt/backups/backups.env; set +a

Report to healthchecks.io. This small function sends a ping, a short web request, to healthchecks.io. If the ping fails, the script continues. A problem with monitoring must not stop the backup:

bash
hc() { curl -fsS -m 10 --retry 5 -o /dev/null "$HC_URL$1" || true; }

trap 'hc /fail' ERR  # if any command fails, ping /fail; then the script stops
hc /start

Make the dumps, then back up. After the dumps from the database section, one restic command backs up the dumps and both projects. The result is one snapshot:

bash
restic backup --exclude-file /opt/backups/excludes.txt \
  /opt/backups/dumps /opt/shop /opt/sync

Delete old snapshots. Retention means how long you keep old backups. Without a retention rule, the repository grows forever:

bash
restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6

This keeps one snapshot per day for 7 days, one per week for 4 weeks and one per month for 6 months. forget removes all other snapshots from the list. --prune then deletes the data that no snapshot uses any more. In a test with 200 nightly runs, restic kept 13 snapshots.

Here is the full script:

/usr/local/bin/backup.sh
#!/usr/bin/env bash
# Nightly backup: database dumps + files → restic → R2
set -euo pipefail
umask 077

# cron's PATH does not include /usr/local/bin
export PATH=/usr/local/bin:/usr/bin:/bin

# RESTIC_REPOSITORY, RESTIC_PASSWORD, the R2 keys and HC_URL
set -a
source /opt/backups/backups.env
set +a

DUMPS=/opt/backups/dumps

hc() { curl -fsS -m 10 --retry 5 -o /dev/null "$HC_URL$1" || true; }

trap 'hc /fail' ERR
hc /start

mkdir -p "$DUMPS"

# Shop (MySQL)
docker compose -f /opt/shop/compose.yaml exec -T database \
  sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --routines --events --all-databases' \
  > "$DUMPS/shop.sql"

# Sync app (PostgreSQL)
docker compose -f /opt/sync/compose.yaml exec -T database \
  sh -c 'exec pg_dumpall -U "$POSTGRES_USER"' \
  > "$DUMPS/sync.sql"

restic backup --exclude-file /opt/backups/excludes.txt "$DUMPS" /opt/shop /opt/sync

restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6

hc ""

The script runs as the user who owns the projects. This user must be in the docker group, but does not need root. Run the script once by hand and read the output.

Run it every night

cron is the Linux service that runs commands on a schedule. With cron, you do not need to remember to run the backup.

Open your crontab, the list of your scheduled commands:

bash
crontab -e

Add this line:

bash
# min hour day month weekday  command
30 0 * * * /usr/local/bin/backup.sh >> /opt/backups/backup.log 2>&1

The first five fields are the time: minute 30, hour 0, every day of the month, every month, every day of the week. So the script runs every night at 00:30, when the shop is quiet.

>> adds the output to the end of a log file. 2>&1 sends error messages to the same file. Without them, cron tries to email the output to you, and on most servers that email never arrives.

You can test this before cron runs the script. This command starts the script with an almost empty environment, like cron does:

bash
env -i HOME="$HOME" /bin/sh -c /usr/local/bin/backup.sh

If you prefer systemd, a timer with Persistent=true does the same job. It also runs a backup that was missed while the server was off.

Know when it fails

A backup usually does not fail with a clear error message. It just stops working. Maybe a password changed, the disk is full, or the cron job stopped running. Without monitoring, you notice only when you need the backup.

healthchecks.io (opens in a new tab) watches for this. You create a check and say “I expect a ping every day”. Then the script sends pings:

  • /start when the backup starts
  • the plain address when the backup worked
  • /fail when a step failed, through the trap

If a fail ping arrives, healthchecks.io sends you an email. If no ping arrives in the expected time, it also sends you an email. This second case is the most important one. It finds the problems where the script cannot report anything itself. The free plan is enough for a few backup jobs.

Keep the backups safe

A backup contains everything an attacker wants: customer data, .env files and keys. And after an attack, the backup is what you need most. So protect it.

The repository password. restic encrypts everything with this password. If you lose it, you lose the backups. There is no way to reset it. Keep a copy outside the server, for example in a password manager.

An attacker on the server can delete the backups. My script deletes old snapshots, so the key on the server is allowed to delete data. If someone takes over the server, they can delete the backups too. There are two ways to stop this:

  • Append-only mode: the storage accepts new data, but refuses to delete anything. restic’s rest-server (opens in a new tab) has this mode, with --append-only.
  • Object lock: the storage refuses to delete files for a set time, even with a valid key. On R2 this is called bucket locks.

With both options, you delete old snapshots from another machine that you trust, with a key that the server does not have.

Separate keys for restores. A restore key is as sensitive as a production key. Create a separate token only for restores. Give it read-only access to the backup bucket. Set an expiry date, and delete the token when the restore is done.

A second copy. This setup has two copies of the data: the live server and R2. A well-known rule for backups asks for three. restic copy copies snapshots to a second repository, for example on a USB disk, and only sends what is new.

What it costs

The first run read 21 GiB and uploaded 7.77 GiB, after the exclusions, deduplication and compression. Since then, each nightly run takes about 11 seconds, because restic uploads only new dumps and new images.

R2 prices are in US dollars. In euros, storage costs about €0.013 per GB per month, and the first 10 GB each month are free. Uploads and downloads stay far inside the free limits, and there are no egress fees.

So at the moment, my backups fit in the free 10 GB tier. At 50 GB, they would cost about €0.53 a month. They stay under €1 until about 85 GB. Hetzner’s backup button costs 20% of the server price, no matter how much data you have.

Test the restore

A backup only counts once you have tested a restore. Until then, you only hope that it works.

Do the test on a different machine, not on the server. When I tested my backups, the full catalogue and all orders came back, and the Greek text was correct.

List the snapshots

bash
restic snapshots

You see one line per snapshot, with its ID and time. latest always means the newest one.

Restore the files

--include selects the folders you need:

bash
restic restore latest --target /tmp/restore \
  --include /opt/backups/dumps \
  --include /opt/shop/public/media

restic restores the files with their full original path. So the images are in /tmp/restore/opt/shop/public/media.

Restore a database

Import the dump into a fresh database container. For MySQL, tell the client to use utf8mb4:

bash
docker compose exec -T database \
  sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot --default-character-set=utf8mb4' < shop.sql

This is easiest with one dump file per database, as recommended in the database section. A dump of all databases restores everything at once, including MySQL’s own system tables. Taking only one database out of it needs manual editing.

For PostgreSQL, import the pg_dumpall file with psql:

bash
docker compose exec -T database \
  sh -c 'exec psql -U "$POSTGRES_USER" -d postgres' < sync.sql

The error role ... already exists is normal here. The container already created that user.

Check the repository

From time to time, also check the repository itself:

bash
restic check                        # checks the structure
restic check --read-data-subset=10% # also downloads 10% of the data and checks it

Write down every step while you test. Then, on the day you really need a restore, you just follow your notes.

That’s it: encrypted, monitored and tested backups for under €1 a month, less than one coffee.

Did this help?

Comments

via GitHub Discussions