Skip to content

CI/CD Integration

How to integrate Queen migrations into your deployment pipeline.

Overview

Queen uses a code-first approach — migrations are Go code compiled into your application. This differs from file-based tools (goose, golang-migrate, flyway) where migrations are separate SQL files.

Key Characteristics

Code-first (Queen): - Migrations are Go structs registered at compile time - Type safety and compile-time validation - Requires rebuild when migrations change - Migrations versioned with application code

File-based (goose, golang-migrate): - Migrations are separate .sql files - Can use the same binary with different migration files - No rebuild needed for new migrations - Migrations can be managed separately from code

When Queen Works Well

Queen is ideal for:

  • Go applications where migrations deploy with the app
  • Docker/Kubernetes deployments with image rebuilds
  • Monorepo setups with migrations in the same repository
  • Teams that prefer type-safe, testable migrations
  • Applications using Go functions for complex migrations

When Queen May Not Fit

Queen may not be ideal for:

  • Pure SQL workflow without Go involvement
  • Separate migration runner as a standalone service
  • DevOps teams without access to application code
  • Legacy systems requiring file-based migration tools
  • Workflows where migrations are added without rebuilding

Deployment Patterns

Pattern 1: Migrations Embedded in Application

Most common pattern. Application runs migrations on startup.

Structure:

myapp/
├── cmd/
│   └── app/
│       └── main.go          # App with embedded migrations
├── migrations/
│   └── register.go          # Migration registration
└── internal/
    └── ...

Application code:

package main

import (
    "context"
    "github.com/honeynil/queen"
    "myapp/migrations"
)

func main() {
    q := queen.New(driver)
    migrations.Register(q)

    // Run migrations on startup
    if err := q.Up(context.Background()); err != nil {
        log.Fatal("migrations failed:", err)
    }

    // Start application
    startServer()
}

CI/CD (GitHub Actions):

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.24'

      - name: Build application
        run: go build -o app ./cmd/app

      - name: Deploy
        run: |
          # App runs migrations on startup
          ./app

Advantages: - Single binary deployment - Migrations always match application version - Automatic rollback if migrations fail

Disadvantages: - Application won't start if migrations fail - Can't run migrations separately for debugging


Pattern 2: Separate Migration Binary

Build separate CLI binary for migrations. Run before deploying application.

Structure:

myapp/
├── cmd/
│   ├── app/
│   │   └── main.go          # Application server
│   └── queen/
│       └── main.go          # Migration CLI
└── migrations/
    └── register.go

Migration CLI:

// cmd/queen/main.go
package main

import (
    "github.com/honeynil/queen/cli"
    "myapp/migrations"
)

func main() {
    cli.Run(migrations.Register)
}

CI/CD (GitLab CI):

stages:
  - build
  - migrate
  - deploy

build:
  stage: build
  script:
    - go build -o queen ./cmd/queen
    - go build -o app ./cmd/app
  artifacts:
    paths:
      - queen
      - app

migrate:
  stage: migrate
  script:
    - ./queen up --yes
  dependencies:
    - build

deploy:
  stage: deploy
  script:
    - deploy-app ./app
  dependencies:
    - build

Advantages: - Migrations run before app deployment - Can retry migrations independently - Better separation of concerns

Disadvantages: - Two binaries to manage - Migrations must be rebuilt for every change


Pattern 3: Docker Multi-Stage Build

Build migrations and application in separate Docker stages.

Dockerfile:

# Stage 1: Build migrations CLI
FROM golang:1.24 AS migration-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o queen ./cmd/queen

# Stage 2: Build application
FROM golang:1.24 AS app-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o app ./cmd/app

# Stage 3: Runtime image
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/

# Copy both binaries
COPY --from=migration-builder /app/queen .
COPY --from=app-builder /app/app .

# Run migrations, then start app
CMD ["sh", "-c", "./queen up --yes && ./app"]

CI/CD (GitHub Actions with Docker):

name: Docker Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t myapp:latest .

      - name: Push to registry
        run: docker push myapp:latest

      - name: Deploy to Kubernetes
        run: kubectl set image deployment/myapp myapp=myapp:latest

Advantages: - Single Docker image with everything - Consistent build environment - Easy rollback (previous image)

Disadvantages: - Larger image size (two binaries) - Migrations run inside container


Docker Examples

Embedded Migrations (Single Binary)

FROM golang:1.24 AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app ./cmd/app

FROM alpine:latest
RUN apk --no-cache add ca-certificates

WORKDIR /root/
COPY --from=builder /app/app .

CMD ["./app"]

Application runs migrations on startup.

Separate Init Container (Kubernetes)

Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    spec:
      initContainers:
      - name: migrations
        image: myapp:latest
        command: ["/root/queen", "up", "--yes"]
        env:
          - name: QUEEN_DSN
            valueFrom:
              secretKeyRef:
                name: db-credentials
                key: dsn

      containers:
      - name: app
        image: myapp:latest
        command: ["/root/app"]

Migrations run in init container before app starts.


CI/CD Platform Examples

GitHub Actions

Full example with testing:

name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.24'

      - name: Run tests
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/testdb?sslmode=disable
        run: |
          go test ./...

      - name: Build migration CLI
        run: go build -o queen ./cmd/queen

      - name: Test migrations
        env:
          QUEEN_DSN: postgres://postgres:postgres@localhost:5432/testdb?sslmode=disable
        run: |
          ./queen check --ci
          ./queen up --yes
          ./queen status
          ./queen validate

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - name: Build and push Docker image
        run: |
          docker build -t myapp:${{ github.sha }} .
          docker push myapp:${{ github.sha }}

      - name: Deploy
        run: kubectl set image deployment/myapp myapp=myapp:${{ github.sha }}

GitLab CI

stages:
  - test
  - build
  - deploy

variables:
  POSTGRES_DB: testdb
  POSTGRES_USER: postgres
  POSTGRES_PASSWORD: postgres

test:
  stage: test
  image: golang:1.24
  services:
    - postgres:16
  variables:
    DATABASE_URL: "postgres://postgres:postgres@postgres:5432/testdb?sslmode=disable"
  script:
    - go test ./...
    - go build -o queen ./cmd/queen
    - ./queen check --ci
    - ./queen up --yes
    - ./queen validate

build:
  stage: build
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  only:
    - main

deploy:
  stage: deploy
  script:
    - kubectl set image deployment/myapp myapp=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  only:
    - main

CircleCI

version: 2.1

orbs:
  go: circleci/go@1.7

jobs:
  test:
    docker:
      - image: cimg/go:1.24
      - image: postgres:16
        environment:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: testdb

    steps:
      - checkout
      - go/load-cache
      - go/mod-download
      - go/save-cache

      - run:
          name: Run tests
          command: go test ./...

      - run:
          name: Build migration CLI
          command: go build -o queen ./cmd/queen

      - run:
          name: Test migrations
          environment:
            QUEEN_DSN: postgres://postgres:postgres@localhost:5432/testdb?sslmode=disable
          command: |
            ./queen check --ci
            ./queen up --yes
            ./queen validate

  deploy:
    docker:
      - image: cimg/base:stable
    steps:
      - checkout
      - setup_remote_docker
      - run:
          name: Build and deploy
          command: |
            docker build -t myapp:latest .
            docker push myapp:latest

workflows:
  version: 2
  test-and-deploy:
    jobs:
      - test
      - deploy:
          requires:
            - test
          filters:
            branches:
              only: main

Best Practices

1. Exit Codes for CI/CD

Use queen's exit codes for pipeline control:

# Fail pipeline if gaps detected
queen check --ci || exit $?

# Allow pending migrations but fail on gaps
queen check --no-gaps --yes

Exit codes: - 0 — Success - 1 — General error - 2 — Configuration error - 3 — Validation failed - 4 — Gaps detected - 5 — Pending migrations (with --no-pending)

2. Environment-Specific Config

Use .queen.yaml for different environments:

development:
  driver: postgres
  dsn: "postgres://localhost/myapp_dev?sslmode=disable"

staging:
  driver: postgres
  dsn: "postgres://staging-db/myapp?sslmode=require"

production:
  driver: postgres
  dsn: "postgres://prod-db/myapp?sslmode=verify-full"
  require_confirmation: true
  require_explicit_unlock: true
# In CI/CD
queen up --use-config --env $ENVIRONMENT --yes

3. Avoid Confirmation Prompts

Always use --yes flag in CI/CD:

queen up --yes
queen reset --yes
queen down 5 --yes

4. Validation Before Deploy

Run validation checks in CI:

# Check for gaps and pending migrations
queen check --ci

# Validate migration checksums
queen validate

# Dry run to preview changes
queen plan

5. Rollback Strategy

Plan for rollback scenarios:

# Tag Docker images with version
docker tag myapp:latest myapp:v1.2.3

# If deployment fails, rollback
kubectl rollout undo deployment/myapp

# Or deploy previous image
kubectl set image deployment/myapp myapp=myapp:v1.2.2

6. Database Credentials

Never hardcode credentials. Use environment variables or secrets:

# Kubernetes secret
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
type: Opaque
stringData:
  dsn: postgres://user:password@host/db
# Reference in deployment
- name: QUEEN_DSN
  valueFrom:
    secretKeyRef:
      name: db-credentials
      key: dsn

Trade-offs and Limitations

Advantages of Code-First

Type safety — Compile-time validation catches errors early ✅ Versioning — Migrations version with application code ✅ Testing — Use queen.NewTest() for migration tests ✅ Go functions — Complex migrations with Go logic ✅ Single artifact — One binary for app + migrations

Limitations

Rebuild required — New migrations require recompiling ❌ No pure SQL workflow — Must use Go to register migrations ❌ DevOps access — Team needs Go tooling and code access ❌ No dynamic loading — Can't add migrations without rebuild

Workarounds

For SQL-first teams:

Use queen import to convert SQL files to Go code:

# Convert goose SQL files to Queen Go code
queen import --from goose ./sql-migrations
go build -o queen ./cmd/queen
./queen up

For separate migration runner:

Build a "thick" migration CLI with all migrations:

// cmd/migrations/main.go
package main

import (
    "github.com/honeynil/queen/cli"
    "mycompany/all-migrations" // All migrations from all services
)

func main() {
    cli.Run(all_migrations.Register)
}

Deploy this as a separate service.


Next Steps