Dockerize MySQL for Dev Environments: Using Init Scripts to Auto-Initialize Schema and Sample Data

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

Set Up in 5 Minutes — Let’s Get to It

Every time a new developer joins the team, I see the same scene play out: they spend the entire morning wrestling with MySQL installation, creating databases, importing schemas, running seed data — and haven’t written a single line of code yet. Even worse, every dev’s local environment is slightly different, so bugs only reproduce on one machine and not another.

Docker + init scripts solves all of that cleanly. The MySQL container automatically creates the schema and seeds data on its very first run. docker compose up and you’re done — nothing else needed. On my team, since adopting this approach, new developer onboarding time dropped from 2–3 hours down to about 10 minutes waiting for the image to pull.

Create this directory structure:

project/
├── docker-compose.yml
└── mysql/
    ├── init/
    │   ├── 01_schema.sql
    │   └── 02_seed_data.sql
    └── conf/
        └── my.cnf

File docker-compose.yml:

services:
  db:
    image: mysql:8.0
    container_name: myapp_db
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: myapp
      MYSQL_USER: devuser
      MYSQL_PASSWORD: devpass
    ports:
      - "3306:3306"
    volumes:
      - ./mysql/init:/docker-entrypoint-initdb.d
      - ./mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf
      - mysql_data:/var/lib/mysql

volumes:
  mysql_data:

File mysql/init/01_schema.sql:

CREATE TABLE IF NOT EXISTS users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(100) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS posts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    title VARCHAR(255) NOT NULL,
    body TEXT,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

File mysql/init/02_seed_data.sql:

INSERT INTO users (username, email) VALUES
    ('alice', '[email protected]'),
    ('bob', '[email protected]');

INSERT INTO posts (user_id, title, body) VALUES
    (1, 'Hello World', 'First post by Alice'),
    (2, 'Docker is great', 'Bob shares his thoughts on Docker');

Now run it:

docker compose up -d
# Wait ~10 seconds then check
docker exec -it myapp_db mysql -u devuser -pdevpass myapp -e "SELECT * FROM users;"

Schema and data are ready. Clone the repo, run one command, and you’re set — regardless of whether you’re on Windows, Mac, or Linux.

How It Works — Understanding It So There Are No Surprises

The /docker-entrypoint-initdb.d directory is where the official MySQL image “welcomes” your initialization scripts. When the container starts for the first time and the volume has no data, the entrypoint script automatically executes all files in that directory in alphabetical order. That’s all there is to it — no magic, just a for f in /docker-entrypoint-initdb.d/* loop in bash.

Four things to understand before using this:

  • Execution order: Alphabetical — that’s why files are named 01_schema.sql, 02_seed_data.sql. The schema must run before seed data, otherwise foreign keys will fail.
  • Runs only once: If the volume already has data from a previous run, init scripts are skipped entirely. This behavior is intentional — it prevents overwriting real data when restarting the container.
  • Supports .sql and .sh: You can mix SQL files and shell scripts in the same directory, and they’ll run interleaved in filename order.
  • Default database is pre-selected: The MYSQL_DATABASE variable sets the database context when scripts run — no need to add USE myapp; at the top of each SQL file.

Want to reset and run init scripts from scratch? Delete the volume:

docker compose down -v   # Remove container AND volume
docker compose up -d     # Recreate from scratch, init scripts run again

Advanced — More Real-World Scenarios

Using Shell Scripts Instead of Plain SQL

Plain SQL isn’t enough when you need to create multiple databases, import large dump files (hundreds of MB), or run conditional logic. Shell scripts can handle all of that:

#!/bin/bash
# mysql/init/03_extra_setup.sh

set -e

mysql -u root -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL
    CREATE DATABASE IF NOT EXISTS myapp_test;
    GRANT ALL PRIVILEGES ON myapp_test.* TO 'devuser'@'%';
EOSQL

echo "Extra setup complete"

The .sh file must have execute permission — this step is easy to forget:

chmod +x mysql/init/03_extra_setup.sh

Customizing MySQL Config for Dev

The mysql/conf/my.cnf file lets you relax MySQL’s strict constraints. Production needs them; dev doesn’t necessarily:

[mysqld]
# Reduce strict mode for a more relaxed dev experience
sql_mode = ONLY_FULL_GROUP_BY,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO

# Disable binary log — dev doesn't need replication, saves disk space
skip-log-bin

# Increase max_connections for multiple service connections
max_connections = 200

# Timezone
default-time-zone = '+07:00'

Checking Startup Health

The backend starting before MySQL is a classic mistake — the app crashes immediately because it can’t connect to the database. Healthchecks solve this cleanly:

services:
  db:
    image: mysql:8.0
    # ... other config ...
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 30s

  backend:
    image: myapp_backend
    depends_on:
      db:
        condition: service_healthy

condition: service_healthy ensures the backend only starts after MySQL is actually accepting connections. No more sleep 30 hacks in the entrypoint.

Practical Tips from Daily Experience

Separate Schema from Seed Data

Schema (DDL) and seed data (DML) should live in separate files — this principle sounds simple but matters more than you’d think. When the schema changes, only edit 01_schema.sql without touching the data. When you need to add test cases, only edit 02_seed_data.sql. Cleaner git diffs, easier code reviews, and easier bug isolation when something goes wrong.

Don’t Hardcode Passwords in docker-compose.yml

Use a .env file:

# .env (add to .gitignore)
MYSQL_ROOT_PASSWORD=your_root_password
MYSQL_PASSWORD=your_dev_password
# docker-compose.yml
environment:
  MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
  MYSQL_PASSWORD: ${MYSQL_PASSWORD}

Simple rule: commit .env.example with placeholder values to git. The real .env goes in .gitignore. No exceptions, not even for private repos.

Make Seed Data Idempotent

Use INSERT IGNORE or INSERT ... ON DUPLICATE KEY UPDATE instead of plain INSERT. Run it as many times as you want without errors:

INSERT IGNORE INTO users (username, email) VALUES
    ('alice', '[email protected]'),
    ('bob', '[email protected]');

This is especially important when restoring from a partial backup — instead of re-running all init scripts, you can just re-run the seed data without worrying about duplicate key errors.

A Hard-Learned Lesson About Backups

3 AM. Disk suddenly full. MySQL mid-write, database corrupted. Sitting there restoring from backup with shaking hands — that was the moment I truly understood why backups matter. Since then, I’ve added a dedicated service to docker-compose that runs mysqldump on a cron schedule every day:

  db_backup:
    image: mysql:8.0
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - ./backups:/backups
    entrypoint: |
      sh -c 'while true; do
        mysqldump -h db -u root -p$$MYSQL_ROOT_PASSWORD myapp > /backups/myapp_$$(date +%Y%m%d_%H%M).sql;
        find /backups -name "*.sql" -mtime +3 -delete;
        sleep 86400;
      done'

5 minutes to set up, keeps the last 3 days of backups, automatically cleans up old ones. Small, but it’s saved me more than once.

Viewing Init Script Logs When Debugging

Init script failing with no obvious cause? Check the container logs:

# View all logs when the container starts
docker compose logs db

# Follow in realtime and filter important lines
docker compose logs -f db 2>&1 | grep -E "(ERROR|init|schema)"

MySQL logs each file as it runs and pinpoints the exact error line — debugging usually wraps up in a few minutes.

I use this setup for every MySQL project, from personal side projects to teams of 10. Creating a new environment now takes exactly as long as pulling the image — no more manually creating tables or asking each other “did you import the schema yet?”

Share: