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
Ingressdo cluster e cria/atualiza os registros no Google Cloud DNS automaticamente. - cert-manager — observa os
Ingresse emite certificados TLS gratuitos do Let’s Encrypt, renovando antes de expirarem. - GitHub Actions — aplica tudo (GitOps): um
git pushna 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,kubectlehelminstaladas 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:
| Placeholder | Exemplo | O que é |
|---|---|---|
PROJECT_ID | meu-projeto | ID do projeto GCP |
REGION | us-central1 | Região do cluster |
CLUSTER | meu-cluster | Nome do cluster |
exemplo.com | seu domínio | Domínio que você controla |
voce@exemplo.com | seu e-mail | E-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á trazemresourcesexplí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 paraletsencrypt-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:
| Secret | Valor |
|---|---|
GKE_PROJECT | meu-projeto |
GKE_CLUSTER | meu-cluster |
GKE_ZONE | us-central1 (a região do cluster) |
GKE_SA_KEY | conteú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:
- APIs + cluster GKE Autopilot
- Zona Cloud DNS + delegação no registrador
- Service Accounts (external-dns e GitHub Actions)
- IP estático reservado
- ingress-nginx (precisa existir antes dos desafios HTTP-01)
- cert-manager + ClusterIssuers
- external-dns
- Aplicações (cada
Ingressdispara DNS + certificado)
Troubleshooting comum
Certificatepreso emREADY=False: rodekubectl describe certificate <nome> -n <ns>e siga a cadeiaOrder → 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
domainFiltersnão batendo com o host do Ingress, a zona ainda não delegada, ou a SA semroles/dns.admin. - Pod rejeitado no Autopilot: faltou
resources.requestsem algum container. O Autopilot é rígido nisso. EXTERNAL-IPdo 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/eprd/), 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 porpaths. - Validação de manifests no pipeline (ex.:
kubeval) antes doapply.
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.