Automating Database Schema Management: PostgreSQL Migrations in a CI/CD Pipeline with Flyway and GitHub Actions
Introduction: Why Manual Migrations Are Dangerous in 2026
Just a few years ago, "running a script manually before deployment" was standard practice for many teams. In 2026, that approach is a recipe for incidents. Teams move faster, deployments happen multiple times a day, and infrastructure is provisioned automatically. Manually applying SQL scripts to a database in such an environment means:
- Human error risk: wrong script applied, to the wrong database, in the wrong order.
- No audit trail: it's unclear who changed the schema and when.
- Environment drift: staging and production diverge silently.
- No automatic rollback when a deployment fails.
- Issues with horizontal scaling and multiple teams working against the same database.
The solution is to integrate PostgreSQL schema management directly into your CI/CD pipeline. Flyway combined with GitHub Actions makes migrations reproducible, versioned, and safe. This article covers the full cycle — from installation to a production-ready configuration.
Flyway vs Liquibase vs Framework-Embedded Migrations
Before diving into Flyway, it's worth understanding why it stands out from the alternatives.
Flyway
- Simple model: SQL files named by convention, migration history tracked in the
flyway_schema_historytable. - Out-of-the-box support for PostgreSQL, MySQL, Oracle, and other databases.
- Minimal configuration to get started, with rich enterprise capabilities.
- Integrates with Maven, Gradle, Docker, CLI, and GitHub Actions.
- Paid Flyway Teams/Enterprise adds dry-run, undo migrations, and extended rollback options.
Liquibase
- More flexible format: XML, YAML, JSON, or SQL for describing changes.
- Supports diff generation between schemas.
- Steeper learning curve and more configuration overhead.
- A good fit for teams that need a database-agnostic abstraction layer.
Framework-Embedded Migrations
- Django migrations, Laravel migrations, Alembic (SQLAlchemy) — convenient within their own ecosystems.
- Tied to a specific language and framework, harder to use in polyglot systems.
- No direct CI/CD integration without additional wrappers.
For teams running PostgreSQL in production with a GitHub Actions CI/CD pipeline, Flyway is the optimal choice: minimal dependencies, predictable behavior, and native Docker support.
Installing and Configuring Flyway for PostgreSQL
Flyway can be run in several ways. The most versatile option for CI/CD is the official Docker image flyway/flyway.
Project Structure
Recommended directory layout in your repository:
project-root/
├── db/
│ ├── migrations/
│ │ ├── V1__init_schema.sql
│ │ ├── V2__add_users_table.sql
│ │ └── V3__add_index_on_email.sql
│ └── flyway.conf
├── docker-compose.yml
└── .github/
└── workflows/
└── deploy.yml
The flyway.conf Configuration File
A basic config for connecting to PostgreSQL:
flyway.url=jdbc:postgresql://localhost:5432/mydb
flyway.user=${DB_USER}
flyway.password=${DB_PASSWORD}
flyway.schemas=public
flyway.locations=filesystem:./db/migrations
flyway.baselineOnMigrate=false
flyway.validateOnMigrate=true
flyway.outOfOrder=false
Flyway substitutes ${DB_USER} and ${DB_PASSWORD} from environment variables — this is critical for CI security.
Writing Versioned SQL Migrations: Naming Conventions and Best Practices
File Naming Convention
Flyway strictly follows a file naming pattern:
V{version}__{description}.sql— versioned migrations (applied once).R__{description}.sql— repeatable migrations (views, stored procedures, triggers).U{version}__{description}.sql— undo migrations (paid version only).
Examples of valid file names:
V1__init_schema.sql
V2__add_users_table.sql
V2_1__add_users_email_index.sql
V3__add_orders_table.sql
R__refresh_analytics_view.sql
Real-World Migration Examples
Schema initialization (V1__init_schema.sql):
-- V1__init_schema.sql
CREATE TABLE IF NOT EXISTS schema_meta (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Creating the users table (V2__add_users_table.sql):
-- V2__add_users_table.sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
username VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_users_email ON users(email);
Adding a foreign key (V3__add_orders_table.sql):
-- V3__add_orders_table.sql
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
total NUMERIC(12, 2) NOT NULL DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
Migration Writing Best Practices
- Each migration should be atomic: one logical change per file.
- Use
IF NOT EXISTS/IF EXISTSfor idempotency where applicable. - Never modify already-applied migration files — Flyway validates checksums.
- Avoid
DROP TABLEwithout a backup or a dedicated deployment stage. - For large table structural changes, use
ALTER TABLE ... ADD COLUMNwith default values instead of recreating the table. - Document the purpose of each migration in a comment at the top of the file.
Integrating Flyway with GitHub Actions: Running Migrations Before Deployment
The key principle: migrations must run before the new application code is deployed. This ensures the new code always sees an up-to-date schema.
Complete GitHub Actions Workflow
# .github/workflows/deploy.yml
name: Deploy with DB Migrations
on:
push:
branches:
- main
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
migrate:
name: Run PostgreSQL Migrations
runs-on: ubuntu-22.04
environment: production
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run Flyway migrations
uses: docker://flyway/flyway:10-alpine
with:
args: migrate
env:
FLYWAY_URL: jdbc:postgresql://${{ secrets.DB_HOST }}:5432/${{ secrets.DB_NAME }}
FLYWAY_USER: ${{ secrets.DB_USER }}
FLYWAY_PASSWORD: ${{ secrets.DB_PASSWORD }}
FLYWAY_LOCATIONS: filesystem:/github/workspace/db/migrations
FLYWAY_VALIDATE_ON_MIGRATE: "true"
FLYWAY_OUT_OF_ORDER: "false"
FLYWAY_BASELINE_ON_MIGRATE: "false"
- name: Verify migration status
uses: docker://flyway/flyway:10-alpine
with:
args: info
env:
FLYWAY_URL: jdbc:postgresql://${{ secrets.DB_HOST }}:5432/${{ secrets.DB_NAME }}
FLYWAY_USER: ${{ secrets.DB_USER }}
FLYWAY_PASSWORD: ${{ secrets.DB_PASSWORD }}
FLYWAY_LOCATIONS: filesystem:/github/workspace/db/migrations
deploy:
name: Deploy Application
runs-on: ubuntu-22.04
needs: migrate
environment: production
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Deploy to production
run: |
echo "Deploying application after successful migrations..."
# Your deploy script: kubectl apply, docker stack deploy, etc.
Running Migration Tests on Pull Requests
For pull request pipelines, it's useful to run migrations against a temporary test database:
# .github/workflows/pr-migration-test.yml
name: Test Migrations on PR
on:
pull_request:
paths:
- 'db/migrations/**'
jobs:
test-migrations:
runs-on: ubuntu-22.04
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpassword
ports:
- 5432:5432
options: >
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run Flyway migrate on test DB
uses: docker://flyway/flyway:10-alpine
with:
args: migrate
env:
FLYWAY_URL: jdbc:postgresql://localhost:5432/testdb
FLYWAY_USER: testuser
FLYWAY_PASSWORD: testpassword
FLYWAY_LOCATIONS: filesystem:/github/workspace/db/migrations
- name: Validate migration info
uses: docker://flyway/flyway:10-alpine
with:
args: validate
env:
FLYWAY_URL: jdbc:postgresql://localhost:5432/testdb
FLYWAY_USER: testuser
FLYWAY_PASSWORD: testpassword
FLYWAY_LOCATIONS: filesystem:/github/workspace/db/migrations
Rollback Strategies and Baseline for Existing Databases
The Rollback Problem in Flyway
Flyway Community Edition does not support automatic undo migrations. This is a deliberate design decision: rolling back a database schema is a dangerous operation, especially when data has already been written under the new schema.
The Forward-Only Strategy
The recommended approach for most production systems is to never roll back the schema, but instead fix mistakes with a new forward migration.
- If
V5added a column with an error — writeV6to correct it. - If
V5dropped a needed column —V6recreates it.
Blue-Green Deployment as a Safety Strategy
With blue-green deployments, migrations run before traffic is switched over. If a migration fails, the switch never happens and the old version continues serving traffic against the untouched schema.
Baseline for an Existing Database
If you're introducing Flyway to an already-existing production database, use the baseline command:
# Set baseline at version 1 for an existing database
flyway -url=jdbc:postgresql://localhost:5432/mydb \
-user=myuser \
-password=mypassword \
-baselineVersion=1 \
-baselineDescription="Initial baseline" \
baseline
After this, Flyway will mark the current state as version 1 and only apply new migrations starting from V2. In your config, set flyway.baselineOnMigrate=false — the baseline command should only be run once, manually.
Working with Migrations in a Docker Environment
Docker Compose for Local Development
The following docker-compose.yml starts PostgreSQL and automatically runs Flyway on startup:
version: '3.9'
services:
postgres:
image: postgres:16-alpine
container_name: app_postgres
restart: unless-stopped
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: appsecret
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
flyway:
image: flyway/flyway:10-alpine
container_name: app_flyway
command: migrate
depends_on:
postgres:
condition: service_healthy
environment:
FLYWAY_URL: jdbc:postgresql://postgres:5432/appdb
FLYWAY_USER: appuser
FLYWAY_PASSWORD: appsecret
FLYWAY_LOCATIONS: filesystem:/flyway/sql
FLYWAY_VALIDATE_ON_MIGRATE: "true"
volumes:
- ./db/migrations:/flyway/sql
restart: on-failure
volumes:
postgres_data:
Run with: docker compose up flyway — this will apply all pending migrations to your local database.
Flyway in a Dockerfile for Production
An alternative approach is to include Flyway in a Kubernetes init container or a standalone Job:
FROM flyway/flyway:10-alpine
COPY db/migrations /flyway/sql
# CMD is provided at container runtime via args
Security: Managing Credentials and Secrets in the Pipeline
Securing database credentials in CI/CD is a critical concern. A leaked production PostgreSQL password can have catastrophic consequences.
GitHub Actions Secrets
Never store credentials in your repository or in unencrypted environment variables. Use GitHub Secrets instead:
- Go to Settings → Secrets and variables → Actions in your repository.
- Create secrets:
DB_HOST,DB_NAME,DB_USER,DB_PASSWORD. - Use
environmentin your workflow to scope secrets per environment (staging, production).
Using HashiCorp Vault or AWS Secrets Manager
For enterprise environments, dynamically fetching credentials from Vault is recommended:
# Step to retrieve credentials from Vault
- name: Import secrets from Vault
uses: hashicorp/vault-action@v3
with:
url: ${{ secrets.VAULT_ADDR }}
token: ${{ secrets.VAULT_TOKEN }}
secrets: |
secret/data/production/postgres username | DB_USER ;
secret/data/production/postgres password | DB_PASSWORD
- name: Run Flyway migrations
uses: docker://flyway/flyway:10-alpine
with:
args: migrate
env:
FLYWAY_URL: jdbc:postgresql://${{ secrets.DB_HOST }}:5432/${{ secrets.DB_NAME }}
FLYWAY_USER: ${{ env.DB_USER }}
FLYWAY_PASSWORD: ${{ env.DB_PASSWORD }}
FLYWAY_LOCATIONS: filesystem:/github/workspace/db/migrations
Principle of Least Privilege
Create a dedicated PostgreSQL user for Flyway with only the minimum required permissions:
-- Create a dedicated migration user
CREATE USER flyway_runner WITH PASSWORD 'strong_random_password';
-- Grant only what's needed for the target database and schema
GRANT CONNECT ON DATABASE appdb TO flyway_runner;
GRANT USAGE, CREATE ON SCHEMA public TO flyway_runner;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO flyway_runner;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO flyway_runner;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL ON TABLES TO flyway_runner;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL ON SEQUENCES TO flyway_runner;
Password Rotation and Auditing
- Rotate service account passwords regularly (at least quarterly).
- Enable connection logging in PostgreSQL via
log_connections = on. - Monitor the
flyway_schema_historytable for unexpected changes. - Use SSL connections: append
?sslmode=requireto the JDBC URL.
Conclusion and Checklist
Automating PostgreSQL migrations with Flyway in a GitHub Actions CI/CD pipeline is not added complexity — it's the foundation of reliable schema management. A properly configured pipeline ensures that the schema is always in sync with the code, changes are versioned and auditable, and the human factor is removed from a critical process.
Your database is not a config file. It deserves the same version control and automation discipline as your application source code.
Implementation Checklist
- A
db/migrations/directory has been created with versioned SQL files. flyway.confis configured without hardcoded credentials.- A GitHub Actions workflow is set up: migrations run in the
migratejob before thedeployjob. - The PR pipeline tests migrations against an ephemeral PostgreSQL service.
- All credentials are stored in GitHub Secrets, scoped per environment.
- A dedicated PostgreSQL user for Flyway has been created with minimal privileges.
- The JDBC URL uses
sslmode=requirefor production. - Docker Compose is configured for local reproduction of the migration pipeline.
- A forward-only strategy for schema changes has been established.
- The team is familiar with the naming convention for migration files.
This approach scales from a small startup all the way to high-load production systems, delivering predictability and safety at every stage of the application lifecycle.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →