Quando comecei a trabalhar com aplicações em Go que dependiam de bancos de dados relacionais, uma das maiores dores que encontrei foi manter as migrations organizadas e executadas de forma confiável entre os ambientes — desenvolvimento, staging e produção.
Se você já passou por aquele cenário em que o código está na nova versão, mas o banco não acompanhou, sabe exatamente o tipo de problema que estou falando.
Foi aí que eu decidi automatizar o processo de migrations usando Docker e integrar tudo ao meu pipeline de CI/CD. Neste artigo, vou mostrar como fiz isso do zero, os desafios que encontrei e as boas práticas que adotei para deixar o processo limpo, previsível e totalmente automatizado.
Por que automatizar migrations?
Em um projeto real, o banco de dados é tão parte da aplicação quanto o próprio código. De nada adianta fazer deploy de uma nova versão se as alterações no esquema do banco não forem aplicadas junto.
Automatizar migrations traz uma série de benefícios:
-
Evita divergências entre ambientes (dev, staging e produção);
-
Garante que toda alteração no schema esteja versionada;
-
Permite rollback em caso de erro;
-
Dá rastreabilidade e previsibilidade nos deploys;
-
E, o mais importante: elimina o “e se eu esqueci de rodar a migration antes do deploy?”.
Ferramentas que uso
No ecossistema Go existem várias opções, mas a combinação que escolhi e recomendo é:
-
golang-migrate → ferramenta consolidada, suporta vários bancos e pode rodar via CLI ou dentro do código Go.
-
Docker → para empacotar tanto o app quanto o processo de migração.
-
GitHub Actions (ou qualquer CI/CD que você use) → para automatizar a execução das migrations a cada deploy.
myapp/
├── cmd/
│ └── api/
│ └── main.go
├── database/
│ └── migrations/
│ ├── 000001_init.up.sql
│ ├── 000001_init.down.sql
│ ├── 000002_add_users.up.sql
│ └── 000002_add_users.down.sql
├── Dockerfile
├── docker-compose.yml
├── go.mod
└── go.sum
O diretório database/migrations guarda os arquivos versionados de migração.
Eu sigo o padrão 000001_descricao.up.sql e 000001_descricao.down.sql — o up aplica e o down reverte.
Criando migrations
Com o golang-migrate instalado, criar uma nova migration é simples:
migrate create -ext sql -dir database/migrations -seq add_users_table
Isso gera:
000003_add_users_table.up.sql
000003_add_users_table.down.sql
No arquivo .up.sql, adiciono as alterações:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
E no .down.sql, o rollback:
DROP TABLE IF EXISTS users;
Rodando migrations localmente
Para testar, basta apontar o comando para o banco local (no meu caso, PostgreSQL):
migrate -path database/migrations -database "postgres://user:pass@localhost:5432/mydb?sslmode=disable" up
E se eu quiser reverter a última alteração:
migrate -path database/migrations -database "..." down 1
Automatizando com Docker
Depois de escrever migrations e testar manualmente, vem o pulo do gato: automatizar com Docker.
Meu Dockerfile ficou assim:
# Builder
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp ./cmd/api
# Final
FROM alpine:latest
WORKDIR /root/
COPY --from=builder /app/myapp .
COPY database/migrations ./migrations
# Instala o CLI migrate
RUN apk add --no-cache curl \
&& curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz \
| tar xvz \
&& mv migrate.linux-amd64 /usr/local/bin/migrate \
&& chmod +x /usr/local/bin/migrate
ENV DATABASE_URL="postgres://user:pass@db:5432/mydb?sslmode=disable"
# Ao iniciar o container, roda as migrations e depois sobe o app
ENTRYPOINT ["sh", "-c", "migrate -path=/root/migrations -database \"$DATABASE_URL\" up && ./myapp"]
Com esse setup, toda vez que o container sobe, ele verifica e aplica automaticamente as migrations pendentes antes de rodar o binário Go.
docker-compose para desenvolvimento
O docker-compose.yml que uso localmente ficou assim:
version: "3.8"
services:
db:
image: postgres:15
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
api:
build: .
depends_on:
- db
environment:
DATABASE_URL: "postgres://user:pass@db:5432/mydb?sslmode=disable"
ports:
- "8080:8080"
volumes:
pgdata:
Ao rodar:
docker-compose up --build
o banco inicia, o container da API executa automaticamente as migrations e em seguida sobe o app.
Simples e funcional.
Integrando com CI/CD
Agora vem a cereja do bolo: integrar o processo no pipeline.
A ideia é que a cada novo deploy, o pipeline execute as migrations antes de atualizar a aplicação.
No meu caso, uso GitHub Actions, mas o conceito serve para qualquer CI.
O workflow (.github/workflows/ci-cd.yml) ficou assim:
name: Go CI/CD
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.21
- name: Run tests
run: go test ./... -v
deploy:
needs: build
runs-on: ubuntu-latest
env:
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
steps:
- uses: actions/checkout@v3
- name: Build and push image
run: |
docker build -t myrepo/myapp:${{ github.sha }} .
echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
docker push myrepo/myapp:${{ github.sha }}
- name: Run migrations
run: |
docker run --rm \
-v $(pwd)/database/migrations:/migrations \
migrate/migrate \
-path=/migrations \
-database "$DATABASE_URL" up
- name: Deploy application
run: |
# exemplo: kubectl set image deployment/myapp myapp=myrepo/myapp:${{ github.sha }}
Dessa forma, o pipeline:
-
Roda os testes;
-
Gera a imagem Docker e envia para o registry;
-
Executa as migrations no banco remoto;
-
Só então atualiza o ambiente de produção.
Se a migração falhar, o deploy é automaticamente interrompido.
Boas práticas que aprendi no processo
-
Nunca execute migrations dentro da etapa de
docker build— o banco não está disponível nesse momento. -
Sempre teste as migrations em um ambiente staging antes de ir para produção.
-
Mantenha o diretório
migrations/versionado no Git. -
Faça migrations pequenas, frequentes e reversíveis.
-
E, acima de tudo: nunca confie em migrations manuais no deploy.
Automatizar migrations foi um divisor de águas na minha rotina de deploys em Go.
Hoje, o fluxo é previsível, padronizado e sem surpresas: o pipeline cuida de aplicar o que precisa, na hora certa, com rastreabilidade total.
Se você ainda está rodando migrations “na mão” ou copiando SQL no terminal, recomendo fortemente investir nessa automação.
Acredite: depois que você configura uma vez, não volta mais atrás.