I resisted Docker for database work longer than I should have. My reasoning was that databases are stateful, containers are supposed to be disposable, and mixing the two felt risky. What changed my mind was spinning up a throwaway MySQL instance for a client demo in under thirty seconds, tearing it down, and doing it again with a different dataset five minutes later — no local install, no leftover config files, no version conflicts with other projects on my machine. That convenience is real, and once you understand how to handle persistence properly, it’s safe for a lot more than demos.
This guide covers running MySQL in Docker the right way, from a single throwaway container to a properly configured, production-lean setup.
Why Containerize MySQL
- Isolation — different projects can run different MySQL versions side by side without conflicts.
- Reproducibility — the exact same environment on your laptop, your teammate’s laptop, and CI.
- Fast teardown/rebuild — testing migrations or schema changes against a clean database takes seconds.
- Portability — the same container definition runs on your laptop, a staging server, or a Kubernetes cluster.
Architecture: Container vs. Persistent Data
graph TD
A[Docker Host] --> B[MySQL Container - mysqld process]
B --> C[Named Volume - /var/lib/mysql data]
A --> D[Docker Network - app_network]
E[Application Container] --> D
B --> D
F[Docker Host Port 3306] -.exposed to.-> B
The key concept: the MySQL process lives inside the container and can be destroyed and recreated freely, but the data should live in a Docker volume, which persists independently of the container’s lifecycle.
Running a Basic MySQL Container
docker run --name mysql-dev \
-e MYSQL_ROOT_PASSWORD=StrongRootPass123! \
-e MYSQL_DATABASE=myapp \
-e MYSQL_USER=appuser \
-e MYSQL_PASSWORD=AppUserPass123! \
-p 3306:3306 \
-d mysql:8.0
Check it’s running:
docker ps
docker logs mysql-dev
You should see something like:
[Entrypoint] MySQL init process done. Ready for start up.
[Server] /usr/sbin/mysqld: ready for connections.
Connect to it:
docker exec -it mysql-dev mysql -u appuser -p myapp
Persisting Data with Volumes
Without a volume, all your data disappears the moment you docker rm the container. Fix that with a named volume:
docker volume create mysql_data
docker run --name mysql-dev \
-e MYSQL_ROOT_PASSWORD=StrongRootPass123! \
-e MYSQL_DATABASE=myapp \
-v mysql_data:/var/lib/mysql \
-p 3306:3306 \
-d mysql:8.0
Now you can safely docker stop mysql-dev && docker rm mysql-dev and recreate the container pointing at the same volume — your tables and rows are exactly as you left them.
Using Docker Compose for a Full Stack
For anything beyond a quick test, I use Compose so the database and application are defined together.
# docker-compose.yml
version: "3.9"
services:
db:
image: mysql:8.0
container_name: mysql-app
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: StrongRootPass123!
MYSQL_DATABASE: myapp
MYSQL_USER: appuser
MYSQL_PASSWORD: AppUserPass123!
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./init:/docker-entrypoint-initdb.d
networks:
- app_network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
app:
build: .
depends_on:
db:
condition: service_healthy
environment:
DB_HOST: db
DB_USER: appuser
DB_PASSWORD: AppUserPass123!
networks:
- app_network
volumes:
mysql_data:
networks:
app_network:
Bring it up:
docker compose up -d
The ./init folder mounted to /docker-entrypoint-initdb.d is a genuinely useful feature — any .sql or .sh file in there runs automatically the first time the container initializes an empty data directory, which is perfect for seeding schema and test data.
-- init/01-schema.sql
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Custom Configuration
To tune MySQL inside the container (InnoDB buffer pool, character set, etc.), mount a config file instead of trying to edit the running container:
# my-custom.cnf
[mysqld]
innodb_buffer_pool_size = 512M
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
max_connections = 200
services:
db:
image: mysql:8.0
volumes:
- ./my-custom.cnf:/etc/mysql/conf.d/custom.cnf
- mysql_data:/var/lib/mysql
Backing Up and Restoring a Containerized Database
# Backup
docker exec mysql-app mysqldump -u root -p"StrongRootPass123!" myapp > backup.sql
# Restore into a fresh container
docker exec -i mysql-app mysql -u root -p"StrongRootPass123!" myapp < backup.sql
For a full volume-level backup instead of a logical dump:
docker run --rm -v mysql_data:/volume -v $(pwd):/backup alpine \
tar czf /backup/mysql_data_backup.tar.gz -C /volume .
Connecting an Application from Another Container
Inside a Compose network, containers reach each other by service name, not localhost:
# Python example
import mysql.connector
conn = mysql.connector.connect(
host="db", # service name from docker-compose.yml
user="appuser",
password="AppUserPass123!",
database="myapp"
)
This is one of the most common beginner mistakes — trying to connect to localhost or 127.0.0.1 from inside another container, which refers to that container itself, not the database container.
Real-World Scenario: Disposable Test Databases in CI
In CI pipelines, I spin up a fresh MySQL container per test run, seed it, run the test suite, then discard it entirely:
# GitHub Actions example
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test_db
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
Every test run starts from a known-clean state — no test pollution from a previous run’s leftover data.
Performance Considerations for Containerized MySQL
- Set
innodb_buffer_pool_sizeexplicitly — containers often under-report available memory to MySQL’s auto-sizing logic, especially with cgroup limits. - Use named volumes rather than bind mounts for the data directory when possible; bind mounts (especially on macOS/Windows Docker Desktop) can have meaningfully worse I/O performance than native volumes.
- Set explicit container resource limits (
--memory,--cpus) so MySQL doesn’t compete unpredictably with other containers on the host. - For production, consider whether you actually need a container orchestrator (Kubernetes StatefulSet with persistent volume claims) rather than a single Docker host.
Security Considerations
- Never bake real passwords into a Dockerfile or commit them to version control — use environment files (
.env) excluded via.gitignore, or a secrets manager. - Don’t expose port 3306 publicly unless absolutely necessary; keep the database on an internal Docker network and only expose the application’s port.
- Run containers with a non-root user where the image supports it, and keep the MySQL image tag pinned to a specific version rather than
latestto avoid unexpected upgrades. - Regularly rebuild images to pick up base image security patches.
Troubleshooting Common Issues
Container exits immediately after starting. Check docker logs <container> — usually a misconfigured environment variable or a corrupted volume from a previous failed initialization.
“Access denied for user” after changing MYSQL_ROOT_PASSWORD. The environment variables only take effect on first initialization of an empty data directory. If the volume already has data, changing the env var does nothing — you need to change the password inside MySQL directly, or wipe the volume for a fresh start.
Data disappears after docker-compose down. By default, docker-compose down doesn’t remove volumes, but docker-compose down -v does — be careful with that flag in scripts.
Slow performance on Windows/macOS. This is usually related to the Docker Desktop virtualization layer for bind-mounted volumes; switching to named volumes typically resolves it.
Frequently Asked Questions
Is Docker suitable for production MySQL? Yes, many teams run production MySQL in containers, but it requires proper persistent volume management, resource limits, backups, and typically orchestration (Kubernetes, ECS, etc.) rather than a single ad-hoc docker run.
Which MySQL image should I use? The official mysql image on Docker Hub is actively maintained and the standard choice; specify a version tag like mysql:8.0 rather than latest.
How do I upgrade MySQL versions in a container? Stop the container, back up the volume, start a new container with the new image version pointed at the same volume, and let MySQL’s upgrade process run — always test this in a non-production environment first.
Can multiple containers share one MySQL data volume? No — only one mysqld process should write to a given data directory at a time. Use one MySQL container per data volume, and connect multiple application containers to that one database container over the network instead.
Interview Questions
- Why should MySQL data be stored in a Docker volume rather than the container’s writable layer?
- What’s the difference between a bind mount and a named volume, and when would you choose each for a database?
- How does
docker-entrypoint-initdb.dwork, and when does it actually execute? - Why can’t a container reach a MySQL container using
localhost? - What are the risks of running production databases in containers, and how do orchestrators like Kubernetes address persistence?
- How would you safely upgrade the MySQL version of a running containerized database?
Summary and Key Takeaways
- Docker makes MySQL fast to spin up, tear down, and reproduce across environments.
- Persistence depends entirely on volumes — without one, data dies with the container.
- Docker Compose is the practical way to define MySQL alongside your application and network configuration.
docker-entrypoint-initdb.dis a powerful tool for automatic schema/seed initialization on first run only.- Containers within the same Compose network talk to each other by service name, not
localhost. - Production use is entirely viable but requires deliberate attention to resource limits, backup strategy, and persistent storage architecture.
Docker didn’t replace my understanding of MySQL internals — if anything, it forced me to be more explicit about configuration, memory limits, and persistence than I ever was running MySQL directly on a host. That explicitness is a good habit either way.
