Tutorial: Configure GitHub Actions for integration tests with PostgreSQL in ephemeral containers and automatic migration rollback — a practical guide

Deploy & DevOps · By · Published on · 9 min read

Aerial view of a yard with colorful containers lined up in rows
Visual metaphor for ephemeral containers and isolation in CI pipelines Photo: Tobi &Chris / Pexels

This walkthrough shows how to build a workflow that starts PostgreSQL as an ephemeral service per job, applies migrations (up), runs integration tests and automatically rolls back (down) at the end — even on failures. Topic: configuring GitHub Actions for integration tests with PostgreSQL in ephemeral containers and automatic migration rollback. At the end you'll have a clean, reproducible pipeline.

1) Prerequisites and pipeline architecture

You need integration tests and migration scripts that support up and down. The workflow creates an ephemeral PostgreSQL instance per job, applies migrations, runs tests and undoes changes at the end. Never point your CI to a shared database.

Architecture: GitHub Actions runner + PostgreSQL service + migrate/rollback scripts. Set DATABASE_URL and validate the connection before the test suite to fail fast.

  • Repository with a migrations directory (pairs like .up.sql and .down.sql).
  • Scripts to apply and revert migrations (bash, make, or your stack's tool).
  • Test runner (pytest, npm test, go test, etc.).
  • Access to an Ubuntu runner on GitHub Actions and psql installed.
  • DATABASE_URL configured to point at the local service.
# Variáveis de ambiente e verificação rápida
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/app_test"
pg_isready -h localhost -p 5432 -U postgres
psql "$DATABASE_URL" -c "SELECT 1;"

# Exemplos de execução (substitua pelo seu runner)
# Node: npm run test:integration
# Python: pytest -m integration
# Go: go test ./... -tags=integration
./scripts/run-tests.sh

2) How the ephemeral DB and automatic rollback work

The PostgreSQL service is discarded at the end of the job, ensuring isolation between runs. Still, running a rollback (down) is useful to validate reversibility and to train the team for real rollback scenarios.

You can combine complementary strategies for isolation, rollback and transactional sandboxes depending on your stack and desired coverage.

Strategy When to use Pros Cons
Ephemeral DB per job Whenever possible Full, clean isolation Doesn't validate rollback scripts
Run migrations rollback (down) at the end To validate reversions and train the team Ensures the down works Requires maintaining reliable down scripts
Transaction-per-test (BEGIN/ROLLBACK) Frameworks that support sandboxing Fast, repeatable runs Doesn't cover complex schema changes

3) Organize migrations and up/down scripts

Standardize a predictable layout and provide two scripts: migrate.sh (up) and rollback.sh (down). Scripts must fail on the first error so issues aren't hidden.

If you need more criteria and best practices on reversions, see the migrations guide in well-structured migrations and rollbacks.

# Estrutura sugerida
# migrations/
#   001_create_users.up.sql
#   001_create_users.down.sql
#   002_add_email_index.up.sql
#   002_add_email_index.down.sql
# scripts/
#   migrate.sh
#   rollback.sh

# scripts/migrate.sh
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
: "${DATABASE_URL:?Defina DATABASE_URL}"
for f in migrations/*.up.sql; do
  echo "[UP] Aplicando $f"
  psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -1 -f "$f"
done

echo "Migrations UP concluídas."

# scripts/rollback.sh
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
: "${DATABASE_URL:?Defina DATABASE_URL}"
# Reverte na ordem inversa
for f in $(ls migrations/*.down.sql | sort -r); do
  echo "[DOWN] Revertendo $f"
  psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -1 -f "$f"
done

echo "Rollback concluído."

4) Define the GitHub Actions workflow with a PostgreSQL service

Create a workflow that starts the postgres service, installs the psql client, applies migrations, runs tests and always attempts rollback at the end. Use GitHub Secrets instead of plaintext passwords.

To harden pipelines and secrets handling, see the CI/CD security checklist. If you also deploy with Actions, check the automatic deploy pipeline guide.

name: ci-integration-postgres

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        ports:
          - 5432:5432
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: app_test
        options: >-
          --health-cmd="pg_isready -U postgres"
          --health-interval=5s
          --health-timeout=5s
          --health-retries=20

    env:
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test

    steps:
      - uses: actions/checkout@v4

      - name: Instalar cliente PostgreSQL
        run: |
          sudo apt-get update
          sudo apt-get install -y postgresql-client

      - name: Aguardar Postgres e validar conexão
        run: |
          for i in {1..30}; do
            if pg_isready -h localhost -p 5432 -U postgres; then
              break
            fi
            sleep 1
          done
          psql "$DATABASE_URL" -c "SELECT version();"

      - name: Rodar migrations (UP)
        run: bash scripts/migrate.sh

      - name: Executar testes de integração
        run: |
          ./scripts/run-tests.sh

      - name: Rollback de migrations (DOWN) — sempre
        if: always()
        run: bash scripts/rollback.sh

5) How to guarantee rollback even when tests fail

Use if: always() on the rollback step. That ensures the down runs regardless of test results. Failures during rollback should fail the job to surface non-reversible migrations.

If you prefer, create a separate cleanup job with needs and if: always() to run additional actions after the suite.

  • Rollback step with if: always().
  • Errors in rollback should fail the pipeline to force immediate fixes.
  • Optional cleanup job to run extra tasks after tests.
# Exemplo de job de limpeza opcional
jobs:
  test:
    runs-on: ubuntu-latest
    # ... passos do job de testes ...

  cleanup:
    runs-on: ubuntu-latest
    needs: [test]
    if: always()
    steps:
      - uses: actions/checkout@v4
      - name: (Opcional) operações de limpeza adicionais
        run: |
          echo "Nada a fazer: o service Postgres é efêmero."
          # Em casos especiais, notifique ou arquive artefatos.

6) Parallelism and isolated DBs per job: how to set up

To parallelize, generate a unique database name per job and export it in DATABASE_URL to avoid collisions between concurrent runs. A matrix allows testing multiple Postgres versions.

If you use self-hosted runners and need to scale concurrency, see the guide on automatic scaling of self-hosted runners.

  • Database name using matrix.pg, github.run_id and github.run_attempt.
  • Idempotent creation via CREATE DATABASE or createdb.
  • Rollback remains valid with multiple isolated databases.
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        pg: [14, 15, 16]
    services:
      postgres:
        image: postgres:${{ matrix.pg }}
        ports: ["5432:5432"]
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd="pg_isready -U postgres"
          --health-interval=5s --health-timeout=5s --health-retries=20
    env:
      DB_NAME: app_test_${{ matrix.pg }}_${{ github.run_id }}_${{ github.run_attempt }}
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/${{ env.DB_NAME }}
    steps:
      - uses: actions/checkout@v4
      - name: Instalar cliente PostgreSQL
        run: sudo apt-get update && sudo apt-get install -y postgresql-client
      - name: Criar banco isolado
        run: |
          psql "postgres://postgres:postgres@localhost:5432/postgres" \
            -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"${DB_NAME}\";" || true
      - name: Rodar migrations e testes
        run: |
          bash scripts/migrate.sh
          ./scripts/run-tests.sh
      - name: Rollback (sempre)
        if: always()
        run: bash scripts/rollback.sh

7) Seeding test data in CI without leaving a mess

Seeds insert the minimal data your tests need. Apply them after migrations and, if there is no explicit rollback for seeds, rely on the ephemeral DB for cleanup.

Prefer idempotent seeds (UPSERT/ON CONFLICT) to avoid duplicates and flakiness across re-runs.

  • Run seeds after migrations and before tests.
  • Fixed IDs and UPSERTs prevent duplicates.
  • Rollback can explicitly remove seeded records if needed.
# scripts/seed.sh (exemplo)
#!/usr/bin/env bash
set -euo pipefail
: "${DATABASE_URL:?}"
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
INSERT INTO users(id, name, email)
VALUES (1, 'Test User', '[email protected]')
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, email=EXCLUDED.email;
SQL

echo "Seeds aplicados."

8) Final checks and troubleshooting common issues

Confirm the schema was applied, tests used the intended database and the rollback cleaned up changes. On failures, focus on connection, authentication and migration ordering.

If the problem persists, isolate a minimal case with a single migration and a test to reduce variables and speed diagnosis.

  • Connection refused: wait for service health and ensure psql is installed.
  • Authentication: use secrets; use a user with adequate privileges.
  • Timeouts: increase health-retries/interval; avoid heavy queries during setup.
  • Locks: apply migrations in a transaction (-1) and serialize complex schema changes.
  • psql not found: install postgresql-client in the job.
  • No down scripts: rely on the ephemeral DB and prioritize writing rollback scripts soon.
# Checks rápidos
psql "$DATABASE_URL" -c "\\dt public.*"                          # Tabelas no schema público
psql "$DATABASE_URL" -c "SELECT count(*) FROM public.users;"       # Tabela-chave
psql "$DATABASE_URL" -c "\\dt public.users" || true               # Após rollback, deve falhar/estar vazia

Frequently asked questions

Do I need rollback if the CI DB is already ephemeral?

Yes — if you want to validate your down scripts. The ephemeral DB ensures cleanup; running rollback verifies migrations are reversible and helps avoid being stuck during a real production rollback.

Can I use docker-compose instead of services in GitHub Actions?

Yes, but GitHub Actions services are simpler for a single PostgreSQL instance. docker-compose adds flexibility when you need multiple services or custom networks in the pipeline.

How do I isolate parallel builds so they don't conflict?

Use unique database names per job (run_id, run_attempt, matrix). Create the DB before running migrations and export DATABASE_URL with the distinct name for each run.

Where do I put the Postgres password in CI?

Store it in GitHub Secrets and reference the secret in the workflow. Examples use default values for clarity, but production projects must use secrets to reduce exposure and for auditability.

What if my migrations don't have down scripts?

Rely on the ephemeral DB to ensure isolation between jobs, but prioritize writing down scripts. Without them you can't validate rollbacks, increasing risk in production.

How can I seed test data without polluting the environment?

Apply idempotent seeds after migrations and remove them in rollback if needed. With ephemeral DBs the container is discarded at the end of the job, providing automatic cleanup.

Want to speed up your Postgres CI pipeline?

If you need this example adapted to your stack (Node, Python, Go, PHP) and want reliable rollbacks, let's discuss your setup.

Talk about your CI pipeline

See also