Official MySQL and MariaDB images make it easy to give each application its own database container. Here’s a setup that keeps your data safe and your database private, with the backup and upgrade routines you’ll need later.
Compose file (MariaDB)
services:
db:
image: mariadb:11.4
restart: unless-stopped
environment:
MARIADB_DATABASE: app
MARIADB_USER: app
MARIADB_PASSWORD_FILE: /run/secrets/db_password
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
MARIADB_AUTO_UPGRADE: "1"
secrets: [db_password, db_root_password]
volumes:
- dbdata:/var/lib/mysql
- ./conf.d:/etc/mysql/conf.d:ro
- ./initdb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
volumes:
dbdata:
secrets:
db_password:
file: ./secrets/db_password.txt
db_root_password:
file: ./secrets/db_root_password.txt
For MySQL, use image: mysql:8.4 and the MYSQL_* variable names (MYSQL_DATABASE, MYSQL_USER, MYSQL_PASSWORD_FILE, MYSQL_ROOT_PASSWORD_FILE); the health check becomes ["CMD", "mysqladmin", "ping", "-h", "localhost"].
mkdir -p secrets conf.d initdb
openssl rand -base64 24 > secrets/db_password.txt
openssl rand -base64 24 > secrets/db_root_password.txt
chmod 600 secrets/*
docker compose up -d
Notes:
- Pin a version (
mariadb:11.4LTS,mysql:8.4LTS).latestmay jump to a new major version. - No published port — app containers on the same network connect to
db:3306. If host tools need it, publish on127.0.0.1:3306only. - Variables like the database name and passwords apply only on first initialisation (empty volume).
MARIADB_AUTO_UPGRADErunsmariadb-upgradeautomatically when you move to a newer version.
Custom configuration
conf.d/custom.cnf:
[mysqld]
innodb_buffer_pool_size = 512M
max_connections = 100
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
slow_query_log = 1
long_query_time = 1
Restart the container to apply. Size the buffer pool to the memory available to the container — see MySQL performance tuning.
Init scripts
*.sql, *.sql.gz and *.sh files in ./initdb run once on first start — import a dump or create extra databases:
cp ~/old-site.sql.gz initdb/10-import.sql.gz
Connect
docker compose exec db mariadb -u app -p app
docker compose exec db mysql -u app -p app # MySQL image
Backups
docker compose exec -T db sh -c 'mariadb-dump -u root -p"$(cat /run/secrets/db_root_password)" --single-transaction --routines --triggers --all-databases' | gzip > db-$(date +%F).sql.gz
(For MySQL: mysqldump and the same options.) Restore:
gunzip < db-2026-11-04.sql.gz | docker compose exec -T db sh -c 'mariadb -u root -p"$(cat /run/secrets/db_root_password)"'
Automate with cron, keep several days, and copy them off the server. Don’t copy the raw volume while the database is running. See mysqldump backup and restore.
Upgrades
- Minor versions (11.4.x):
docker compose pull && docker compose up -d. - MariaDB major versions: generally upgradable in place with
MARIADB_AUTO_UPGRADE=1; take a dump first. - MySQL major versions: in-place upgrades are supported between consecutive LTS lines (e.g. 8.0 → 8.4); always back up, and read the release notes for removed features (like
mysql_native_passwordbeing disabled by default in 8.4). - Switching between MySQL and MariaDB: use dump and restore, not the data volume — see MariaDB vs MySQL.
Troubleshooting
- App can’t connect at first start — the database was still initialising. Use
depends_on: condition: service_healthy— see Docker health checks. - “Access denied” after changing the password in
.env— env vars only apply to a new volume; change it withALTER USER. - Container restarts with OOM — buffer pool larger than the container’s memory limit.
Related: WordPress with Docker Compose and PostgreSQL in Docker Compose.