Colocar uma aplicação no ar em Kubernetes tem duas tarefas chatas que todo mundo já fez na mão pelo menos uma vez: criar o registro DNS apontando o domínio para o cluster e emitir e renovar o certificado HTTPS. Feito manualmente, isso vira gargalo e fonte de erro — alguém esquece de renovar um certificado, o site cai, e o suporte acorda às 3 da manhã.

Este tutorial mostra como automatizar as duas coisas de vez. Ao final, sempre que você aplicar um Ingress novo no cluster, o registro DNS aparece sozinho e o certificado HTTPS é emitido e renovado automaticamente — e todo o deploy roda via GitHub Actions, sem kubectl apply manual. É a mesma arquitetura que usamos em produção na DevPlus, aqui reconstruída do zero, com valores de exemplo, para você reproduzir no seu ambiente.

O que vamos construir

Quatro peças trabalhando juntas dentro de um cluster GKE Autopilot:

  • ingress-nginx — o controlador de entrada. Recebe todo o tráfego HTTP/HTTPS através de um IP externo fixo.
  • external-dns — observa os Ingress do cluster e cria/atualiza os registros no Google Cloud DNS automaticamente.
  • cert-manager — observa os Ingress e emite certificados TLS gratuitos do Let’s Encrypt, renovando antes de expirarem.
  • GitHub Actions — aplica tudo (GitOps): um git push na pasta certa dispara o deploy no cluster.
   Registrador do domínio (NS apontando → Cloud DNS)


   ┌──────────────────── GKE Autopilot ────────────────────┐
   │                                                        │
   │   git push  ─────►  GitHub Actions  ─────►  kubectl    │
   │                                              │         │
   │        ┌──────────────┬────────────────┬─────┘         │
   │        ▼              ▼                ▼               │
   │  external-dns    cert-manager     ingress-nginx        │
   │  cria registro   emite cert TLS   roteia o tráfego     │
   │  no Cloud DNS    (Let's Encrypt)  (LoadBalancer)       │
   │        │              │                │               │
   └────────┼──────────────┼────────────────┼───────────────┘
            ▼              ▼                ▼
     app.exemplo.com    🔒 HTTPS      IP externo estático

O motivo de usar o GKE Autopilot é simples: a Google gerencia os nós, o escalonamento e o patching. Você paga pelos pods que roda, não por VMs ociosas, e não precisa cuidar de node pools. Para a maioria dos times, é a forma mais tranquila de rodar Kubernetes em produção.

Pré-requisitos

Antes de começar, você precisa de:

  • Uma conta no Google Cloud com billing ativo (o cluster e o Load Balancer têm custo — veja o aviso abaixo).
  • Um domínio próprio já registrado (ex.: exemplo.com). Você vai precisar acessar o painel do registrador para trocar os nameservers.
  • As CLIs gcloud, kubectl e helm instaladas na sua máquina.
  • Um repositório no GitHub para versionar os manifests.

💸 Aviso de custo: um cluster GKE Autopilot cobra pelos recursos dos pods, e o Load Balancer externo (do ingress-nginx) tem custo fixo por hora, além do IP estático. Para um laboratório, os valores são baixos, mas lembre de destruir tudo ao terminar (há uma seção de limpeza no final). Let’s Encrypt e external-dns são gratuitos.

Ao longo do tutorial, substitua estes valores de exemplo pelos seus:

PlaceholderExemploO que é
PROJECT_IDmeu-projetoID do projeto GCP
REGIONus-central1Região do cluster
CLUSTERmeu-clusterNome do cluster
exemplo.comseu domínioDomínio que você controla
voce@exemplo.comseu e-mailE-mail para o Let’s Encrypt

Parte 1 — Criar o cluster GKE Autopilot

Comece definindo variáveis de ambiente para reaproveitar nos comandos:

export PROJECT_ID="meu-projeto"
export REGION="us-central1"
export CLUSTER="meu-cluster"
export DOMAIN="exemplo.com"

gcloud config set project $PROJECT_ID

Habilite as APIs necessárias (Kubernetes e Cloud DNS):

gcloud services enable container.googleapis.com dns.googleapis.com

Crie o cluster Autopilot. Note que Autopilot é sempre regional (não use zona):

gcloud container clusters create-auto $CLUSTER \
  --location=$REGION \
  --project=$PROJECT_ID

A criação leva alguns minutos. Quando terminar, baixe as credenciais para o kubectl apontar para o cluster:

gcloud container clusters get-credentials $CLUSTER --location=$REGION
kubectl get nodes

⚠️ Regra de ouro do Autopilot: todo container precisa declarar resources.requests. Se você esquecer, o Autopilot injeta valores padrão (0,5 vCPU / 2 GiB), o que pode encarecer ou fazer o pod ser rejeitado. Por isso, todos os manifests deste tutorial já trazem resources explícitos — mantenha esse hábito.

Parte 2 — Delegar o domínio para o Cloud DNS

Para o external-dns gerenciar seus registros, o domínio precisa ser servido pelo Google Cloud DNS. Crie uma zona gerenciada:

gcloud dns managed-zones create exemplo-com \
  --dns-name="exemplo.com." \
  --description="Zona DNS do cluster Kubernetes" \
  --visibility=public

Agora pegue os nameservers que o Google atribuiu à zona:

gcloud dns managed-zones describe exemplo-com \
  --format="value(nameServers)"
# ns-cloud-a1.googledomains.com. ns-cloud-a2... (4 endereços)

No painel do seu registrador (Registro.br, GoDaddy, Cloudflare Registrar, etc.), troque os nameservers do domínio pelos quatro endereços retornados acima. Essa é a delegação — é o que faz o mundo perguntar ao Cloud DNS quando alguém acessa exemplo.com.

A propagação de nameservers pode levar de alguns minutos a algumas horas. O external-dns só consegue criar registros depois que a zona está delegada. Você pode seguir configurando o cluster enquanto isso propaga.

Parte 3 — Service Accounts para automação

Vamos criar duas contas de serviço (Service Accounts) do GCP: uma para o external-dns gerenciar o DNS, outra para o GitHub Actions acessar o cluster.

SA do external-dns

# Cria a conta
gcloud iam service-accounts create external-dns \
  --display-name="external-dns DNS manager"

# Concede permissão de administrar o Cloud DNS
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:external-dns@$PROJECT_ID.iam.gserviceaccount.com" \
  --role="roles/dns.admin"

# Gera uma chave JSON (vamos usar como Secret no cluster)
gcloud iam service-accounts keys create external-dns-key.json \
  --iam-account="external-dns@$PROJECT_ID.iam.gserviceaccount.com"

SA do GitHub Actions

gcloud iam service-accounts create github-deployer \
  --display-name="GitHub Actions deployer"

# Permissão para operar no cluster
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:github-deployer@$PROJECT_ID.iam.gserviceaccount.com" \
  --role="roles/container.developer"

gcloud iam service-accounts keys create github-deployer-key.json \
  --iam-account="github-deployer@$PROJECT_ID.iam.gserviceaccount.com"

🔒 Nota de segurança: chaves JSON são simples de reproduzir, mas são um segredo de longa duração. Em produção, o ideal é usar Workload Identity Federation (autenticação sem chave), tanto para o external-dns quanto para o GitHub Actions. Fica como próximo passo — este tutorial usa chaves JSON para reduzir o número de passos e facilitar a reprodução. Nunca faça commit desses arquivos .json.

Parte 4 — Estrutura do repositório (GitOps)

Organize o repositório separando a infraestrutura de base (core) das aplicações (apps). Essa separação é a mesma que usamos internamente e mantém o cluster fácil de entender:

meu-repo/
├── .github/
│   └── workflows/
│       ├── ingress-nginx.yaml
│       ├── cert-manager.yaml
│       ├── external-dns.yaml
│       └── whoami.yaml
└── k8s/
    ├── core/
    │   ├── ingress-nginx/
    │   │   └── values.yaml
    │   ├── cert-manager/
    │   │   ├── values.yaml
    │   │   └── cluster-issuer.yaml
    │   └── external-dns/
    │       └── values.yaml
    └── apps/
        └── whoami/
            ├── deployment.yaml
            ├── service.yaml
            └── ingress.yaml

Cada workflow no GitHub Actions é disparado por mudanças na pasta correspondente. Assim, mexer no external-dns/values.yaml só redeploya o external-dns.

Parte 5 — ingress-nginx com IP estático

Primeiro, reserve um IP externo regional estático — assim o endereço não muda se o Load Balancer for recriado (o que quebraria seu DNS):

gcloud compute addresses create nginx-ingress-ip --region=$REGION

# Anote o IP atribuído (útil para conferir depois)
gcloud compute addresses describe nginx-ingress-ip \
  --region=$REGION --format="value(address)"

Crie o k8s/core/ingress-nginx/values.yaml. Repare na annotation que vincula o IP estático pelo nome do recurso (o campo loadBalancerIP do Service foi descontinuado no Kubernetes):

# k8s/core/ingress-nginx/values.yaml
controller:
  service:
    annotations:
      # vincula o IP estático reservado (pelo NOME, não pelo IP)
      networking.gke.io/load-balancer-ip-addresses: "nginx-ingress-ip"
      networking.gke.io/load-balancer-type: "External"

  # Autopilot exige requests explícitos
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    limits:
      cpu: 500m
      memory: 512Mi

  autoscaling:
    enabled: true
    minReplicas: 1
    maxReplicas: 3
    targetCPUUtilizationPercentage: 80

  # os Jobs do admission webhook também precisam de requests no Autopilot
  admissionWebhooks:
    patch:
      resources:
        requests:
          cpu: 50m
          memory: 64Mi
        limits:
          cpu: 100m
          memory: 128Mi

Instale localmente (na primeira vez, para validar) ou deixe o GitHub Actions fazer — os comandos são os mesmos:

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx --create-namespace \
  --values k8s/core/ingress-nginx/values.yaml

Confirme que o Load Balancer recebeu o IP estático:

kubectl get svc ingress-nginx-controller -n ingress-nginx
# EXTERNAL-IP deve ser o IP que você reservou

ℹ️ Nota: o projeto ingress-nginx entrou em modo de manutenção e a comunidade recomenda avaliar implementações da Gateway API para novos clusters. O ingress-nginx segue funcionando e é ótimo para aprender o padrão — só tenha isso no radar para o longo prazo.

Parte 6 — cert-manager e os ClusterIssuers

Instale o cert-manager. Atenção: a flag correta hoje é --set crds.enabled=true (a antiga installCRDs=true foi substituída na v1.15):

helm repo add jetstack https://charts.jetstack.io --force-update
helm repo update

helm upgrade --install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --version v1.20.3 \
  --set crds.enabled=true

Confira a versão estável mais recente e ajuste o --version.

Agora crie os ClusterIssuer — a “conta” no Let’s Encrypt que emite os certificados. Definimos dois: um de staging (para testar sem esbarrar em limites) e um de produção:

# k8s/core/cert-manager/cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: voce@exemplo.com
    privateKeySecretRef:
      name: letsencrypt-staging
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: voce@exemplo.com
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
kubectl apply -f k8s/core/cert-manager/cluster-issuer.yaml

O desafio HTTP-01 funciona assim: o Let’s Encrypt acessa http://seu-dominio/.well-known/acme-challenge/... através do ingress-nginx para provar que você controla o domínio. Por isso o ingress-nginx precisa estar no ar antes — e o DNS já resolvendo.

📌 Regra prática: comece sempre pelo letsencrypt-staging. O Let’s Encrypt de produção tem limites de emissão rígidos — se ficar tentando com erro de configuração, você é bloqueado por horas. Valide com staging, depois troque para letsencrypt-prod.

Parte 7 — external-dns

Crie o namespace e o Secret com a chave JSON da conta de serviço do external-dns:

kubectl create namespace external-dns

kubectl create secret generic external-dns-credentials \
  --namespace external-dns \
  --from-file=credentials.json=external-dns-key.json

Crie o k8s/core/external-dns/values.yaml:

# k8s/core/external-dns/values.yaml
provider:
  name: google

# só mexe em registros deste domínio
domainFilters:
  - exemplo.com

# 'sync' cria E remove registros; use 'upsert-only' se quiser que ele nunca apague
policy: sync

# marca os registros que ele gerencia (evite conflito entre clusters)
registry: txt
txtOwnerId: meu-cluster

# observa Ingress e Services do tipo LoadBalancer
sources:
  - ingress
  - service

# informa o projeto onde está a zona do Cloud DNS
extraArgs:
  - --google-project=meu-projeto

# aponta para a chave JSON montada pelo Secret
env:
  - name: GOOGLE_APPLICATION_CREDENTIALS
    value: /etc/secrets/credentials.json

extraVolumes:
  - name: gcp-creds
    secret:
      secretName: external-dns-credentials

extraVolumeMounts:
  - name: gcp-creds
    mountPath: /etc/secrets
    readOnly: true

resources:
  requests:
    cpu: 50m
    memory: 64Mi
  limits:
    cpu: 100m
    memory: 128Mi

Instale:

helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update

helm upgrade --install external-dns external-dns/external-dns \
  --namespace external-dns --create-namespace \
  --values k8s/core/external-dns/values.yaml

Confira os logs — ele deve conectar ao Cloud DNS sem erros:

kubectl logs -n external-dns deploy/external-dns

Parte 8 — GitHub Actions: o CD do cluster

Até aqui rodamos os comandos localmente para entender cada peça. Agora vamos deixar o GitHub Actions fazer o trabalho a cada push.

No seu repositório GitHub, vá em Settings → Secrets and variables → Actions e crie:

SecretValor
GKE_PROJECTmeu-projeto
GKE_CLUSTERmeu-cluster
GKE_ZONEus-central1 (a região do cluster)
GKE_SA_KEYconteúdo completo do arquivo github-deployer-key.json

Crie o workflow do external-dns em .github/workflows/external-dns.yaml. Ele só roda quando algo muda em k8s/core/external-dns/:

# .github/workflows/external-dns.yaml
name: Deploy external-dns

on:
  workflow_dispatch:
  push:
    branches: [main]
    paths:
      - "k8s/core/external-dns/**"

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      NAMESPACE: external-dns
      VALUES: k8s/core/external-dns/values.yaml
    steps:
      - uses: actions/checkout@v4

      # autentica no GCP com a chave da SA (google-github-actions v3)
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GKE_PROJECT }}
          credentials_json: ${{ secrets.GKE_SA_KEY }}

      # baixa as credenciais do cluster para o kubectl/helm
      - uses: google-github-actions/get-gke-credentials@v3
        with:
          cluster_name: ${{ secrets.GKE_CLUSTER }}
          location: ${{ secrets.GKE_ZONE }}

      - name: Instalar/atualizar external-dns
        run: |
          helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
          helm repo update
          helm upgrade --install external-dns external-dns/external-dns \
            --namespace $NAMESPACE --create-namespace \
            --values $VALUES --atomic

Os workflows de ingress-nginx e cert-manager seguem exatamente o mesmo esqueleto — só mudam o paths, o repositório Helm e o values.yaml. O --atomic garante que, se o deploy falhar, o Helm faz rollback automático. Para as aplicações (próxima parte), o passo final vira um kubectl apply em vez de helm upgrade.

Repare no padrão: autenticar → pegar credenciais do cluster → aplicar. Todo pipeline de deploy para GKE segue esses três passos.

Parte 9 — Colocando à prova com uma aplicação real

Vamos subir uma app de demonstração (traefik/whoami, que só devolve informações da requisição) e ver a mágica acontecer. Crie os três manifests em k8s/apps/whoami/.

# k8s/apps/whoami/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami
  namespace: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: whoami
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
        - name: whoami
          image: traefik/whoami:latest
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 100m
              memory: 128Mi
# k8s/apps/whoami/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: whoami
  namespace: demo
spec:
  selector:
    app: whoami
  ports:
    - port: 80
      targetPort: 80

O Ingress é onde tudo se conecta. As annotations e o bloco tls são o que dispara o cert-manager, e o host é o que o external-dns lê para criar o registro DNS:

# k8s/apps/whoami/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: whoami
  namespace: demo
  annotations:
    # começe com "letsencrypt-staging" para testar; troque para prod depois
    cert-manager.io/cluster-issuer: "letsencrypt-staging"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - app.exemplo.com
      secretName: app-exemplo-com-tls
  rules:
    - host: app.exemplo.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: whoami
                port:
                  number: 80

Crie o namespace e aplique:

kubectl create namespace demo
kubectl apply -f k8s/apps/whoami/

Agora observe as peças reagindo automaticamente:

# 1) external-dns cria o registro A apontando app.exemplo.com para o IP do nginx
kubectl logs -n external-dns deploy/external-dns | grep app.exemplo.com

# 2) DNS resolvendo (pode levar 1-2 min)
dig +short app.exemplo.com

# 3) cert-manager emitindo o certificado
kubectl get certificate -n demo
# NAME                  READY   SECRET                 AGE
# app-exemplo-com-tls   True    app-exemplo-com-tls    90s

# 4) o site no ar com HTTPS
curl -I https://app.exemplo.com

Quando o Certificate mostrar READY=True no staging (o navegador vai reclamar do certificado de teste, o que é esperado), troque a annotation para letsencrypt-prod, aplique de novo e apague o secret antigo para forçar a reemissão:

# depois de editar a annotation para letsencrypt-prod:
kubectl apply -f k8s/apps/whoami/ingress.yaml
kubectl delete secret app-exemplo-com-tls -n demo   # força reemissão pelo issuer de produção

Em segundos você tem um certificado válido e confiável, com renovação automática. Daqui pra frente, cada nova app é só um Ingress novo — DNS e HTTPS saem de graça.

Ordem de bootstrap (resumo)

A ordem importa. Para um cluster novo, aplique nesta sequência:

  1. APIs + cluster GKE Autopilot
  2. Zona Cloud DNS + delegação no registrador
  3. Service Accounts (external-dns e GitHub Actions)
  4. IP estático reservado
  5. ingress-nginx (precisa existir antes dos desafios HTTP-01)
  6. cert-manager + ClusterIssuers
  7. external-dns
  8. Aplicações (cada Ingress dispara DNS + certificado)

Troubleshooting comum

  • Certificate preso em READY=False: rode kubectl describe certificate <nome> -n <ns> e siga a cadeia Order → Challenge. As causas mais comuns são o DNS ainda não resolver (o desafio HTTP-01 precisa do domínio apontando para o nginx) ou rate limit do Let’s Encrypt (por isso staging primeiro).
  • external-dns não cria registros: confira os logs. Quase sempre é o domainFilters não batendo com o host do Ingress, a zona ainda não delegada, ou a SA sem roles/dns.admin.
  • Pod rejeitado no Autopilot: faltou resources.requests em algum container. O Autopilot é rígido nisso.
  • EXTERNAL-IP do nginx fica <pending>: confira se o IP estático é regional (não global) e está na mesma região do cluster.

Como levamos isso para produção na DevPlus

O que você montou aqui é exatamente o padrão que roda por trás dos nossos produtos (Turnno, HookScope e outros): GKE Autopilot, ingress-nginx com IP estático, cert-manager com Let’s Encrypt e external-dns no Cloud DNS, tudo entregue por GitHub Actions com o fluxo autenticar → credenciais → aplicar. Na operação real, a estrutura evolui para:

  • Ambientes separados (dev/ e prd/), cada um com seu cluster, IP e workflows.
  • Workload Identity Federation no lugar das chaves JSON, eliminando segredos de longa duração.
  • Repositório core/ + apps/ onde a infra de base e dezenas de aplicações convivem, cada uma com deploy isolado por paths.
  • Validação de manifests no pipeline (ex.: kubeval) antes do apply.

Nada disso muda o núcleo do que você aprendeu — só adiciona camadas de segurança e escala em cima da mesma fundação.

Limpeza (evite cobranças)

Se isso foi um laboratório, destrua os recursos ao terminar:

gcloud container clusters delete $CLUSTER --location=$REGION
gcloud compute addresses delete nginx-ingress-ip --region=$REGION
gcloud dns managed-zones delete exemplo-com   # apague os registros da zona antes, se houver

Conclusão

DNS e TLS deixaram de ser tarefas manuais e viraram efeito colateral de aplicar um Ingress. Essa é a essência de uma plataforma Kubernetes bem montada: a infraestrutura reage às suas intenções em vez de exigir cliques. Com external-dns e cert-manager configurados uma vez, todo deploy futuro herda DNS e HTTPS automáticos — e o GitHub Actions garante que tudo seja versionado e reproduzível.

Precisa de uma plataforma Kubernetes em produção, sem virar refém de infraestrutura? A DevPlus projeta e opera clusters GKE para empresas que querem escalar com segurança. Fale com a gente.

Veja também: Guia de sobrevivência: rodando .NET no GKE Autopilot em produção, Health Checks no ASP.NET Core: do básico ao Kubernetes e Otimizando seu Dockerfile para .NET.