O artigo Compilação cruzada, binários estáticos e Docker com Go produziu um binário estático e uma imagem Docker otimizada. Agora é hora de colocá-los em produção. Três plataformas dominam o mercado de deploy de aplicações Go containerizadas, cada uma com filosofia diferente:
Railway — zero configuração, deploy por push, ideal para projetos pessoais e startups que querem ir ao ar rapidamente sem pensar em infraestrutura.
Fly.io — infraestrutura global com controle fino, deploys na borda, banco de dados gerenciado, excelente para APIs que precisam de baixa latência em múltiplas regiões.
Google Cloud Run — serverless containerizado, escala para zero quando sem tráfego, integração nativa com o ecossistema GCP, ideal para cargas variáveis e aplicações corporativas.
Railway: do repositório ao ar em minutos
Railway detecta automaticamente projetos Go e os compila sem configuração adicional.
Deploy via CLI
# Instalar CLI
curl -fsSL https://railway.app/install.sh | sh
# Login
railway login
# Criar projeto e vincular o diretório atual a ele
railway init --name taskapi
# Provisionar banco PostgreSQL
railway add --database postgres
# Configurar variáveis de ambiente
railway variables set JWT_SECRET=$(openssl rand -hex 32)
railway variables set APP_ENV=producao
railway variables set LOG_LEVEL=info
# Ver variáveis (DATABASE_URL é criada automaticamente pelo plugin)
railway variables
# Deploy
railway up
# Logs: sem flags, acompanha em tempo real; --lines 100 busca as últimas 100
railway logs
# Status do deploy
railway status
Arquivo de configuração railway.toml
# railway.toml
[build]
builder = "dockerfile"
dockerfilePath = "Dockerfile"
[deploy]
healthcheckPath = "/saude"
healthcheckTimeout = 30
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 3
# Variáveis não ficam neste arquivo: o schema não tem essa seção. Os segredos
# vão por "railway variables", e a porta o Railway injeta em PORT, que a
# aplicação precisa ler em vez de fixar 8080
Executando migrações no Railway
// cmd/api/main.go — executa migrações na inicialização
// O esquema da URL escolhe o driver do golang-migrate: com o driver
// database/postgres, postgres:// serve; com database/pgx/v5, precisa ser
// pgx5://, e postgres:// dá "unknown driver postgres (forgotten import?)"
func executarMigracoes(databaseURL string) error {
m, err := migrate.New(
"file://migrations",
databaseURL,
)
if err != nil {
return fmt.Errorf("criar migrator: %w", err)
}
defer m.Close()
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("executar migrações: %w", err)
}
version, dirty, _ := m.Version()
slog.Info("migrações executadas", "versão", version, "dirty", dirty)
return nil
}
GitHub Actions para Railway
# .github/workflows/deploy-railway.yml
name: Deploy Railway
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
# A imagem oficial da CLI do Railway, em vez de uma action de terceiros
# fixada em @main, que pode mudar a qualquer momento com acesso ao token
container: ghcr.io/railwayapp/cli:latest
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
steps:
- uses: actions/checkout@v7
- name: Deploy no Railway
run: railway up --service taskapi --ci
Fly.io: controle e distribuição global
Fly.io oferece VMs reais (Firecracker microVMs), não containers abstratos. Isso significa acesso a recursos como volumes persistentes, redes privadas e deploy em múltiplas regiões simultaneamente.
Configuração inicial
# Instalar flyctl
curl -L https://fly.io/install.sh | sh
# Login
fly auth login
# Criar e configurar a aplicação
fly launch \
--name taskapi \
--region gru \
--no-deploy
# Fly Postgres: o banco roda em Machines do seu app, e a operação é sua.
# A Fly oferece também o Managed Postgres, criado com "fly mpg create"
fly postgres create \
--name taskapi-db \
--region gru \
--initial-cluster-size 1 \
--vm-size shared-cpu-1x \
--volume-size 10
# Associa o banco à aplicação
# Isso cria automaticamente DATABASE_URL nos secrets
fly postgres attach taskapi-db --app taskapi
# Segredos da aplicação
fly secrets set \
JWT_SECRET="$(openssl rand -hex 32)" \
APP_ENV="producao" \
LOG_LEVEL="info" \
--app taskapi
fly.toml completo
# fly.toml
app = "taskapi"
primary_region = "gru"
[build]
dockerfile = "Dockerfile"
[build.args]
VERSAO = "1.0.0"
[env]
PORT = "8080"
APP_ENV = "producao"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true # para máquinas sem tráfego
auto_start_machines = true # reinicia quando tráfego chega
min_machines_running = 1 # sempre 1 instância mínima
[http_service.concurrency]
type = "requests"
hard_limit = 1000
soft_limit = 800
[[http_service.checks]]
grace_period = "10s"
interval = "30s"
method = "GET"
path = "/saude"
timeout = "5s"
tls_skip_verify = false
[[vm]]
memory = "256mb"
cpu_kind = "shared"
cpus = 1
# Não existe seção de regiões no fly.toml: as Machines de cada região
# são criadas com "fly scale count 2 --region gru", mostrado adiante
# Volume para dados persistentes (se necessário). Cada Machine precisa do
# seu próprio volume, e uma API sem estado não precisa desta seção
[[mounts]]
source = "taskapi_data"
destination = "/data"
Deploy e operações
# Primeiro deploy
fly deploy --app taskapi
# Deploy com estratégia rolling (sem downtime)
fly deploy --strategy rolling --app taskapi
# Escalar horizontalmente
fly scale count 3 --app taskapi
# Escalar verticalmente
fly scale vm shared-cpu-2x --memory 512 --app taskapi
# SSH na instância
fly ssh console --app taskapi
# Logs em tempo real
fly logs --app taskapi
# Monitoramento
fly status --app taskapi
fly checks list --app taskapi
# Executar comando ad-hoc (ex: migração manual)
fly ssh console --app taskapi -C "/taskapi migrate"
Deploy multi-região
# Adicionar região secundária
fly regions add iad # Northern Virginia
fly regions add lhr # London
fly scale count 1 --region iad
fly scale count 1 --region lhr
O Fly.io roteia automaticamente usuários para a região mais próxima, reduzindo latência globalmente.
Google Cloud Run: serverless com escala automática
Cloud Run executa containers de forma serverless — você paga apenas pelo tempo de CPU consumido durante requisições. Escala para zero automaticamente quando sem tráfego, e de zero para centenas de instâncias em segundos.
Configuração do GCP
# Instalar gcloud CLI
# https://cloud.google.com/sdk/docs/install
# Autenticar
gcloud auth login
gcloud auth configure-docker us-central1-docker.pkg.dev
# Criar projeto
gcloud projects create taskapi-prod --name="Task API Produção"
gcloud config set project taskapi-prod
# Habilitar APIs necessárias
gcloud services enable \
run.googleapis.com \
cloudbuild.googleapis.com \
sqladmin.googleapis.com \
secretmanager.googleapis.com \
artifactregistry.googleapis.com
# Criar repositório de imagens
gcloud artifacts repositories create taskapi \
--repository-format=docker \
--location=us-central1 \
--description="Task API Docker images"
Cloud SQL: PostgreSQL gerenciado
# Criar instância Cloud SQL
gcloud sql instances create taskapi-db \
--database-version=POSTGRES_16 \
--tier=db-f1-micro \
--region=us-central1 \
--storage-size=10GB \
--storage-auto-increase \
--backup-start-time=03:00 \
--availability-type=zonal
# Criar banco e usuário
gcloud sql databases create taskapi --instance=taskapi-db
gcloud sql users create taskapi_user \
--instance=taskapi-db \
--password="$(openssl rand -hex 16)"
Secret Manager para variáveis sensíveis
# Armazenar segredos no Secret Manager
echo -n "sua-jwt-secret-aqui" | \
gcloud secrets create jwt-secret --data-file=-
echo -n "postgres://taskapi_user:senha@/taskapi?host=/cloudsql/project:region:instance" | \
gcloud secrets create database-url --data-file=-
# Criar service account para o Cloud Run
gcloud iam service-accounts create taskapi-sa \
--display-name="Task API Service Account"
# Permissões para acessar os segredos
gcloud secrets add-iam-policy-binding jwt-secret \
--member="serviceAccount:taskapi-sa@taskapi-prod.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
gcloud secrets add-iam-policy-binding database-url \
--member="serviceAccount:taskapi-sa@taskapi-prod.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
# Permissão para conectar ao Cloud SQL
gcloud projects add-iam-policy-binding taskapi-prod \
--member="serviceAccount:taskapi-sa@taskapi-prod.iam.gserviceaccount.com" \
--role="roles/cloudsql.client"
Build e deploy no Cloud Run
# Build via Cloud Build (sem Docker local)
gcloud builds submit \
--tag us-central1-docker.pkg.dev/taskapi-prod/taskapi/api:latest \
.
# Deploy no Cloud Run
gcloud run deploy taskapi \
--image us-central1-docker.pkg.dev/taskapi-prod/taskapi/api:latest \
--region us-central1 \
--platform managed \
--allow-unauthenticated \
--service-account taskapi-sa@taskapi-prod.iam.gserviceaccount.com \
--set-secrets="JWT_SECRET=jwt-secret:latest,DATABASE_URL=database-url:latest" \
--add-cloudsql-instances taskapi-prod:us-central1:taskapi-db \
--min-instances 0 \
--max-instances 10 \
--concurrency 1000 \
--cpu 1 \
--memory 256Mi \
--timeout 30s \
--port 8080
service.yaml: configuração declarativa
# cloudrun/service.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: taskapi
labels:
cloud.googleapis.com/location: us-central1
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/minScale: "0"
autoscaling.knative.dev/maxScale: "10"
run.googleapis.com/cloudsql-instances: "taskapi-prod:us-central1:taskapi-db"
run.googleapis.com/startup-cpu-boost: "true"
spec:
serviceAccountName: taskapi-sa@taskapi-prod.iam.gserviceaccount.com
containerConcurrency: 1000
timeoutSeconds: 30
containers:
- image: us-central1-docker.pkg.dev/taskapi-prod/taskapi/api:latest
ports:
- containerPort: 8080
env:
- name: APP_ENV
value: producao
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: jwt-secret
key: latest
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: database-url
key: latest
resources:
limits:
cpu: "1"
memory: 256Mi
livenessProbe:
httpGet:
path: /saude
initialDelaySeconds: 10
periodSeconds: 30
startupProbe:
httpGet:
path: /saude
failureThreshold: 3
periodSeconds: 10
# Aplicar configuração declarativa
gcloud run services replace cloudrun/service.yaml --region us-central1
# Listar revisões
gcloud run revisions list --service taskapi --region us-central1
# Traffic splitting — deploy gradual
gcloud run services update-traffic taskapi \
--region us-central1 \
--to-revisions taskapi-00002-abc=10,taskapi-00001-xyz=90
# Logs
gcloud logging read \
"resource.type=cloud_run_revision AND resource.labels.service_name=taskapi" \
--limit 50 \
--format json
GitHub Actions para Cloud Run
# .github/workflows/deploy-cloudrun.yml
name: Deploy Cloud Run
on:
push:
branches: [main]
env:
PROJECT_ID: taskapi-prod
REGION: us-central1
SERVICE: taskapi
IMAGE: us-central1-docker.pkg.dev/taskapi-prod/taskapi/api
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- name: Autenticar no GCP
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- name: Configurar gcloud
uses: google-github-actions/setup-gcloud@v3
- name: Configurar Docker
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev
- name: Build e push
run: |
docker build \
--build-arg VERSAO=${{ github.sha }} \
--build-arg COMMIT=${{ github.sha }} \
-t ${{ env.IMAGE }}:${{ github.sha }} \
-t ${{ env.IMAGE }}:latest \
.
docker push ${{ env.IMAGE }}:${{ github.sha }}
docker push ${{ env.IMAGE }}:latest
- name: Deploy
run: |
gcloud run deploy ${{ env.SERVICE }} \
--image ${{ env.IMAGE }}:${{ github.sha }} \
--region ${{ env.REGION }} \
--quiet
- name: URL do serviço
run: |
gcloud run services describe ${{ env.SERVICE }} \
--region ${{ env.REGION }} \
--format "value(status.url)"
Comparativo das três plataformas
| Aspecto | Railway | Fly.io | Cloud Run |
|---|---|---|---|
| Configuração inicial | Mínima | Média | Alta |
| Controle de infraestrutura | Baixo | Alto | Médio |
| Banco de dados gerenciado | Sim | Sim (Managed Postgres) | Cloud SQL |
| Multi-região | Sim (réplicas por região) | Sim | Sim (um serviço por região) |
| Escala para zero | Opcional (sleepApplication) |
Opcional (auto_stop_machines) |
Sim (padrão) |
| Cobrança | Por uso de recursos | Por Machine em execução | Por requisição e tempo de CPU |
| Melhor para | Prototipagem e projetos pequenos | APIs globais com controle | Carga variável no ecossistema GCP |
Preços, SLAs e tempos de cold start mudam com frequência e dependem do plano contratado, e por isso ficaram fora da tabela: a página de preços de cada plataforma é a fonte para esses números.
Saúde e observabilidade em produção
Independentemente da plataforma, um endpoint de saúde robusto é essencial:
type StatusSaude struct {
Status string `json:"status"`
Versao string `json:"versao"`
Uptime string `json:"uptime"`
Checks map[string]string `json:"checks"`
}
var inicioApp = time.Now()
func handlerSaude(db *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancelar := context.WithTimeout(r.Context(), 3*time.Second)
defer cancelar()
checks := make(map[string]string)
status := "ok"
// Verifica banco de dados
if err := db.Ping(ctx); err != nil {
// O detalhe vai para o log, e não para a resposta: o endpoint é
// público, e a mensagem do driver pode revelar host e usuário
slog.Error("health check do banco falhou", "err", err)
checks["banco"] = "falha"
status = "degradado"
} else {
checks["banco"] = "ok"
}
httpStatus := http.StatusOK
if status != "ok" {
httpStatus = http.StatusServiceUnavailable
}
resposta := StatusSaude{
Status: status,
Versao: Versao,
Uptime: time.Since(inicioApp).Round(time.Second).String(),
Checks: checks,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpStatus)
json.NewEncoder(w).Encode(resposta)
}
}
Railway, Fly.io e Cloud Run levam o mesmo contêiner para produção com graus diferentes de controle: o Railway decide quase tudo, o Fly.io expõe as Machines e as regiões, e o Cloud Run cobra por requisição e escala até zero. Em qualquer um, a aplicação precisa ler a porta do ambiente, responder a um health check honesto e receber os segredos de fora do repositório. As plataformas evoluem rápido: comandos de CLI mudam de nome, seções de configuração deixam de existir, e serviços de banco ganham versões gerenciadas. Por isso, antes de seguir qualquer tutorial, inclusive este, vale conferir o comando na ajuda da própria CLI e a configuração contra o schema ou a referência oficial.
Fontes e leituras recomendadas
-
Railway Docs — Documentação oficial do Railway. https://docs.railway.app
-
Fly.io Docs — Documentação completa do Fly.io. https://fly.io/docs/
-
Cloud Run Docs — Documentação oficial do Google Cloud Run. https://cloud.google.com/run/docs
-
Cloud Run com Go — Guia específico para Go no Cloud Run. https://cloud.google.com/run/docs/quickstarts/build-and-deploy/deploy-go-service
-
Workload Identity Federation — Autenticação segura para GitHub Actions. https://cloud.google.com/blog/products/identity-security/enabling-keyless-authentication-from-github-actions
-
Twelve-Factor App — Metodologia de aplicações cloud-native. https://12factor.net/
Exercícios
Exercício 1
A primeira versão deste artigo tinha, no railway.toml, uma seção [[deploy.environmentVariables]] definindo PORT. O deploy funcionou mesmo assim. O que essa seção fazia, e como a aplicação recebe a porta no Railway?
Ver resposta
✓ Resposta: Nada: o schema oficial do arquivo, publicado pelo Railway, não tem essa seção. A parte deploy aceita opções como healthcheckPath, restartPolicyType, numReplicas, multiRegionConfig e sleepApplication, mas nenhuma lista de variáveis. O deploy funcionava porque o Railway injeta PORT por conta própria, e a seção inválida era simplesmente ignorada. O risco desse tipo de configuração é a falsa segurança: alguém acrescenta ali um segredo, acredita que ele chegou à aplicação, e não chegou. Variáveis vão pelo painel ou por railway variables set, e a aplicação precisa ler os.Getenv("PORT") em vez de fixar 8080. Conferir a configuração contra o schema, que editores com suporte a JSON Schema fazem automaticamente, pega seções inexistentes.
Exercício 2
O workflow de deploy no Railway usava uses: bervProject/railway-deploy@main, recebendo o RAILWAY_TOKEN. Funcionava. Por que a versão do artigo trocou por um contêiner com a CLI oficial?
Ver resposta
✓ Resposta: Uma action referenciada por @main roda o código que estiver nesse branch no momento do deploy, e não o que foi revisado quando o workflow foi escrito. Se o repositório da action for comprometido, ou simplesmente mudar de comportamento, o próximo deploy executa o código novo com acesso ao token do Railway, que permite publicar e alterar o projeto. A mitigação geral é fixar actions de terceiros pelo hash completo do commit, que não muda; tags e branches podem ser movidos. A versão do artigo evita a dependência: usa a imagem oficial ghcr.io/railwayapp/cli como contêiner do job e chama railway up --service taskapi --ci, em que --ci, conforme a ajuda da CLI, transmite só os logs de build e termina quando o build acaba. Menos intermediários entre o segredo e a plataforma significam menos pontos de falha.
Exercício 3
O fly.toml da primeira versão tinha uma seção [[regions]] com name = "gru" e count = 2. Depois do deploy, havia uma Machine só. Por quê, e como escalar por região?
Ver resposta
✓ Resposta: A referência oficial do fly.toml não tem seção de regiões. O arquivo define a região primária, com primary_region, onde o fly deploy cria as Machines novas, e o resto da configuração de serviço, volumes e VM; quantas Machines rodam e onde é estado do app, alterado por comandos. A seção era ignorada, e o app ficava com o que o deploy criou. Para ter duas Machines em São Paulo e uma na Virgínia, o caminho é fly scale count 2 --region gru e fly scale count 1 --region iad, como na seção de multi-região do artigo. Com volumes, há um cuidado a mais: cada Machine precisa do seu próprio volume, criado na mesma região, e por isso uma API sem estado não deve declarar [[mounts]] só por precaução.
Exercício 4
O health check original respondia {"banco": "falha: failed to connect to `user=taskapi host=10.0.3.7 database=taskapi`: dial error..."} quando o banco caía, e ainda tinha um campo banco sempre vazio. O que há de errado nessa resposta?
Ver resposta
✓ Resposta: O endpoint de saúde costuma ser público, porque o balanceador e a plataforma precisam acessá-lo sem autenticação, e a mensagem de erro do driver revela o host interno, o nome do banco e o usuário: informação útil para quem procura onde atacar. O detalhe pertence ao log, que só a equipe lê; a resposta diz apenas falha e o código 503, que é o que a plataforma usa para tirar a instância do balanceamento. O campo Banco da struct nunca era preenchido, porque o resultado ia para o mapa Checks, e por isso a resposta sempre trazia "banco": "" ao lado do valor verdadeiro em checks, confundindo quem lê. A versão do artigo remove o campo e registra o erro com slog.Error. Vale ainda separar dois endpoints: um de vida, que só diz que o processo responde, e um de prontidão, que verifica o banco.
Exercício 5
A tabela comparativa da primeira versão trazia "SLA 99,5%" para o Railway, "cold start <100 ms" para o Fly.io e custos mensais fixos, e marcava "Não" para multi-região e escala para zero no Railway. Por que esses itens saíram ou mudaram?
Ver resposta
✓ Resposta: Os números não tinham fonte e dependem de coisas que mudam: o plano contratado, a região, o tamanho da imagem, a versão da plataforma. Um SLA, quando existe, está num contrato e costuma valer só em planos pagos específicos; um cold start depende do que a aplicação faz ao subir. Publicados sem fonte, viram verdade para quem lê e envelhecem sem aviso. Já os dois "Não" estavam desatualizados: o schema atual do railway.toml tem multiRegionConfig, para réplicas em várias regiões, e sleepApplication, que suspende o serviço sem tráfego. A versão do artigo mantém só comparações qualitativas, verificáveis na documentação de cada plataforma, e manda buscar preços e garantias nas páginas oficiais. A regra vale para qualquer comparativo técnico: número sem data e sem fonte é opinião.