Docker Compose: Pro Tips for Automating Database Migrations and Schema Updates

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

The Problem: When the App Outruns the Database

The first time I deployed a real-world project with Docker Compose, I learned a hard lesson from a very basic mistake. Right after typing docker-compose up -d and seeing Docker report all containers as a healthy green “Started,” I eagerly accessed the web interface. The result? A slap in the face in the form of an Internal Server Error (500).

Checking the logs, I found a familiar message: Relation "users" does not exist. It turned out that even though the Database container was running, the table structure (Schema) was still empty or hadn’t been updated yet. At that time, the only manual solution was to docker exec into the container to run the migration. Doing this once is fine, but doing it 10 times in Production or a CI/CD system is a total disaster.

Why depends_on Is Just an Empty Promise

The most common mistake for beginners (and my past self) is placing absolute trust in depends_on: - db. In reality, Docker Compose only ensures that the DB container starts before the App container.

However, “running” doesn’t mean the Database engine is ready to accept connections. A PostgreSQL instance typically takes 5 to 10 seconds to initialize memory and check system files. Meanwhile, a Node.js or Go application takes less than a second to start. Consequently, the App tries to connect to a DB that is still “booting up,” leading to an immediate crash.

3 Practical Solutions to Automate Migrations

To solve this, we need a mechanism to check the actual state of the DB before allowing the main application to run.

1. Using an Entrypoint Script (Lightweight & Popular)

Instead of starting directly with npm start, wrap it in a shell script. This script acts as a gatekeeper: Wait for the DB port to open -> Run Migrations -> Start the App.

Here is the entrypoint.sh file I often use for Node.js projects:

#!/bin/sh

# Wait for Database to open port 5432
echo "Waiting for postgres..."
while ! nc -z db 5432; do
  sleep 0.1
done

echo "PostgreSQL is up - executing migrations"

# Execute schema updates
npm run db:migrate

# Hand over control to the main application
exec "$@"

Don’t forget to grant execution permissions in your Dockerfile:

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
CMD ["npm", "start"]

2. Leveraging the wait-for-it Tool

If you’re hesitant to write manual scripts, wait-for-it.sh is a perfect alternative. This tool is extremely stable and supports a timeout feature, preventing containers from hanging indefinitely if the DB encounters an issue.

The configuration in docker-compose.yml would look like this:

services:
  app:
    build: .
    command: ["./wait-for-it.sh", "db:5432", "--timeout=30", "--", "npm", "run", "migrate-and-start"]
    depends_on:
      - db

3. Separating Migration into a Dedicated Service (The Pro Approach)

For large systems requiring horizontal scaling (running multiple App containers simultaneously), stuffing migrations into the startup process wastes resources. The best way is to separate the migration into a “One-off container” that runs exactly once.

services:
  db:
    image: postgres:15
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
      interval: 5s

  migration:
    build: .
    command: npm run db:migrate
    depends_on:
      db:
        condition: service_healthy

  app:
    build: .
    command: npm start
    depends_on:
      migration:
        condition: service_completed_successfully

The workflow is very robust: Docker waits for db to pass its health check, then triggers the migration container. Only when the migration exits with Exit Code 0 (success) does the app start serving users.

Real-World Tips to Avoid Data Loss

After handling many incidents, I’ve distilled 3 golden rules you should remember:

  • Idempotency: Migration scripts must be able to run multiple times without causing errors. Always use CREATE TABLE IF NOT EXISTS or use libraries like Flyway, Alembic, or Sequelize.
  • Always Have a Backup Plan: Before running migrations in Production, ensure the system has automatically snapshotted the DB. Docker cannot save you if a script accidentally executes a DROP COLUMN command.
  • Logs are Your Lifeline: Ensure the migration container prints clear logs. When docker-compose up hangs, you need to know immediately whether it’s because the DB isn’t ready or there’s a SQL syntax error.

Conclusion: Which Method Should You Choose?

If you are developing a personal project or in the development phase, Method 1 (Entrypoint) is the quickest and cleanest choice. It keeps the compose file simple and easy to understand.

Conversely, if your goal is a Production environment or deployment on Kubernetes, Method 3 (Service Separation) is the mandatory path. Separating responsibilities makes your system much more stable and easier to manage. Happy deploying!

Share: