Backing up databases running in containers
When you run a database as a container, its volume is included in the regular mittwald project backup. That protects you against losing the volume — but it does not guarantee that the database files inside it are restorable.
A project backup copies the volume while the database is running. The database keeps data in memory, in write-ahead logs and in partially written pages, so a copy taken at an arbitrary moment can capture a torn state. Restoring it may work, may require crash recovery, or may fail outright — and you will only find out during an incident.
A SQL dump avoids this. The database itself writes out a consistent snapshot of its contents. This guide shows how to schedule such a dump with a cronjob, so that a restorable copy always sits in your volume when the project backup runs.
How this works
The setup consists of three pieces that build on each other:
- A cronjob runs a dump command inside your database container on a schedule.
- The dump is written to a volume as a compressed
.sql.gzfile. Old dumps are deleted after a retention period. - The mittwald project backup picks up that volume as part of its regular run, so the dump is retained off-site along with everything else in the project.
You therefore end up with two layers: the project backup restores the volume, and the dump inside it restores the database.
Prerequisites
To follow this guide, you will need:
- A mittwald project on a plan that supports containerized workloads
- A running PostgreSQL, MariaDB or MySQL container
- For the CLI and API paths: the mittwald CLI (
mw) installed and logged in (see the CLI documentation), plus the stack ID and service name of the container (mw stack listandmw container listshow both). Everything up to and including verification also works entirely in the mStudio UI.
Step 1: Where the dump is stored
The dump goes into a subdirectory of the volume that already holds your database data. That volume is picked up by the project backup, so the dump is carried off-site with it — no additional volume and no stack change required.
This is also what the container templates do:
- PostgreSQL:
/var/lib/postgresql/backups - MariaDB and MySQL:
/var/lib/mysql/backups
Step 2: Choose the dump command
The commands below write a timestamped, gzipped dump and delete dumps older than seven days. They abort without leaving a truncated file behind if the dump fails, so a failed run never overwrites your last good backup with a broken one.
Adjust the d= path if your volume is mounted somewhere else (see step 1).
- PostgreSQL
- MariaDB
- MySQL
sh -lc 'set -e; d=/var/lib/postgresql/backups; mkdir -p "$d";
f=$d/backup_$(date +%F_%H%M%S).sql;
{ pg_dumpall -h localhost -U "$POSTGRES_USER" -f "$f"; } || { rm -f "$f"; exit 1; };
gzip "$f";
find "$d" -name "backup_*.sql.gz" -type f -mtime +7 -delete'
pg_dumpall dumps the entire cluster, including all databases and roles. It
authenticates over localhost without a password, because the official images
grant trust for local TCP connections in their generated pg_hba.conf.
To dump a single database instead, replace pg_dumpall with
pg_dump -h localhost -U "$POSTGRES_USER" -d "$POSTGRES_DB".
sh -lc 'set -e; d=/var/lib/mysql/backups; mkdir -p "$d";
f=$d/backup_$(date +%F_%H%M%S).sql;
{ MYSQL_PWD="$BACKUP_PASSWORD" mariadb-dump -u root --all-databases --routines --events --single-transaction --result-file="$f"; } || { rm -f "$f"; exit 1; };
gzip "$f";
find "$d" -name "backup_*.sql.gz" -type f -mtime +7 -delete'
--single-transaction takes the dump inside one transaction, which gives a
consistent snapshot of InnoDB tables without locking them — your application keeps
writing while the dump runs. Without it, mariadb-dump falls back to locking every
table for the duration of the dump.
sh -lc 'set -e; d=/var/lib/mysql/backups; mkdir -p "$d";
f=$d/backup_$(date +%F_%H%M%S).sql;
{ MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqldump -u root --all-databases --routines --events --single-transaction --result-file="$f"; } || { rm -f "$f"; exit 1; };
gzip "$f";
find "$d" -name "backup_*.sql.gz" -type f -mtime +7 -delete'
--single-transaction takes the dump inside one transaction, which gives a
consistent snapshot of InnoDB tables without locking them — your application keeps
writing while the dump runs.
Step 3: Create the cronjob
- mStudio UI
- CLI
- API
- Navigate to the project that your database container runs in.
- Select "Cronjobs" from the "Components" section of the sidebar.
- Click "Create".
- Enter a name and switch the target from "App" to "Container".
- Pick your database container under "Linked container".
- Paste the dump command from step 2 into "Command to execute".
- Leave the interval on "Cron syntax" and enter a schedule, for example
0 3 * * *for a daily run at 03:00. Set the time zone toEurope/Berlin. - Click "Create".
The dialog has no field for failure notifications. Configure them afterwards: open the cronjob, and use "Edit" in the "Error handling" section to set the timeout and the email address. By default, mittwald notifies you from the first failed run onwards.
Use the mw cronjob create command with the --container-id flag, which targets
a container service instead of an app installation:
user@local $ mw cronjob create \
--container-id postgres \
--description "PostgreSQL dump" \
--interval "0 3 * * *" \
--timezone "Europe/Berlin" \
--timeout 1h \
--email ops@example.com \
--command 'sh -lc '\''set -e; d=/var/lib/postgresql/backups; mkdir -p "$d"; f=$d/backup_$(date +%F_%H%M%S).sql; { pg_dumpall -h localhost -U "$POSTGRES_USER" -f "$f"; } || { rm -f "$f"; exit 1; }; gzip "$f"; find "$d" -name "backup_*.sql.gz" -type f -mtime +7 -delete'\'''
--container-id accepts the container ID, its short ID or the service name.
--interpreter and --url are not accepted for container cronjobs; the command
runs directly inside the container.
Use the POST/
operation with a ServiceTarget, which addresses a container service by stack ID
and service name:
POST /v2/projects/<project-id>/cronjobs HTTP/1.1
Host: api.mittwald.de
Content-Type: application/json
{
"description": "PostgreSQL dump",
"interval": "0 3 * * *",
"timeZone": "Europe/Berlin",
"active": true,
"timeout": 3600,
"concurrencyPolicy": "forbid",
"email": "ops@example.com",
"failedExecutionAlertThreshold": 1,
"target": {
"stackId": "11111111-2222-3333-4444-555555555555",
"serviceIdentifier": "postgres",
"command": "sh -lc 'set -e; d=/var/lib/postgresql/backups; mkdir -p \"$d\"; f=$d/backup_$(date +%F_%H%M%S).sql; { pg_dumpall -h localhost -U \"$POSTGRES_USER\" -f \"$f\"; } || { rm -f \"$f\"; exit 1; }; gzip \"$f\"; find \"$d\" -name \"backup_*.sql.gz\" -type f -mtime +7 -delete'"
}
}
/v2/projects/{projectId}/cronjobs/ Two settings deserve attention:
concurrencyPolicy: "forbid"prevents a second dump from starting while the previous one is still running. Overlapping dumps compete for the same database and can fill the volume.timeoutis given in seconds and defaults to one hour. Large databases need a higher value; the dump is killed when the timeout expires, and the command then removes the partial file.
mw cronjob create covers the timeout with its --timeout flag, but has no
equivalent for concurrencyPolicy — set that one via the API.
Schedule the dump so that it finishes before your project backup runs, and outside of peak traffic hours.
Step 4: Verify the backup
A backup you have never tested is an assumption, not a backup. Trigger the cronjob once manually and check the result.
- mStudio UI
- CLI
- Open the cronjob and click "Run now" in the "Interval" section.
- Switch to the "History" tab. The run appears with its date and runtime.
- A failed run is marked "Execution failed". Open the "..." menu on that entry and choose "Show log" to see the exit code and the error output.
The cronjob overview marks failing cronjobs with the same "Execution failed" badge, so a glance at that list tells you whether your backups are still running. Make it a habit to look — a backup cronjob can fail for weeks without anything else going wrong.
A runtime of 0 seconds together with a failed status usually means the dump never connected to the database.
Confirm that a dump was actually written. The examples use the PostgreSQL path;
for MariaDB and MySQL it is /var/lib/mysql/backups (see step 1).
user@local $ mw container exec <container-id> "ls -la /var/lib/postgresql/backups"
Check three things:
-
The file exists and carries the current timestamp.
-
The size is plausible. A dump of a few hundred bytes usually means the command connected but found no data.
-
The content is readable. Decompress it and look at the beginning:
user@local $ mw container exec <container-id> "gunzip -c /var/lib/postgresql/backups/backup_*.sql.gz | head -20"
To keep a copy outside the platform, download the dump with mw container cp:
user@local $ mw container cp <container-id>:/var/lib/postgresql/backups/backup_2026-09-07_030000.sql.gz ./
Repeat this check after any change to the database version, the volume layout or the dump command.
Restoring a dump
Restore the volume from the project backup first if the container itself is gone, then replay the dump into the running database.
A restore runs a command inside the container, which the mStudio UI does not offer on its own. You have three ways in:
- Web-SSH — install the free 1-Click Web-SSH for Apps & Containers extension from the marketplace. It gives you a terminal for apps and containers directly in mStudio, without a local CLI or SSH keys. The same terminal lets you inspect the dump files in the volume.
- CLI —
mw container ssh <container-id>opens a shell in the container. - Any SSH client —
mw container ssh <container-id> --infoprints the hostname and username without connecting, so you can use OpenSSH or any other client:ssh <username>@<hostname>.
All three drop you inside the container. List the available dumps first — for MariaDB
and MySQL, the directory is /var/lib/mysql/backups (see step 1):
user@container $ ls -la /var/lib/postgresql/backups
Then replay the one you want:
- PostgreSQL
- MariaDB
- MySQL
user@container $ gunzip -c /var/lib/postgresql/backups/backup_2026-09-07_030000.sql.gz | psql -h localhost -U "$POSTGRES_USER" -d postgres
A pg_dumpall dump recreates roles and databases. When you replay it into a
cluster where those already exist, psql reports already exists errors for them
and continues — the table data is still restored. For a clean restore, drop and
recreate the target database first, or restore into an empty container.
user@container $ gunzip -c /var/lib/mysql/backups/backup_2026-09-07_030000.sql.gz | MYSQL_PWD="$BACKUP_PASSWORD" mariadb -u root
The dump contains CREATE DATABASE ... IF NOT EXISTS and DROP TABLE IF EXISTS
statements, so it replaces the tables it contains. Tables that were created after
the dump was taken are not removed.
user@container $ gunzip -c /var/lib/mysql/backups/backup_2026-09-07_030000.sql.gz | MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -u root
The dump contains CREATE DATABASE ... IF NOT EXISTS and DROP TABLE IF EXISTS
statements, so it replaces the tables it contains. Tables that were created after
the dump was taken are not removed.
Because these dumps include the role definitions (PostgreSQL) and the mysql
system database (MariaDB and MySQL), database users and their passwords are
restored along with the data.
Further resources
- Containers — volumes, stacks and project backups
- mittwald container templates