CI/CD Integration: GitHub Actions Pipeline for Database Deployments
The Liquibase commands covered in Articles 5 and 16 become reliable only when they run automatically on every change. A developer who remembers to run futureRollbackSQL before merging is better than one who doesn’t — but a pipeline that enforces it is better than both.
This article builds a complete GitHub Actions pipeline: PR validation gates that block merges when rollback is missing, a staging deployment workflow, and a production deployment workflow with mandatory tagging and pre-generated rollback files.
The setup-liquibase Action
Liquibase’s current GitHub Actions approach is a single setup-liquibase action that installs Liquibase and adds it to PATH, then lets you run any Liquibase command as a standard run step. This replaced 50+ individual command actions in Liquibase 4.x and is the only supported approach for Liquibase 5.x.
- uses: liquibase/setup-liquibase@v1
with:
version: "4.33.0" # Liquibase version
# edition: "pro" # Uncomment for Liquibase Pro
After this step, liquibase is available in all subsequent run steps.
Workflow 1: PR Validation Gates
This workflow runs on every PR that touches migration files. It enforces the gates from Article 16 before merge is allowed.
# .github/workflows/db-pr-validation.yml
name: Database Migration Validation
on:
pull_request:
paths:
- 'src/main/resources/db/changelog/**'
- 'liquibase.properties'
concurrency:
group: db-pr-${{ github.head_ref }}
cancel-in-progress: true
jobs:
validate-migrations:
name: Validate Migrations
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: ecommerce_ci
MYSQL_USER: lb_user
MYSQL_PASSWORD: lb_pass
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -u root --password=root"
--health-interval=10s
--health-timeout=5s
--health-retries=10
env:
LB_URL: jdbc:mysql://127.0.0.1:3306/ecommerce_ci?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
LB_USERNAME: lb_user
LB_PASSWORD: lb_pass
LB_CHANGELOG: src/main/resources/db/changelog/db.changelog-master.yaml
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- uses: liquibase/setup-liquibase@v1
with:
version: "4.33.0"
# Gate 1: Syntax and checksum validation
- name: Gate 1 — Validate changelog
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file="${LB_CHANGELOG}" \
validate
# Gate 2: Show pending changesets
- name: Gate 2 — Status check
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file="${LB_CHANGELOG}" \
status --verbose
# Gate 3: Preview forward SQL (human-readable in PR logs)
- name: Gate 3 — Preview forward SQL
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file="${LB_CHANGELOG}" \
update-sql | tee /tmp/forward.sql
echo "### Forward SQL Preview" >> $GITHUB_STEP_SUMMARY
echo '```sql' >> $GITHUB_STEP_SUMMARY
cat /tmp/forward.sql >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
# Gate 4: Validate rollback exists for ALL pending changesets
- name: Gate 4 — Validate rollback (BLOCKING)
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file="${LB_CHANGELOG}" \
future-rollback-sql | tee /tmp/rollback.sql
echo "### Rollback SQL Preview" >> $GITHUB_STEP_SUMMARY
echo '```sql' >> $GITHUB_STEP_SUMMARY
cat /tmp/rollback.sql >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
# Gate 5: Round-trip test — apply, rollback, re-apply
- name: Gate 5 — Rollback round-trip test (BLOCKING)
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file="${LB_CHANGELOG}" \
update-testing-rollback
# Gate 6: Final clean apply
- name: Gate 6 — Apply migrations cleanly
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file="${LB_CHANGELOG}" \
update
# Gate 7: Verify 0 pending after apply
- name: Gate 7 — Confirm 0 pending changesets
run: |
RESULT=$(liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file="${LB_CHANGELOG}" \
status 2>&1)
echo "$RESULT"
if echo "$RESULT" | grep -q "changesets have not been applied"; then
PENDING=$(echo "$RESULT" | grep "changesets have not been applied" | awk '{print $1}')
echo "ERROR: $PENDING changeset(s) still pending after update"
exit 1
fi
echo "✓ 0 pending changesets"
Key points:
- Gates 4 and 5 are blocking — they fail the PR if rollback is missing or broken
- Gate 3 and 4 write SQL previews to the GitHub Step Summary — visible directly in the Actions UI without downloading artefacts
- The MySQL service container uses
127.0.0.1notlocalhostto avoid socket vs TCP connection issues concurrencycancels stale runs when new commits are pushed to the same PR branch
Workflow 2: Staging Deployment
Triggers on merge to main (or your integration branch). Deploys to the staging database with automatic tagging.
# .github/workflows/db-staging-deploy.yml
name: Database — Deploy to Staging
on:
push:
branches: [main]
paths:
- 'src/main/resources/db/changelog/**'
jobs:
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
environment: staging # Uses GitHub Environments for approval/secrets
env:
APP_VERSION: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- uses: liquibase/setup-liquibase@v1
with:
version: "4.33.0"
- name: Validate changelog
env:
LB_URL: ${{ secrets.STAGING_DB_URL }}
LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
--contexts=staging,default \
validate
- name: Check pending changesets
env:
LB_URL: ${{ secrets.STAGING_DB_URL }}
LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
--contexts=staging,default \
status
- name: Generate rollback SQL (archive as artefact)
env:
LB_URL: ${{ secrets.STAGING_DB_URL }}
LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
--contexts=staging,default \
future-rollback-sql > rollback-staging-${APP_VERSION}.sql
- name: Archive rollback SQL
uses: actions/upload-artifact@v4
with:
name: rollback-staging-${{ github.sha }}
path: rollback-staging-${{ github.sha }}.sql
retention-days: 30
- name: Tag before deployment
env:
LB_URL: ${{ secrets.STAGING_DB_URL }}
LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
tag staging-${APP_VERSION}
- name: Deploy migrations
env:
LB_URL: ${{ secrets.STAGING_DB_URL }}
LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
--contexts=staging,default \
update
- name: Verify deployment
env:
LB_URL: ${{ secrets.STAGING_DB_URL }}
LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
history
echo "✓ Staging deployment complete: staging-${APP_VERSION}"
Workflow 3: Production Deployment
Triggers on a release tag push (v*.*.*). Uses GitHub Environments with required reviewers for the production deployment approval gate.
# .github/workflows/db-production-deploy.yml
name: Database — Deploy to Production
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+' # Triggers on v1.2.0, v2.0.0, etc.
jobs:
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
environment: production # Requires human approval via GitHub Environments
env:
APP_VERSION: ${{ github.ref_name }} # e.g., v1.2.0
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- uses: liquibase/setup-liquibase@v1
with:
version: "4.33.0"
# Pre-deployment: validate + preview
- name: Validate changelog
env:
LB_URL: ${{ secrets.PROD_DB_URL }}
LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
--contexts=prod,default \
validate
- name: Preview forward SQL
env:
LB_URL: ${{ secrets.PROD_DB_URL }}
LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
--contexts=prod,default \
update-sql | tee forward-prod-${APP_VERSION}.sql
- name: Generate rollback SQL
env:
LB_URL: ${{ secrets.PROD_DB_URL }}
LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
--contexts=prod,default \
future-rollback-sql | tee rollback-prod-${APP_VERSION}.sql
- name: Archive SQL files
uses: actions/upload-artifact@v4
with:
name: db-deployment-${{ env.APP_VERSION }}
path: |
forward-prod-${{ env.APP_VERSION }}.sql
rollback-prod-${{ env.APP_VERSION }}.sql
retention-days: 90 # Keep production deployment artefacts 90 days
- name: Take pre-deployment snapshot
env:
LB_URL: ${{ secrets.PROD_DB_URL }}
LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
snapshot \
--snapshot-format=json \
--output-file=snapshot-pre-${APP_VERSION}.json
- name: Archive pre-deployment snapshot
uses: actions/upload-artifact@v4
with:
name: db-snapshot-pre-${{ env.APP_VERSION }}
path: snapshot-pre-${{ env.APP_VERSION }}.json
retention-days: 90
# Deployment
- name: Tag before deployment (mandatory)
env:
LB_URL: ${{ secrets.PROD_DB_URL }}
LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
tag ${APP_VERSION}
- name: Deploy to production
env:
LB_URL: ${{ secrets.PROD_DB_URL }}
LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
--contexts=prod,default \
update
# Post-deployment
- name: Verify deployment
env:
LB_URL: ${{ secrets.PROD_DB_URL }}
LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
history
echo "✓ Production deployment complete: ${APP_VERSION}"
- name: Post deployment summary
run: |
echo "## Production DB Deployment: ${APP_VERSION}" >> $GITHUB_STEP_SUMMARY
echo "- Tag: \`${APP_VERSION}\`" >> $GITHUB_STEP_SUMMARY
echo "- Rollback command: \`liquibase rollback --tag=${APP_VERSION}\`" >> $GITHUB_STEP_SUMMARY
echo "- Rollback SQL: download artefact \`db-deployment-${APP_VERSION}\`" >> $GITHUB_STEP_SUMMARY
Workflow 4: Rollback (Manual Trigger)
A workflow that performs rollback when triggered manually from the GitHub Actions UI:
# .github/workflows/db-rollback.yml
name: Database — Rollback
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options: [staging, production]
rollback_tag:
description: 'Tag to roll back to (e.g., v1.1.0)'
required: true
type: string
jobs:
rollback:
name: Rollback ${{ inputs.environment }} to ${{ inputs.rollback_tag }}
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- uses: liquibase/setup-liquibase@v1
with:
version: "4.33.0"
- name: Preview rollback SQL
env:
LB_URL: ${{ inputs.environment == 'production' && secrets.PROD_DB_URL || secrets.STAGING_DB_URL }}
LB_USERNAME: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_USER || secrets.STAGING_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_PASSWORD || secrets.STAGING_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
rollback-sql --tag=${{ inputs.rollback_tag }}
- name: Execute rollback
env:
LB_URL: ${{ inputs.environment == 'production' && secrets.PROD_DB_URL || secrets.STAGING_DB_URL }}
LB_USERNAME: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_USER || secrets.STAGING_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_PASSWORD || secrets.STAGING_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
rollback --tag=${{ inputs.rollback_tag }}
- name: Verify rollback
env:
LB_URL: ${{ inputs.environment == 'production' && secrets.PROD_DB_URL || secrets.STAGING_DB_URL }}
LB_USERNAME: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_USER || secrets.STAGING_DB_MIGRATION_USER }}
LB_PASSWORD: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_PASSWORD || secrets.STAGING_DB_MIGRATION_PASSWORD }}
run: |
liquibase \
--url="${LB_URL}" \
--username="${LB_USERNAME}" \
--password="${LB_PASSWORD}" \
--changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
status
echo "✓ Rollback to ${{ inputs.rollback_tag }} complete"
GitHub Secrets Configuration
Repository Settings → Secrets and variables → Actions
Staging:
STAGING_DB_URL jdbc:mysql://staging-host:3306/ecommerce?...
STAGING_DB_MIGRATION_USER lb_migration_user
STAGING_DB_MIGRATION_PASSWORD ***
Production:
PROD_DB_URL jdbc:mysql://prod-host:3306/ecommerce?...
PROD_DB_MIGRATION_USER lb_migration_user
PROD_DB_MIGRATION_PASSWORD ***
Use GitHub Environment secrets (not repository secrets) for staging and production — they are only accessible when the workflow targets that environment, and production environments can require human approval before the job runs.
Branch Protection Rules
Configure branch protection on main to require the PR validation workflow:
Repository Settings → Branches → Branch protection rules → main
✓ Require status checks to pass before merging
Required checks:
- validate-migrations / validate-migrations
✓ Require branches to be up to date before merging
✓ Dismiss stale pull request approvals when new commits are pushed
With this configuration, a PR that adds a migration without rollback cannot be merged — the CI gate blocks it.
Common Mistakes
Storing DB credentials in workflow files: Connection strings with embedded passwords are visible to anyone with repository access. Always use secrets (${{ secrets.X }}). For production, use GitHub Environment secrets with required reviewers.
Not using concurrency on PR workflows: Without concurrency, pushing multiple commits to a PR branch can queue up many workflow runs that test against the same CI database simultaneously — causing race conditions and false failures. The cancel-in-progress: true setting cancels the stale run when a new commit arrives.
Connecting to MySQL using localhost in service containers: GitHub Actions service containers run in Docker. The localhost hostname resolves to the runner, not the container. Use 127.0.0.1 (which the port-forwarding maps correctly) or the service container’s hostname.
Best Practices
- Four separate workflows — PR validation, staging deploy, production deploy, rollback — each with a single clear responsibility
- GitHub Environments for staging and production — enables required reviewer approval before production jobs run
- Archive rollback SQL as a workflow artefact — the pre-generated rollback file is available in the Actions UI for download during an incident
update-sqlandfuture-rollback-sqloutput to GitHub Step Summary — SQL previews visible in the PR without downloading artefacts- Branch protection requiring PR validation checks — enforces that no migration merges without passing all gates
What You’ve Learned
liquibase/setup-liquibase@v1is the current GitHub Actions approach — installs Liquibase and adds to PATH- PR validation workflow enforces seven gates before merge: validate, status, forward SQL, rollback SQL, round-trip test, clean apply, 0 pending
- Staging workflow tags, deploys, and archives rollback SQL automatically on every merge to
main - Production workflow requires human approval via GitHub Environments before running
- Rollback workflow is a
workflow_dispatchtrigger — one click from the Actions UI during an incident - GitHub Environments scope secrets to specific environments and enforce approval gates
Next: Article 20 — Testing Migrations: H2 vs Testcontainers vs Real MySQL — how to write integration tests that actually validate your migrations.