Go na nuvem: deploy no Railway, Fly.io e Google Cloud Run

Go na nuvem: deploy no Railway, Fly.io e Google Cloud Run

Deploy de uma API Go no Railway, no Fly.io e no Google Cloud Run: CLIs conferidas com as versões atuais, railway.toml e fly.toml sem seções inexistentes, workflows sem actions em @main, health check que não vaza detalhes e um comparativo sem números inventados.
Golang

• • 16 min de leitura

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

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.

Comentários

Mais em Golang

Ponteiros: conceito, uso e quando evitar
Ponteiros: conceito, uso e quando evitar

Ponteiros em Go: os operadores & e *, o nil que entra em pânico e o que não…

Go Modules: go.mod, go.sum e gerenciamento de dependências
Go Modules: go.mod, go.sum e gerenciamento de dependências

Go Modules: go.mod e go.sum, go get e go mod tidy, versionamento semântico e o…

Armadilhas da concorrência: race conditions e como detectá-las
Armadilhas da concorrência: race conditions e como detectá-las

Armadilhas da concorrência em Go: corridas de dados e o detector -race…