Skip to content
datahacks.dev
MySQL

Running MySQL in Docker With a Data Volume That Persists

Run MySQL 8.4 in Docker with a named volume, environment variables, a custom config, health checks, initialization scripts and safe upgrades.

6 min read
Summarize in AI
Flat illustration of shipping containers stacked on a ship, with the Docker logo.

Docker is the quickest way to get a MySQL server that is isolated from your machine, reproducible between colleagues and easy to throw away. It is also the quickest way to lose data if you forget one detail: the data directory lives inside the container unless you say otherwise. This article starts MySQL 8.4 from the official image with a persistent volume, configures it, checks its health, loads initial data and covers the upgrade and backup habits that keep a containerized database from becoming a surprise.

Start a server with a volume

Shell
docker volume create mysql84-data
docker run -d --name mysql84 \
  -e MYSQL_ROOT_PASSWORD=change-this-long-password \
  -p 127.0.0.1:3306:3306 \
  -v mysql84-data:/var/lib/mysql \
  mysql:8.4

The named volume mysql84-data is managed by Docker and survives docker rm. The -p 127.0.0.1:3306:3306 mapping publishes the port only on the loopback interface; without the address, Docker binds to all interfaces and bypasses typical host firewall rules, exposing the database to the network. Pin the tag (mysql:8.4, or better a full version such as 8.4.3) and not latest, so a pull does not change the major version under you. A bind mount such as -v $PWD/data:/var/lib/mysql also works and makes the files visible on the host, with permission and performance differences on macOS and Windows; named volumes are usually faster and safer there.

First start behavior

The entrypoint checks the data directory. If it is empty, it initializes a new server and applies the environment variables: MYSQL_ROOT_PASSWORD, and optionally MYSQL_DATABASE, MYSQL_USER and MYSQL_PASSWORD to create a database and a user. If the directory already contains data, the variables are ignored, which surprises people who change a password in their compose file and see no effect. To change the root password later, use ALTER USER inside the server. Watch the first start with docker logs -f mysql84 until you see "ready for connections" listening on port 3306; the log shows the start twice, once for a temporary server during initialization. Connect with docker exec -it mysql84 mysql -uroot -p, or from the host with mysql -h 127.0.0.1 -P 3306 -uroot -p. Use 127.0.0.1, not localhost, from the host: the client treats localhost as a Unix socket path, which does not exist outside the container, a cause of the error covered in fixing can not connect to local MySQL server through socket.

Configuration without rebuilding

The image reads extra configuration from /etc/mysql/conf.d/*.cnf. Mount a directory:

Text
# ./conf.d/local.cnf
[mysqld]
character-set-server = utf8mb4
innodb_buffer_pool_size = 1G
slow_query_log = ON
long_query_time = 0.5
Shell
docker run -d --name mysql84 -v $PWD/conf.d:/etc/mysql/conf.d:ro -v mysql84-data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=... mysql:8.4

Options can also be passed on the command line (mysql:8.4 --character-set-server=utf8mb4 --innodb-buffer-pool-size=1G). Size the buffer pool to the memory limit you give the container, and set the limit explicitly (--memory 2g), because MySQL sizes some defaults from the host memory it sees. The settings to consider are explained in how big should innodb_buffer_pool_size be.

Initialization scripts

Files placed in /docker-entrypoint-initdb.d/ run alphabetically on the first start only, when the data directory is empty. .sql, .sql.gz and .sh files are supported. A development schema and seed data can be loaded this way:

Shell
-v $PWD/init:/docker-entrypoint-initdb.d:ro

They do not rerun on later starts, so a schema change needs a migration tool, not an edited init file, unless you recreate the volume. Keep real passwords out of these files; use environment variables or Docker secrets (MYSQL_ROOT_PASSWORD_FILE reads the value from a file, which works with secrets).

Docker Compose and health checks

A compose file makes the setup repeatable:

YAML
services:
  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root
      MYSQL_DATABASE: app
    ports: ["127.0.0.1:3306:3306"]
    volumes: ["db-data:/var/lib/mysql", "./conf.d:/etc/mysql/conf.d:ro"]
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
      interval: 10s
      timeout: 5s
      retries: 10
    secrets: [db_root]
volumes:
  db-data:
secrets:
  db_root:
    file: ./secrets/db_root.txt

The health check lets other services wait for service_healthy before connecting, which solves the race where an application starts before MySQL accepts connections. Applications in the same compose network reach the database by service name (db), not localhost. Do not publish the port at all if only containers need it.

Backups and upgrades

A volume is not a backup. Take logical dumps on a schedule:

Shell
docker exec mysql84 sh -c 'exec mysqldump --single-transaction --routines --triggers --events -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases' | gzip > backup_$(date +%F).sql.gz

with the options explained in mysqldump options for consistent backups, and test a restore into a scratch container. For upgrades, changing the image tag makes the new server open the old data directory. MySQL supports in-place upgrades between adjacent LTS or innovation versions in many cases and refuses unsupported jumps. Back up first, read the upgrade notes, and test on a copy of the volume (docker run --rm -v mysql84-data:/from -v copy:/to alpine cp -a /from/. /to/). Downgrades are not supported once the data dictionary has been upgraded.

Resource limits and networking

Set memory and CPU limits on the container, and size innodb_buffer_pool_size below the memory limit with room for per-connection buffers, since a container killed by the out-of-memory killer looks like a crash and restarts into crash recovery. Keep the database on a private Docker network shared only with the services that need it, and leave out the published port entirely in environments where nothing outside Docker connects. If you must reach it from another machine, put it behind an SSH tunnel or a VPN rather than exposing 3306 to the internet.

Performance and platform notes

On Linux, volumes perform close to native. On macOS and Windows, bind mounts across the virtualization layer are slow for database workloads; named volumes stay inside the VM and are much faster. Give Docker Desktop enough memory. Containers restart policies (--restart unless-stopped) bring the database back after a reboot. Logs go to the container's stdout by default, so docker logs shows the error log, and the slow log needs a file path and a mounted volume if you want to keep it.

When a container is not the right home for the database

For a production database that carries real data, a container adds layers to understand during an incident: volume drivers, orchestration restarts, resource limits and image updates. It is a perfectly reasonable choice with orchestration experience, local SSD volumes and a tested backup path, and a managed service or a native install is simpler for a small team. For development, testing, continuous integration and demos, containers are ideal because they start clean, match production versions and can be thrown away.

What to do next

Start the server with a named volume and loopback-only port, add a config file, load a seed script and remove and recreate the container to prove the data stays. Then write the backup command into your notes. The image is documented on Docker Hub: mysql, and the server options are in the MySQL server option reference. The hub is getting a MySQL server running and usable.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.