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.4 LTS, mysql:8.4 LTS). latest may 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 on 127.0.0.1:3306 only.
  • Variables like the database name and passwords apply only on first initialisation (empty volume).
  • MARIADB_AUTO_UPGRADE runs mariadb-upgrade automatically 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_password being 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 with ALTER USER.
  • Container restarts with OOM — buffer pool larger than the container’s memory limit.

Related: WordPress with Docker Compose and PostgreSQL in Docker Compose.

  • MySQL
  • MariaDB
  • Docker