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.

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
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.4The 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:
# ./conf.d/local.cnf
[mysqld]
character-set-server = utf8mb4
innodb_buffer_pool_size = 1G
slow_query_log = ON
long_query_time = 0.5docker 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.4Options 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:
-v $PWD/init:/docker-entrypoint-initdb.d:roThey 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:
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.txtThe 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:
docker exec mysql84 sh -c 'exec mysqldump --single-transaction --routines --triggers --events -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases' | gzip > backup_$(date +%F).sql.gzwith 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.


