DevOps

Automating Database Schema Management: PostgreSQL Migrations in a CI/CD Pipeline with Flyway and GitHub Actions

Ruslan Ismailov Published 14 min read
A

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_history table.
  • 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 EXISTS for idempotency where applicable.
  • Never modify already-applied migration files — Flyway validates checksums.
  • Avoid DROP TABLE without a backup or a dedicated deployment stage.
  • For large table structural changes, use ALTER TABLE ... ADD COLUMN with 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 V5 added a column with an error — write V6 to correct it.
  • If V5 dropped a needed column — V6 recreates 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 environment in 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_history table for unexpected changes.
  • Use SSL connections: append ?sslmode=require to 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

  1. A db/migrations/ directory has been created with versioned SQL files.
  2. flyway.conf is configured without hardcoded credentials.
  3. A GitHub Actions workflow is set up: migrations run in the migrate job before the deploy job.
  4. The PR pipeline tests migrations against an ephemeral PostgreSQL service.
  5. All credentials are stored in GitHub Secrets, scoped per environment.
  6. A dedicated PostgreSQL user for Flyway has been created with minimal privileges.
  7. The JDBC URL uses sslmode=require for production.
  8. Docker Compose is configured for local reproduction of the migration pipeline.
  9. A forward-only strategy for schema changes has been established.
  10. 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 →