MySQL, MariaDB, PostgreSQL and SQLite backups on Alpine - 7-Zip, S3, cron, rebuilt weekly
10K+
This Docker image is managed and kept up to date by epicsoft LLC.
Database backups for MySQL, MariaDB, PostgreSQL and SQLite on the official Alpine image. Depending on the configuration, the dump is compressed and encrypted with 7-Zip and uploaded to an S3 storage.
linux/amd64 and linux/arm64Working directory: /backup
The image supports two modes:
CRON_SCHEDULE to a cron expression and the container stays running and creates the backups itself.In cron mode a failing backup does not stop the container — the error is logged and the next scheduled run is executed as usual. If a backup is still running when the next run is due, that run is skipped with a warning instead of running twice in parallel. All output of the scheduled backups is written to the container log (docker logs).
Use TZ to set the timezone the schedule is evaluated in — without it the schedule uses UTC.
A running cron container can create a backup right away, which is the quickest way to check whether a new configuration actually works:
docker exec <container> backup-now
The manual run goes through the same code as a scheduled one - same environment, same lock, same compression, upload, retention and webhooks - and its output appears in the terminal instead of in docker logs. It exits with the exit code of the backup, so it can be used in a script or a pipeline. If a scheduled backup happens to be running at that moment, the manual run is skipped with a warning instead of running a second dump in parallel.
CRON_RUN_ON_START=true does the same thing once at container start.
The image brings its own healthcheck. It does not just look for a running cron daemon - that alone would report a healthy container while every single backup has been failing for weeks. Instead it reports unhealthy when:
HEALTHCHECK_MAX_AGE is set and the last successful backup is older than thatThe third rule is the one that makes the check worth having, because it is the only one that notices a schedule that quietly stopped firing. Set it a little above the backup interval, so a single late run does not raise a false alarm:
services:
backup:
image: epicsoft/dbbackup:2.0
environment:
BACKUP: mariadb
MARIADB_HOST: db
MARIADB_USERNAME: <USERNAME>
MARIADB_PASSWORD_FILE: /run/secrets/db_password
CRON_SCHEDULE: "0 3 * * *"
TZ: Europe/Berlin
HEALTHCHECK_MAX_AGE: 26h
secrets:
- db_password
volumes:
- ./backups:/backup:rw
HEALTHCHECK_MAX_AGE accepts m for minutes, h for hours and d for days; a plain number is read as minutes. Until the first backup has run, the start of the container counts as the reference, so a fresh container is not reported unhealthy before its first run is genuinely overdue. A one-shot container is never reported unhealthy, since there is nothing long running to watch.
The reason for an unhealthy container is shown in docker inspect:
docker inspect --format '{{json .State.Health}}' <container>
The interval, timeout and retries of the check can be overridden per service with the healthcheck key in the compose file.
Every password and key can be read from a file instead of an environment variable by appending _FILE to the variable name and pointing it at the file. This is meant for Docker secrets, Kubernetes secret mounts and comparable mechanisms, so the credential never has to appear in the container configuration.
MARIADB_PASSWORD_FILEMYSQL_PASSWORD_FILEPGSQL_PASSWORD_FILECOMPRESS_ENCRYPTION_PASSWORD_FILES3_ACCESS_KEY_FILES3_SECRET_KEY_FILEWEBHOOK_SUCCESS_URL_FILEWEBHOOK_FAILURE_URL_FILENo password is passed on the command line, where it would be readable for every process in the container and through docker top on the host. For MySQL and MariaDB the dump is started with a --defaults-extra-file that is created at runtime with mode 0600 and removed again when the backup ends. For PostgreSQL the password is handed over in PGPASSWORD, and 7-Zip reads its encryption password from stdin - neither is visible there either.
A trailing newline in the file is stripped. Setting a variable and its _FILE counterpart at the same time is an error, as is a missing, unreadable or empty file. In cron mode the file is read again on every scheduled run.
The backup itself is the database in plain text and it stays on the volume after the container is gone, so it is not created world readable either. Everything a run writes gets mode 0640 for files and 0750 for directories, adjustable with BACKUP_UMASK. Encrypt the backup with COMPRESS_ENCRYPTION_PASSWORD if the file mode alone is not enough for where it is stored.
Old backup files in the working directory can be removed automatically after a successful backup. Two rules are available and both are off by default:
RETENTION_KEEP_LAST keep the given number of newest backupsRETENTION_KEEP_DAYS keep every backup that is not older than the given number of daysA backup file is removed only if neither rule wants to keep it. That makes RETENTION_KEEP_LAST a floor under RETENTION_KEEP_DAYS: if the backups fail for longer than RETENTION_KEEP_DAYS, the last working backups are still kept instead of being deleted for being too old. Using RETENTION_KEEP_DAYS without RETENTION_KEEP_LAST is possible, but the combination of both is recommended.
The cleanup only ever touches files of this backup in the working directory, that is files matching *.<FILENAME>.sql* with the MARIADBDUMP_FILENAME, MYSQLDUMP_FILENAME or PGSQL_FILENAME of the current configuration. Other files in the directory, including backups written with a different filename, are left alone. Empty files left behind by a failed dump are always removed and never count as a backup.
A dump that was aborted leaves a truncated file behind, because the shell creates the target of the redirection before the dump command fails. Such a file is removed at the end of a failed run, so it can never be mistaken for a backup. A dump that completed is kept even if a later step - compression or upload - fails.
The cleanup runs only after the dump - and if enabled, the upload - succeeded. A failed backup never deletes anything. Files that were already uploaded to S3 are not affected; use the lifecycle rules of the bucket for retention there.
Two independent URLs can be called when a backup run ends:
WEBHOOK_SUCCESS_URL is called after a run that completed without an errorWEBHOOK_FAILURE_URL is called after a run that failed anywhere - a missing configuration value, a failing dump, a failing compression or a failing uploadThe URL is used exactly as given, so they can point at two different endpoints or at the same endpoint with different query parameters:
docker run --rm \
-e BACKUP=mysql \
-e MYSQL_HOST=example.com \
-e MYSQL_USERNAME=<USERNAME> \
-e MYSQL_PASSWORD='<PASSWORD>' \
-e WEBHOOK_SUCCESS_URL='https://example.com/hook?job=nightly&status=ok' \
-e WEBHOOK_FAILURE_URL='https://example.com/hook?job=nightly&status=failed' \
-v /tmp:/backup:rw \
epicsoft/dbbackup:2.0
WEBHOOK_METHOD selects how they are called and applies to both:
GET (default) calls the URL and sends nothing elsePOST sends a JSON body describing the run, with Content-Type: application/json{
"status": "success",
"backup": "mariadb",
"hostname": "0548b1cc0fa7",
"database_host": "db.example.com",
"databases": "demo",
"started_at": "2026-08-26T05:14:28Z",
"finished_at": "2026-08-26T05:14:31Z",
"duration_seconds": 3,
"exit_code": 0,
"file_name": "20260826-051428.backup.sql.7z",
"file_path": "/backup",
"file_size": 817,
"compressed": true,
"encrypted": true,
"s3_target": "s3://backups/nightly/20260826-051428.backup.sql.7z"
}
file_size is the size of the backup file in bytes - after compression, if that is enabled, so it is the size of the file that file_name names. compressed says whether the dump was packed with 7-Zip and encrypted whether that archive is password protected - the two are independent, compression without a password is possible. The body never contains a password, a key or a token. Every field that is not known yet is sent as null, which is what most fields look like when the run failed before the dump started. status is success or failure and exit_code is the exit code of the backup.
A webhook that cannot be reached or that answers with anything other than 2xx is logged as a warning and does not turn a successful backup into a failed one. Each call waits at most 10 seconds and is retried twice. Because a webhook URL often contains a token, WEBHOOK_SUCCESS_URL_FILE and WEBHOOK_FAILURE_URL_FILE are available as well.
Released through a git tag X.Y.Z, which publishes X.Y.Z, X.Y, X and moves latest. Changes and the contained
versions are listed in the changelog.
| Tag | Content |
|---|---|
2.0.2 | exactly this build - use this in production |
2.0 | latest patch of 2.0 - safe to follow, no behaviour changes |
2 | latest minor of 2 - new features, no breaking changes |
latest | the main branch, rebuilt weekly on alpine:latest - can change the major version without warning |
develop | current state of the develop branch, not for production |
With a backup image, a silently changed behaviour is not noticed when it happens but when the backup is needed. Pin
X.Y.ZorX.Y.
2.0.2 contains the MariaDB client 11.8.8, the PostgreSQL clients 17.11 and 18.6, SQLite 3.53.4, AWS CLI
2.34.63, 7-Zip 26.01, OpenSSL 3.5.9 and Alpine 3.24.2. A release tag is built once and keeps these versions.
BACKUP type of database backup, one of mysql, mariadb, pgsql, sqlite requiredCRON_SCHEDULE cron expression with 5 fields, for example 0 3 * * * for a daily backup at 03:00, or one of @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly and @reboot (runs once at container start, never again). A single line of letters, digits, @, *, ,, /, - and spaces - anything else is rejected on start instead of silently never running. If empty the container creates one backup and exits (default: empty)CRON_RUN_ON_START create a backup immediately when the container starts, additionally to the schedule (default: false)HEALTHCHECK_MAX_AGE how old the last successful backup may get before the container is reported unhealthy, for example 26h. Understands m, h and d, a plain number counts as minutes. If empty the healthcheck only reports a dead cron daemon and a failed run (default: empty)BACKUP_UMASK octal file mode mask for everything a run writes — dump, archive and staging directory. 027 gives files 0640 and directories 0750; set 022 if the backups are read on the host as another user (default: 027)TZ timezone the cron schedule is evaluated in, for example Europe/Berlin (default: UTC)WEBHOOK_METHOD how the webhooks are called, GET or POST (default: GET)WEBHOOK_SUCCESS_URL URL to call after a successful backup, or WEBHOOK_SUCCESS_URL_FILE (default: empty)WEBHOOK_FAILURE_URL URL to call after a failed backup, or WEBHOOK_FAILURE_URL_FILE (default: empty)RETENTION_KEEP_LAST number of newest backups to keep in the working directory. If empty this rule is not applied (default: empty)RETENTION_KEEP_DAYS keep backups in the working directory that are not older than this number of days. If empty this rule is not applied (default: empty)STAGING_DIR directory the raw dump is written to while COMPRESS_ENABLE is on, so only the finished archive reaches /backup and no unpacked dump ends up in a snapshot of the backup storage. Must be on the container's own file system, not inside /backup; without compression it has no effect (default: /tmp/dbbackup)COMPRESS_ENABLE enable compression of dump with 7-Zip (default: false)COMPRESS_OPTIONS 7-Zip arguments for compression (default: a -t7z -md=20 -m0=lzma2 -mx=9 -mmt=on -aoa)COMPRESS_ENCRYPTION_PASSWORD 7-Zip password for encryption, or COMPRESS_ENCRYPTION_PASSWORD_FILE (default: empty)S3_ENABLE enable S3 upload (default: false)S3_ACCESS_KEY your S3 access key, or S3_ACCESS_KEY_FILE requiredS3_SECRET_KEY your S3 secret key, or S3_SECRET_KEY_FILE requiredS3_REGION S3 bucket region required (not required for MinIO)S3_BUCKET your S3 bucket path requiredS3_PREFIX path prefix in your bucket (default: backup)S3_ENDPOINT S3 Endpoint URL without schema, for S3 Compliant APIs such as MinIO. The endpoint is always called over https (default: empty)S3_FILENAME a consistent filename to overwrite with your backup. If not set will use a timestamp.S3_S3V4 set to true to enable Signature Version 4, required for MinIO servers (default: false)only if BACKUP is mariadb
MARIADB_HOST database host requiredMARIADB_PORT database port (default: 3306)MARIADB_USERNAME database username requiredMARIADB_PASSWORD database password, or MARIADB_PASSWORD_FILE requiredMARIADBDUMP_OPTIONS dump options (default: --verbose --hex-blob --quote-names --quick --add-drop-table --add-locks --create-options --allow-keywords --disable-keys --extended-insert --single-transaction --comments --no-tablespaces)MARIADBDUMP_ADD_OPTIONS additional dump options (default: empty)MARIADBDUMP_DATABASES list of databases to backup (default: --all-databases)MARIADBDUMP_FILENAME name of backup file, only letters, digits, ., _ and - (default: backup)only if BACKUP is mysql
MYSQL_HOST database host requiredMYSQL_PORT database port (default: 3306)MYSQL_USERNAME database username requiredMYSQL_PASSWORD database password, or MYSQL_PASSWORD_FILE requiredMYSQLDUMP_OPTIONS dump options (default: --verbose --hex-blob --quote-names --quick --add-drop-table --add-locks --create-options --allow-keywords --disable-keys --extended-insert --single-transaction --comments --no-tablespaces)MYSQLDUMP_ADD_OPTIONS additional dump options (default: empty)MYSQLDUMP_DATABASES list of databases to backup (default: --all-databases)MYSQLDUMP_FILENAME name of backup file, only letters, digits, ., _ and - (default: backup)only if BACKUP is pgsql
PGSQL_HOST database host requiredPGSQL_PORT database port (default: 5432)PGSQL_USERNAME database username requiredPGSQL_PASSWORD database password, or PGSQL_PASSWORD_FILE requiredPGSQL_OPTIONS dump options (default: --verbose)PGSQL_DATABASES list of databases to backup (default: --all-databases)PGSQL_FILENAME name of backup file, only letters, digits, ., _ and - (default: backup)PGSQL_VERSION Version number of the pg-client; possible values are 17 and 18 (default: 17)only if BACKUP is sqlite
SQLITE_DATABASE path to the SQLite database file inside the container, several paths separated by spaces (only with SQLITE_MODE=dump) required for BACKUP=sqliteSQLITE_MODE backup uses the built-in online backup and writes a self-contained database file, dump writes SQL text (default: backup)SQLITE_FILENAME name of the backup file, without timestamp and extension, only letters, digits, ., _ and - (default: backup)SQLITE_INTEGRITY_CHECK run PRAGMA integrity_check and fail the run instead of storing an unusable backup. In backup mode the copy is checked, in dump mode the source (default: true)SQLITE_REPORT_SIZE additionally log the compacted size (VACUUM INTO) next to the file size. SQLite does not return deleted pages to the file system, so a growing file is not necessarily growing data (default: false)Alpine packages are installed in the current image in the latest version - https://pkgs.alpinelinux.org/packages
docker run --rm \
-e BACKUP=mysql \
-e MYSQL_HOST=example.com \
-e MYSQL_USERNAME=<USERNAME> \
-e MYSQL_PASSWORD='<PASSWORD>' \
-v /tmp:/backup:rw \
epicsoft/dbbackup:2.0
docker run --rm \
-e BACKUP=pgsql \
-e PGSQL_HOST=example.com \
-e PGSQL_PORT=5432 \
-e PGSQL_USERNAME=<USERNAME> \
-e PGSQL_PASSWORD='<PASSWORD>' \
-e PGSQL_FILENAME=pg-example \
-e PGSQL_VERSION=17 \
-v /tmp:/backup:rw \
epicsoft/dbbackup:2.0
The database is a file, so it has to be mounted into the container, and the
container needs to be able to read a file that belongs to the application - hence
group_add.
docker run --rm \
-e BACKUP=sqlite \
-e SQLITE_DATABASE=/data/grafana.db \
-e SQLITE_FILENAME=grafana \
-e RETENTION_KEEP_DAYS=30 \
-e COMPRESS_ENABLE=true \
--group-add 472 \
-v /srv/grafana:/data:rw \
-v /mnt/backup/grafana:/backup:rw \
epicsoft/dbbackup:2.0
SQLITE_MODE=backup is the default and the safe one while the application is
writing. A plain file copy can catch the database mid-write, and the -wal and
-shm files belonging to it are not part of the copy - the result looks fine
until it is restored.
Why the source is mounted
rwand notro: a database in WAL mode cannot even be read from a read-only mount, because SQLite has to create the-shmfile next to it.:roworks only for databases that use a rollback journal. The image detects the case and says so instead of failing withunable to open database file.
docker run --rm \
-e BACKUP=mariadb \
-e MARIADB_HOST=example.com \
-e MARIADBDUMP_DATABASES=<DATABASE-NAME> \
-e MARIADB_USERNAME=<USERNAME> \
-e MARIADB_PASSWORD='<PASSWORD>' \
-e COMPRESS_ENABLE=true \
-e COMPRESS_ENCRYPTION_PASSWORD='<COMPRESS-PASSWORD>' \
-v /tmp:/backup:rw \
epicsoft/dbbackup:2.0
docker run --rm \
-e BACKUP=mysql \
-e MYSQL_HOST=example.com \
-e MYSQL_USERNAME=<USERNAME> \
-e MYSQL_PASSWORD_FILE=/run/secrets/mysql_password \
-v /path/to/mysql_password:/run/secrets/mysql_password:ro \
-v /tmp:/backup:rw \
epicsoft/dbbackup:2.0
With Docker Compose and a secret the same setup looks like this:
services:
backup:
image: epicsoft/dbbackup:2.0
environment:
BACKUP: mysql
MYSQL_HOST: example.com
MYSQL_USERNAME: <USERNAME>
MYSQL_PASSWORD_FILE: /run/secrets/mysql_password
CRON_SCHEDULE: "0 3 * * *"
TZ: Europe/Berlin
secrets:
- mysql_password
volumes:
- /tmp:/backup:rw
secrets:
mysql_password:
file: ./mysql_password.txt
docker run -d --restart unless-stopped \
-e BACKUP=mysql \
-e MYSQL_HOST=example.com \
-e MYSQL_USERNAME=<USERNAME> \
-e MYSQL_PASSWORD='<PASSWORD>' \
-e CRON_SCHEDULE='0 3 * * *' \
-e TZ=Europe/Berlin \
-v /tmp:/backup:rw \
epicsoft/dbbackup:2.0
S3 upload tested with Wasabi
docker run --rm \
-e BACKUP=mariadb \
-e MARIADB_HOST=example.com \
-e MARIADBDUMP_DATABASES=<DATABASE-NAME> \
-e MARIADB_USERNAME=<USERNAME> \
-e MARIADB_PASSWORD='<PASSWORD>' \
-e S3_ENABLE=true \
-e S3_ACCESS_KEY=<ACCESS-KEY> \
-e S3_SECRET_KEY=<SECRET-KEY> \
-e S3_BUCKET=backup \
-e S3_PREFIX=subdirectory \
-e S3_ENDPOINT=<ENDPOINT-WITHOUT-SCHEMA> \
-e S3_S3V4=true \
epicsoft/dbbackup:2.0
Known errors and their fixes are collected in TROUBLESHOOTING.md:
Access denied for user ... (using password: YES) although the password is correctSQLite database '...' uses WAL and its directory is not writableTLS/SSL error: self-signed certificate in certificate chainIssues are welcome on GitLab and GitHub. Please report a security problem as a confidential issue on GitLab instead of a public one.
MIT — Copyright (c) 2024-2026 epicsoft LLC / Alexander Schwarz
The MIT license applies only to files created by epicsoft LLC. Third-party software in the image — the database
clients, 7-Zip, the AWS CLI, the Alpine packages and the official alpine image — remains under its own licenses.
Content type
Image
Digest
sha256:7c27700f0…
Size
64.8 MB
Last updated
2 days ago
docker pull epicsoft/dbbackup