Running Liquibase Migrations in Docker and CI

Running Liquibase Migrations in Docker and CI

Reading time1 min
#DevOps#Cloud#Database#Liquibase#Docker#CI/CD#Kubernetes#SchemaMigration

Running Liquibase Migrations in Docker and CI

Liquibase applies schema changes from changelog files and records each applied changeset in the DATABASECHANGELOG table. Running it from a Docker image gives every environment the same Liquibase version and drivers. This post covers the changelog layout, the image, a CI pipeline, and the details that cause trouble in production.

Changelog layout

A master changelog includes the change files in order:

# db/changelog/db.changelog-master.yaml
databaseChangeLog:
  - include:
      file: changes/001-create-users.sql
      relativeToChangelogFile: true
  - include:
      file: changes/002-users-created-at-idx.sql
      relativeToChangelogFile: true

Formatted SQL changelogs are easy to review and need no translation for DBAs:

--liquibase formatted sql

--changeset my-team:001-create-users
CREATE TABLE users (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email text NOT NULL UNIQUE,
  created_at timestamptz NOT NULL DEFAULT now()
);
--rollback DROP TABLE users;

Statements that cannot run in a transaction need runInTransaction:false:

--liquibase formatted sql

--changeset my-team:002-users-created-at-idx runInTransaction:false
CREATE INDEX CONCURRENTLY users_created_at_idx ON users (created_at);
--rollback DROP INDEX IF EXISTS users_created_at_idx;

Rules that prevent most problems:

  • Never edit an applied changeset. Liquibase stores a checksum and fails validation if the content changes. Fix mistakes with a new changeset.
  • Keep paths stable. A changeset is identified by id, author and changelog file path. Moving a file or running with a different path makes Liquibase see new changesets. Use the same relative path everywhere, or set logicalFilePath.
  • One DDL statement per changeset on MySQL. MySQL commits DDL implicitly, so a failed multi-statement changeset can stay half applied. PostgreSQL rolls back the failed changeset's transaction.
  • Write rollbacks. Liquibase can generate rollback for change types like createTable or addColumn, but not for raw SQL. Formatted SQL needs explicit --rollback lines.

The Docker image

Since Liquibase 5.0, the liquibase/liquibase image no longer bundles database drivers or extensions. Build a small image with the driver you need, and pin the version:

# db/Dockerfile
FROM liquibase/liquibase:5.0
RUN lpm add postgresql --global

Run it with the changelog directory mounted at /liquibase/changelog. The image switches to that directory, so relative paths match local runs:

docker build -t my-liquibase db/
docker run --rm \
  -v "$PWD/db/changelog:/liquibase/changelog" \
  -e LIQUIBASE_COMMAND_URL="jdbc:postgresql://db.example.com:5432/my_app" \
  -e LIQUIBASE_COMMAND_USERNAME=my_app_migrator \
  -e LIQUIBASE_COMMAND_PASSWORD \
  my-liquibase update --changelog-file=db.changelog-master.yaml

-e LIQUIBASE_COMMAND_PASSWORD without a value passes the variable from the host environment, so the password never appears in the command line or shell history. Use a dedicated migration user with DDL rights and keep the application user without them.

CI pipeline

Three stages:

  1. Pull request: apply the changelog to a throwaway database, then test the rollbacks.
  2. Review: generate the SQL that would run against staging or production with update-sql and attach it to the pull request.
  3. Release: tag the database, run update, then deploy the application.

The pull request check with GitHub Actions:

name: db-migrations
on:
  pull_request:
    paths: ["db/**"]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_DB: my_app
          POSTGRES_USER: my_app
          POSTGRES_PASSWORD: test
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U my_app"
          --health-interval 5s --health-timeout 5s --health-retries 10
    steps:
      - uses: actions/checkout@v5
      - run: docker build -t my-liquibase db/
      - name: Apply, roll back, apply again
        run: |
          docker run --rm --network host \
            -v "$PWD/db/changelog:/liquibase/changelog" \
            -e LIQUIBASE_COMMAND_URL=jdbc:postgresql://localhost:5432/my_app \
            -e LIQUIBASE_COMMAND_USERNAME=my_app \
            -e LIQUIBASE_COMMAND_PASSWORD=test \
            my-liquibase update-testing-rollback --changelog-file=db.changelog-master.yaml

update-testing-rollback applies pending changesets, rolls them back and applies them again, so a broken --rollback line fails the build instead of failing during an incident.

In the release job, run migrations before the new application version starts, and only ship changes the running version can live with: add columns and tables first, drop them in a later release.

Rollback

Tag before every production update. These commands work the same through the Docker image:

liquibase tag --tag=release-1.4.0
liquibase update --changelog-file=db.changelog-master.yaml

# preview, then roll back to the tag if needed
liquibase rollback-sql --tag=release-1.4.0 --changelog-file=db.changelog-master.yaml
liquibase rollback --tag=release-1.4.0 --changelog-file=db.changelog-master.yaml

rollback-count --count=1 undoes the last changeset. A rollback cannot bring back data: if a changeset dropped a column, its rollback recreates an empty one. For destructive changes the real recovery path is a backup, so take one first.

Contexts

Changesets can carry a context, for example test data marked context: test. If you run update without --context-filter, Liquibase applies all changesets, including those. Pass the filter explicitly in every environment, such as --context-filter=prod.

Locks and drift

Liquibase takes a lock in the DATABASECHANGELOGLOCK table while it runs. If a job is killed, the lock stays, and the next run waits for it and then fails. Check that no migration is running, then clear it with liquibase release-locks.

liquibase status --verbose lists changesets not yet applied. To catch manual changes, compare environments:

liquibase diff \
  --url=jdbc:postgresql://prod-db.example.com:5432/my_app \
  --reference-url=jdbc:postgresql://staging-db.example.com:5432/my_app \
  --username=readonly --password="$PROD_PASSWORD" \
  --reference-username=readonly --reference-password="$STAGING_PASSWORD"

Kubernetes

Run migrations once per release as a pipeline step, a Kubernetes Job, a Helm pre-upgrade hook or an Argo CD PreSync hook. Avoid init containers: every replica tries to migrate, and pods wait on the lock during long migrations.

Checklist

  • Master changelog with explicit includes and stable file paths.
  • Applied changesets are never edited.
  • Every changeset has a rollback, tested with update-testing-rollback in CI.
  • A pinned Liquibase image with drivers added through lpm.
  • Credentials from the CI secret store, a separate migration user.
  • update-sql output reviewed before production.
  • Tag before update, migrate before deploy, destructive changes in a later release.
  • --context-filter set explicitly in every environment.