Código da Aula: Github
Crie a branch aula-15-security a partir da 02-ingress e entre nela
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
git checkout 02-ingress
git checkout -b aula-15-security⚙️ Parte 0 — Verificação do Ambiente
📁 Verificar Docker
🐧 Linux / Mac:
sudo systemctl start docker
docker info⊞ Windows (PowerShell):
docker-machine start default
docker-machine regenerate-certs default -f
docker-machine env default | Invoke-Expression
docker infoNo Windows com Docker Toolbox, o Docker roda dentro de uma VM VirtualBox. Esses 4 comandos garantem que a VM está rodando, com certificados válidos e o terminal apontando para ela.
📁 Verificar Terraform
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
terraform versionSe precisar atualizar ou instalar: https://developer.hashicorp.com/terraform/install
Para adicionar o Terraform ao PATH no Windows (PowerShell):
[System.Environment]::SetEnvironmentVariable("PATH", $env:PATH + ";C:\terraform", "User")📁 Verificar e instalar doctl
O doctl é a CLI oficial da DigitalOcean. Usamos ele para autenticar e salvar o kubeconfig do cluster automaticamente durante o terraform apply.
🐧 macOS:
brew install doctl
doctl version🐧 Linux:
wget https://github.com/digitalocean/doctl/releases/download/v1.159.0/doctl-1.159.0-linux-amd64.tar.gz
tar xf doctl-1.159.0-linux-amd64.tar.gz
sudo mv doctl /usr/local/bin
doctl version⊞ Windows (PowerShell):
Invoke-WebRequest https://github.com/digitalocean/doctl/releases/download/v1.159.0/doctl-1.159.0-windows-amd64.zip -OutFile doctl.zip
Expand-Archive -Path doctl.zip -DestinationPath C:\doctl
[System.Environment]::SetEnvironmentVariable("PATH", $env:PATH + ";C:\doctl", "User")
doctl version📁 Autenticar o doctl com a DigitalOcean
- Acesse o Painel DigitalOcean → API → Tokens
- Clique em Generate New Token
- Dê um nome (ex:
aula-15-k8s-security), marque read e write - Copie o token gerado — ele só aparece uma vez
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
doctl auth init --access-token SEU_TOKEN_AQUI
doctl account get📁 Verificar versões disponíveis do Kubernetes
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
doctl kubernetes options versionsCopie o Slug da versão mais recente (ex: 1.36.0-do.0) — você vai usar esse valor no terraform.tfvars.
🐳 Parte 1 — Imagens Docker
Nesta aula vamos utilizar as imagens que já estão publicadas no Docker Hub a partir das aulas anteriores:
- toticavalcanti/fiber-auth-api:v1.0 — backend Go/Fiber
- toticavalcanti/auth-ui:v1.1 — frontend React/Nginx
📁 Verificar e baixar as imagens do Docker Hub
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
docker pull toticavalcanti/fiber-auth-api:v1.0
docker pull toticavalcanti/auth-ui:v1.1🐧 Linux / Mac:
docker images | grep toticavalcanti⊞ Windows (PowerShell):
docker images | Select-String "toticavalcanti"⊞ Windows (CMD):
docker images | findstr toticavalcanti📁 Recap — Dockerfiles das aulas anteriores
📁 Ativar BuildKit
🐧 Linux / Mac:
export DOCKER_BUILDKIT=1⊞ Windows (PowerShell):
$env:DOCKER_BUILDKIT=1📁 Dockerfile do Backend
codigo-fluente-fiber-tutorial/devops/Dockerfile-backend
# Etapa 1: Build
FROM golang:1.18 as builder
WORKDIR /app
COPY backend/fiber-project/go.mod backend/fiber-project/go.sum ./
RUN go mod download
COPY backend/fiber-project/ ./
RUN go build -o main .
# Etapa 2: Imagem final leve (distroless)
FROM gcr.io/distroless/base-debian10
WORKDIR /app
COPY --from=builder /app/main .
CMD ["./main"]📁 Dockerfile do Frontend
codigo-fluente-fiber-tutorial/devops/Dockerfile-frontend
# Etapa 1: Build da aplicação React
FROM node:16-alpine as build
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY frontend/react-auth/package*.json ./
RUN npm ci --legacy-peer-deps --verbose
COPY frontend/react-auth/ ./
ENV REACT_APP_API_URL=/api
RUN npm run build
# Etapa 2: Servir com Nginx
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY frontend/react-auth/nginx.conf /etc/nginx/conf.d/default.conf
COPY frontend/react-auth/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]📁 Build e Push das imagens
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
docker build -f devops/Dockerfile-backend -t toticavalcanti/fiber-auth-api:v1.0 .
docker build -f devops/Dockerfile-frontend -t toticavalcanti/auth-ui:v1.1 .docker login
docker push toticavalcanti/fiber-auth-api:v1.0
docker push toticavalcanti/auth-ui:v1.1
☁️ Parte 2 — Estrutura do Projeto Terraform
O Terraform faz tudo de uma vez: cria o cluster na DigitalOcean e deploya a aplicação com todas as configurações de segurança. Um único terraform apply — o mesmo padrão das aulas anteriores.
Nesta aula a estrutura fica assim:
user-auth-system/
└── devops/
├── main.tf ← MODIFICADO: versão, nome e região do cluster viram variáveis
├── variables.tf ← MODIFICADO: adiciona cluster_name, cluster_version e demais variáveis
├── locals.tf ← NOVO: padrões de segurança centralizados
├── rbac.tf ← NOVO: ServiceAccounts e Roles com menor privilégio
├── deployment.tf ← MODIFICADO: SecurityContext no backend e frontend; MySQL sem SecurityContext
├── network_policy.tf ← NOVO: Zero Trust networking entre pods
├── helm_ingress.tf ← MODIFICADO: namespace dedicado + desabilita admission webhook
├── ingress.tf ← sem mudanças
├── outputs.tf ← sem mudanças
├── terraform.tfvars.example ← MODIFICADO: reflete todas as variáveis
└── .gitignore ← sem mudanças📁 Arquivos modificados e criados nesta aula
devops/main.tf ← MODIFICADO
O que mudou em relação à aula 14 e por quê:
- required_version — adicionamos
>= 1.0para garantir compatibilidade mínima do Terraform. - name = var.cluster_name — o nome do cluster deixa de ser hardcoded e passa a vir do
terraform.tfvars. - region = var.do_region — a região deixa de ser hardcoded e passa a vir do
terraform.tfvars. - version = var.cluster_version — a versão do Kubernetes deixa de ser hardcoded. Use o slug obtido via
doctl kubernetes options versions. - sleep 120 — aumentado de 45 para 120 segundos para garantir que o DNS do cluster esteja propagado antes de criar os recursos Kubernetes.
user-auth-system/devops/main.tf
terraform {
required_version = ">= 1.0"
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.33.0"
}
helm = {
source = "hashicorp/helm"
version = "2.17.0"
}
}
}
provider "digitalocean" {
token = var.do_token
}
# Criação do Cluster Kubernetes
resource "digitalocean_kubernetes_cluster" "meu_cluster" {
name = var.cluster_name
region = var.do_region
version = var.cluster_version
node_pool {
name = "default-pool"
size = "s-2vcpu-4gb"
node_count = 2
}
provisioner "local-exec" {
command = <<-EOT
echo "Iniciando kubeconfig..."
doctl kubernetes cluster kubeconfig save ${var.cluster_name}
echo "Kubeconfig salvo. Aguardando cluster ficar pronto..."
sleep 120
kubectl wait --for=condition=Ready nodes --all --timeout=300s
echo "Cluster está pronto!"
EOT
}
}
provider "kubernetes" {
host = digitalocean_kubernetes_cluster.meu_cluster.endpoint
token = digitalocean_kubernetes_cluster.meu_cluster.kube_config[0].token
cluster_ca_certificate = base64decode(
digitalocean_kubernetes_cluster.meu_cluster.kube_config[0].cluster_ca_certificate
)
}
provider "helm" {
kubernetes {
host = digitalocean_kubernetes_cluster.meu_cluster.endpoint
token = digitalocean_kubernetes_cluster.meu_cluster.kube_config[0].token
cluster_ca_certificate = base64decode(
digitalocean_kubernetes_cluster.meu_cluster.kube_config[0].cluster_ca_certificate
)
}
}devops/variables.tf ← MODIFICADO
O que mudou e por quê:
- do_region, cluster_name, cluster_version — região e versão do cluster deixam de ser hardcoded.
- namespace, environment — permitem identificar o ambiente nos labels dos recursos.
- backend_image, frontend_image, mysql_image — imagens Docker via variáveis em vez de hardcoded.
- backend_replica_count — número de réplicas do backend via variável.
- mysql_database — nome do banco via variável.
user-auth-system/devops/variables.tf
variable "do_token" {
type = string
sensitive = true
description = "Token de acesso para a API da DigitalOcean"
}
variable "do_region" {
description = "Região onde o cluster será criado"
type = string
default = "nyc1"
}
variable "cluster_name" {
description = "Nome do cluster Kubernetes"
type = string
default = "meu-cluster"
}
variable "cluster_version" {
description = "Versão do Kubernetes (use: doctl kubernetes options versions)"
type = string
default = "1.36.0-do.0"
}
variable "namespace" {
description = "Namespace Kubernetes para os recursos"
type = string
default = "default"
}
variable "environment" {
description = "Ambiente de execução"
type = string
default = "production"
}
variable "backend_image" {
description = "Imagem Docker do backend Go/Fiber"
type = string
default = "toticavalcanti/fiber-auth-api:v1.0"
}
variable "frontend_image" {
description = "Imagem Docker do frontend React/Nginx"
type = string
default = "toticavalcanti/auth-ui:v1.1"
}
variable "mysql_image" {
description = "Imagem Docker do MySQL"
type = string
default = "mysql:8.0"
}
variable "backend_replica_count" {
description = "Número de réplicas do backend"
type = number
default = 1
}
variable "mysql_database" {
description = "Nome do banco de dados MySQL"
type = string
default = "auth_db"
}
variable "mysql_root_password" {
description = "Senha do root para o MySQL"
type = string
sensitive = true
}
variable "gmail_username" {
type = string
description = "Endereço do Gmail para enviar emails"
}
variable "gmail_password" {
type = string
sensitive = true
description = "Senha do Gmail ou Senha de App"
}
variable "react_app_api_url" {
description = "URL para a API do auth-api"
type = string
default = "/api/"
}devops/locals.tf ← NOVO
Centraliza os padrões de segurança reutilizados em todos os recursos. Em vez de repetir as mesmas configurações de SecurityContext em cada container, definimos uma vez aqui e referenciamos em todo lugar.
- common_labels — labels aplicados em todos os recursos para rastreabilidade.
- pod_security_context e container_security_context — definem que nenhum container roda como root, o filesystem é somente leitura e todas as Linux capabilities são removidas. Aplicados no backend e frontend. O MySQL não recebe SecurityContext pois a imagem oficial requer privilégios de root para inicializar — o isolamento do MySQL é garantido pela NetworkPolicy.
- resource limits — CPU e memória definidos para backend, frontend e MySQL.
user-auth-system/devops/locals.tf
locals {
common_labels = {
project = "fiber-auth"
managed-by = "terraform"
}
pod_security_context = {
run_as_non_root = true
run_as_user = 65534
run_as_group = 65534
fs_group = 65534
}
container_security_context = {
allow_privilege_escalation = false
read_only_root_filesystem = true
run_as_non_root = true
run_as_user = 65534
drop_capabilities = ["ALL"]
}
backend_resources = {
requests = { cpu = "100m", memory = "128Mi" }
limits = { cpu = "500m", memory = "512Mi" }
}
frontend_resources = {
requests = { cpu = "50m", memory = "64Mi" }
limits = { cpu = "200m", memory = "256Mi" }
}
mysql_resources = {
requests = { cpu = "250m", memory = "512Mi" }
limits = { cpu = "500m", memory = "1Gi" }
}
}devops/rbac.tf ← NOVO
Implementa o princípio do menor privilégio: cada serviço tem sua própria identidade no cluster e só pode fazer o que é estritamente necessário.
- ServiceAccounts separadas — uma para backend, uma para frontend, uma para MySQL.
- automount_service_account_token = false — impede que o token seja montado automaticamente nos pods.
- Role readonly_debugger — o backend pode ver logs para debug, mas não pode deletar pods ou criar secrets.
- Frontend e MySQL sem nenhuma Role — acesso zero à API do Kubernetes.
user-auth-system/devops/rbac.tf
resource "kubernetes_service_account" "backend" {
metadata {
name = "backend-service-account"
labels = local.common_labels
}
automount_service_account_token = false
}
resource "kubernetes_service_account" "frontend" {
metadata {
name = "frontend-service-account"
labels = local.common_labels
}
automount_service_account_token = false
}
resource "kubernetes_service_account" "mysql" {
metadata {
name = "mysql-service-account"
labels = local.common_labels
}
automount_service_account_token = false
}
resource "kubernetes_role" "readonly_debugger" {
metadata {
name = "readonly-debugger"
labels = local.common_labels
}
rule {
api_groups = [""]
resources = ["pods", "pods/log"]
verbs = ["get", "list"]
}
}
resource "kubernetes_role_binding" "backend_readonly" {
metadata {
name = "backend-readonly-binding"
labels = local.common_labels
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "Role"
name = kubernetes_role.readonly_debugger.metadata[0].name
}
subject {
kind = "ServiceAccount"
name = kubernetes_service_account.backend.metadata[0].name
}
}devops/deployment.tf ← MODIFICADO
O que mudou em relação à aula 14 e por quê:
- service_account_name — cada deployment usa a ServiceAccount específica criada no
rbac.tf. - automount_service_account_token = false — o token não é montado automaticamente.
- SecurityContext no backend e frontend —
run_as_non_root,read_only_root_filesystem,capabilities drop ALLeseccomp_profile RuntimeDefault. - MySQL sem SecurityContext — a imagem oficial do MySQL requer privilégios de root para inicializar o banco. O isolamento é garantido via NetworkPolicy.
- Volumes tmpfs —
/tmp,/var/cache/nginxe/var/runmontados em RAM porque o filesystem é somente leitura. - Resource limits via locals — CPU e memória via
locals.tf. - Imagens, banco e réplicas via variáveis —
var.mysql_image,var.backend_image,var.frontend_image,var.mysql_database,var.backend_replica_count. - namespace = "ingress-nginx" no data source — o Ingress Controller agora roda em namespace dedicado.
user-auth-system/devops/deployment.tf
# Data Source para o IP do Ingress Controller
data "kubernetes_service" "ingress_nginx" {
metadata {
name = "ingress-nginx-controller"
namespace = "ingress-nginx"
}
depends_on = [helm_release.ingress_nginx]
}
##################################
# SECRETS
##################################
resource "kubernetes_secret" "gmail_credentials" {
metadata {
name = "gmail-credentials"
}
data = {
username = var.gmail_username
password = var.gmail_password
}
}
resource "kubernetes_secret" "mysql_secret" {
metadata {
name = "mysql-secret"
}
data = {
mysql-root-password = base64encode(var.mysql_root_password)
}
}
##################################
# VOLUMES
##################################
resource "kubernetes_persistent_volume" "mysql_pv" {
metadata {
name = "mysql-pv"
labels = {
type = "local"
app = "mysql"
}
}
spec {
capacity = {
storage = "5Gi"
}
access_modes = ["ReadWriteOnce"]
storage_class_name = "manual"
persistent_volume_reclaim_policy = "Retain"
persistent_volume_source {
host_path {
path = "/mnt/data"
type = "DirectoryOrCreate"
}
}
}
}
resource "kubernetes_persistent_volume_claim" "mysql_pvc" {
metadata {
name = "mysql-pvc"
labels = {
app = "mysql"
}
}
spec {
access_modes = ["ReadWriteOnce"]
resources {
requests = {
storage = "5Gi"
}
}
storage_class_name = "manual"
volume_name = kubernetes_persistent_volume.mysql_pv.metadata[0].name
}
depends_on = [
kubernetes_persistent_volume.mysql_pv
]
}
##################################
# MYSQL: DEPLOYMENT E SERVICE
##################################
resource "kubernetes_deployment" "mysql" {
metadata {
name = "mysql"
labels = {
app = "mysql"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "mysql"
}
}
template {
metadata {
labels = {
app = "mysql"
}
}
spec {
service_account_name = kubernetes_service_account.mysql.metadata[0].name
automount_service_account_token = false
# A imagem oficial do MySQL requer root para inicializar o banco.
# O isolamento é garantido via NetworkPolicy — apenas o backend acessa o MySQL.
container {
name = "mysql"
image = var.mysql_image
env {
name = "MYSQL_ROOT_PASSWORD"
value = var.mysql_root_password
}
env {
name = "MYSQL_DATABASE"
value = var.mysql_database
}
resources {
limits = {
memory = local.mysql_resources.limits.memory
cpu = local.mysql_resources.limits.cpu
}
requests = {
memory = local.mysql_resources.requests.memory
cpu = local.mysql_resources.requests.cpu
}
}
port {
container_port = 3306
name = "mysql"
}
volume_mount {
name = "mysql-persistent-storage"
mount_path = "/var/lib/mysql"
}
readiness_probe {
tcp_socket {
port = 3306
}
initial_delay_seconds = 15
period_seconds = 10
}
liveness_probe {
tcp_socket {
port = 3306
}
initial_delay_seconds = 20
period_seconds = 10
}
}
volume {
name = "mysql-persistent-storage"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.mysql_pvc.metadata[0].name
}
}
}
}
}
depends_on = [kubernetes_persistent_volume_claim.mysql_pvc]
}
resource "kubernetes_service" "mysql_service" {
metadata {
name = "mysql-service"
labels = {
app = "mysql"
}
}
spec {
selector = {
app = "mysql"
}
type = "ClusterIP"
port {
port = 3306
target_port = 3306
name = "mysql"
}
}
}
##################################
# BACKEND: DEPLOYMENT E SERVICE
##################################
resource "kubernetes_deployment" "auth_api" {
metadata {
name = "auth-api"
labels = {
app = "auth-api"
}
}
spec {
replicas = var.backend_replica_count
selector {
match_labels = {
app = "auth-api"
}
}
template {
metadata {
labels = {
app = "auth-api"
}
}
spec {
service_account_name = kubernetes_service_account.backend.metadata[0].name
automount_service_account_token = false
security_context {
run_as_non_root = local.pod_security_context.run_as_non_root
run_as_user = local.pod_security_context.run_as_user
run_as_group = local.pod_security_context.run_as_group
fs_group = local.pod_security_context.fs_group
seccomp_profile { type = "RuntimeDefault" }
}
init_container {
name = "wait-for-mysql"
image = "busybox:1.28"
command = [
"sh", "-c",
"until nc -z mysql-service 3306; do echo waiting for mysql; sleep 2; done;"
]
security_context {
allow_privilege_escalation = false
read_only_root_filesystem = true
run_as_non_root = true
run_as_user = 65534
capabilities { drop = ["ALL"] }
}
}
container {
name = "auth-api"
image = var.backend_image
env {
name = "MYSQL_ROOT_PASSWORD"
value = var.mysql_root_password
}
env {
name = "DB_DSN"
value = "root:${var.mysql_root_password}@tcp(mysql-service:3306)/${var.mysql_database}?parseTime=true"
}
env {
name = "GMAIL_USERNAME"
value_from {
secret_key_ref {
name = kubernetes_secret.gmail_credentials.metadata[0].name
key = "username"
}
}
}
env {
name = "GMAIL_PASSWORD"
value_from {
secret_key_ref {
name = kubernetes_secret.gmail_credentials.metadata[0].name
key = "password"
}
}
}
env {
name = "APP_URL"
value = "http://${data.kubernetes_service.ingress_nginx.status[0].load_balancer[0].ingress[0].ip}"
}
security_context {
allow_privilege_escalation = local.container_security_context.allow_privilege_escalation
read_only_root_filesystem = local.container_security_context.read_only_root_filesystem
run_as_non_root = local.container_security_context.run_as_non_root
run_as_user = local.container_security_context.run_as_user
capabilities { drop = local.container_security_context.drop_capabilities }
}
port {
container_port = 3000
name = "http"
}
resources {
limits = {
cpu = local.backend_resources.limits.cpu
memory = local.backend_resources.limits.memory
}
requests = {
cpu = local.backend_resources.requests.cpu
memory = local.backend_resources.requests.memory
}
}
volume_mount {
name = "tmp-dir"
mount_path = "/tmp"
}
readiness_probe {
http_get {
path = "/api/health"
port = 3000
}
initial_delay_seconds = 15
period_seconds = 10
}
liveness_probe {
http_get {
path = "/api/health"
port = 3000
}
initial_delay_seconds = 20
period_seconds = 10
}
}
volume {
name = "tmp-dir"
empty_dir { medium = "Memory" }
}
}
}
}
depends_on = [
kubernetes_deployment.mysql,
kubernetes_service.mysql_service
]
}
resource "kubernetes_service" "auth_api" {
metadata {
name = "auth-api-service"
labels = {
app = "auth-api"
}
}
spec {
selector = {
app = "auth-api"
}
type = "ClusterIP"
port {
port = 3000
target_port = 3000
name = "http"
}
}
}
##################################
# FRONTEND: DEPLOYMENT E SERVICE
##################################
resource "kubernetes_deployment" "auth_ui" {
metadata {
name = "auth-ui"
labels = {
app = "auth-ui"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "auth-ui"
}
}
template {
metadata {
labels = {
app = "auth-ui"
}
}
spec {
service_account_name = kubernetes_service_account.frontend.metadata[0].name
automount_service_account_token = false
security_context {
run_as_non_root = true
run_as_user = 101
run_as_group = 101
fs_group = 101
seccomp_profile { type = "RuntimeDefault" }
}
container {
name = "auth-ui"
image = var.frontend_image
env {
name = "REACT_APP_API_URL"
value = var.react_app_api_url
}
command = ["/bin/sh", "-c"]
args = [
<<-EOT
echo 'window._env_ = { REACT_APP_API_URL: \"${var.react_app_api_url}\" };' > /usr/share/nginx/html/config.js
nginx -g 'daemon off;'
EOT
]
security_context {
allow_privilege_escalation = false
read_only_root_filesystem = true
run_as_non_root = true
run_as_user = 101
capabilities { drop = ["ALL"] }
}
port {
container_port = 80
name = "http"
}
resources {
limits = {
cpu = local.frontend_resources.limits.cpu
memory = local.frontend_resources.limits.memory
}
requests = {
cpu = local.frontend_resources.requests.cpu
memory = local.frontend_resources.requests.memory
}
}
volume_mount {
name = "nginx-cache"
mount_path = "/var/cache/nginx"
}
volume_mount {
name = "nginx-run"
mount_path = "/var/run"
}
volume_mount {
name = "tmp-dir"
mount_path = "/tmp"
}
readiness_probe {
http_get {
path = "/index.html"
port = 80
}
initial_delay_seconds = 10
period_seconds = 5
failure_threshold = 3
success_threshold = 1
timeout_seconds = 1
}
liveness_probe {
http_get {
path = "/index.html"
port = 80
}
initial_delay_seconds = 15
period_seconds = 10
failure_threshold = 3
success_threshold = 1
timeout_seconds = 1
}
}
volume {
name = "nginx-cache"
empty_dir { medium = "Memory" }
}
volume {
name = "nginx-run"
empty_dir { medium = "Memory" }
}
volume {
name = "tmp-dir"
empty_dir { medium = "Memory" }
}
}
}
}
depends_on = [
kubernetes_deployment.auth_api
]
}
resource "kubernetes_service" "auth_ui" {
metadata {
name = "auth-ui-service"
labels = {
app = "auth-ui"
}
}
spec {
selector = {
app = "auth-ui"
}
type = "ClusterIP"
port {
port = 80
target_port = 80
name = "http"
}
}
}devops/network_policy.tf ← NOVO
Implementa Zero Trust networking — tudo bloqueado por padrão, só o que é explicitamente permitido funciona.
Nossa arquitetura Zero Trust após esta aula:
- Frontend → Backend (porta 3000): ✅ permitido
- Backend → MySQL (porta 3306): ✅ permitido
- Backend → Gmail SMTP (porta 587): ✅ permitido
- Frontend → MySQL diretamente: ❌ bloqueado
- Qualquer pod invasor → qualquer serviço: ❌ bloqueado
- default-deny-all — bloqueia TODO tráfego Ingress e Egress no namespace.
- backend-ingress-policy — o backend só aceita do frontend (porta 3000) ou do Ingress Controller.
- backend-egress-policy — o backend só pode sair para MySQL (3306), Gmail SMTP (587, 465) e DNS (53).
- frontend-policy — o frontend só aceita do Ingress Controller e só fala com o backend. Nunca acessa o MySQL diretamente.
- mysql-ingress-policy — o MySQL só aceita conexões do backend.
user-auth-system/devops/network_policy.tf
resource "kubernetes_network_policy" "default_deny_all" {
metadata {
name = "default-deny-all"
labels = local.common_labels
}
spec {
pod_selector {}
policy_types = ["Ingress", "Egress"]
}
}
resource "kubernetes_network_policy" "backend_ingress" {
metadata {
name = "backend-ingress-policy"
labels = local.common_labels
}
spec {
pod_selector {
match_labels = {
app = "auth-api"
}
}
policy_types = ["Ingress"]
ingress {
from {
pod_selector {
match_labels = {
app = "auth-ui"
}
}
}
ports {
port = "3000"
protocol = "TCP"
}
}
ingress {
from {
namespace_selector {
match_labels = {
"kubernetes.io/metadata.name" = "ingress-nginx"
}
}
}
ports {
port = "3000"
protocol = "TCP"
}
}
}
}
resource "kubernetes_network_policy" "backend_egress" {
metadata {
name = "backend-egress-policy"
labels = local.common_labels
}
spec {
pod_selector {
match_labels = {
app = "auth-api"
}
}
policy_types = ["Egress"]
egress {
to {
pod_selector {
match_labels = {
app = "mysql"
}
}
}
ports {
port = "3306"
protocol = "TCP"
}
}
egress {
ports {
port = "587"
protocol = "TCP"
}
}
egress {
ports {
port = "465"
protocol = "TCP"
}
}
egress {
ports {
port = "53"
protocol = "UDP"
}
}
egress {
ports {
port = "53"
protocol = "TCP"
}
}
}
}
resource "kubernetes_network_policy" "frontend_policy" {
metadata {
name = "frontend-policy"
labels = local.common_labels
}
spec {
pod_selector {
match_labels = {
app = "auth-ui"
}
}
policy_types = ["Ingress", "Egress"]
ingress {
from {
namespace_selector {
match_labels = {
"kubernetes.io/metadata.name" = "ingress-nginx"
}
}
}
ports {
port = "80"
protocol = "TCP"
}
}
egress {
to {
pod_selector {
match_labels = {
app = "auth-api"
}
}
}
ports {
port = "3000"
protocol = "TCP"
}
}
egress {
ports {
port = "53"
protocol = "UDP"
}
}
}
}
resource "kubernetes_network_policy" "mysql_ingress" {
metadata {
name = "mysql-ingress-policy"
labels = local.common_labels
}
spec {
pod_selector {
match_labels = {
app = "mysql"
}
}
policy_types = ["Ingress", "Egress"]
ingress {
from {
pod_selector {
match_labels = {
app = "auth-api"
}
}
}
ports {
port = "3306"
protocol = "TCP"
}
}
}
}devops/helm_ingress.tf ← MODIFICADO
O que mudou e por quê:
- namespace = "ingress-nginx" e create_namespace = true — o Ingress Controller agora roda em namespace dedicado, que é o padrão recomendado. Evita conflitos com recursos do namespace
default. - admissionWebhooks.enabled = false — o job de admission webhook falha em clusters novos porque tenta validar recursos antes do controller estar pronto. Desabilitar resolve o problema sem impacto funcional.
user-auth-system/devops/helm_ingress.tf
resource "helm_release" "ingress_nginx" {
name = "ingress-nginx"
repository = "https://kubernetes.github.io/ingress-nginx"
chart = "ingress-nginx"
namespace = "ingress-nginx"
create_namespace = true
timeout = 600
# Precisamos do Service tipo LoadBalancer
# para a DigitalOcean provisionar IP externo
set {
name = "controller.service.type"
value = "LoadBalancer"
}
# Publicar service do controller para relatarmos IP
set {
name = "controller.publishService.enabled"
value = "true"
}
# Desabilitar admission webhook que causa falha no job de pre-install
set {
name = "controller.admissionWebhooks.enabled"
value = "false"
}
}devops/terraform.tfvars.example ← MODIFICADO
O que mudou e por quê:
- Reflete todas as variáveis do
variables.tf. - O
cluster_versionatualizado para1.36.0-do.0— sempre verifique a versão mais recente comdoctl kubernetes options versions.
user-auth-system/devops/terraform.tfvars.example
# cp terraform.tfvars.example terraform.tfvars
# NUNCA commitar o terraform.tfvars
do_token = "SEU_TOKEN_DIGITALOCEAN"
do_region = "nyc1"
cluster_name = "meu-cluster"
cluster_version = "1.36.0-do.0"
namespace = "default"
environment = "production"
backend_image = "toticavalcanti/fiber-auth-api:v1.0"
frontend_image = "toticavalcanti/auth-ui:v1.1"
mysql_image = "mysql:8.0"
backend_replica_count = 1
mysql_database = "auth_db"
mysql_root_password = "SUBSTITUA_POR_SENHA_FORTE"
gmail_username = "SEU_EMAIL@gmail.com"
gmail_password = "SENHA_DE_APP_GMAIL"
react_app_api_url = "/api/"🧪 Parte 3 — Deploy Passo a Passo
Passo 1: Preparar o terraform.tfvars
🐧 Linux / Mac:
cd devops
cp terraform.tfvars.example terraform.tfvars
nano terraform.tfvars⊞ Windows (PowerShell):
cd devops
Copy-Item terraform.tfvars.example terraform.tfvars
notepad terraform.tfvars⊞ Windows (CMD):
cd devops
copy terraform.tfvars.example terraform.tfvars
notepad terraform.tfvarsPreencha com seus dados reais: do_token, cluster_version, mysql_root_password, gmail_username e gmail_password.
Passo 2: Inicializar o Terraform
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
terraform initPasso 3: Verificar o plano
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
terraform planPasso 4: Criar o cluster e fazer o deploy
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
terraform applyDigite yes quando solicitado. O processo leva cerca de 5 a 10 minutos.
Passo 5: Atualizar o kubeconfig
Após o terraform apply, o kubeconfig gerado automaticamente pode apontar para uma versão antiga do doctl. Execute este comando para garantir que está usando o doctl correto:
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
doctl kubernetes cluster kubeconfig save meu-cluster --set-current-contextPasso 6: Verificar o cluster e os pods
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
kubectl get nodes
kubectl get pods
kubectl get svc
kubectl get svc -n ingress-nginx
kubectl get networkpolicies
kubectl get serviceaccounts,roles,rolebindings
kubectl get secretsPasso 7: Aguardar todos os pods ficarem Ready
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
kubectl wait --for=condition=ready pod -l app=auth-api --timeout=120s
kubectl wait --for=condition=ready pod -l app=auth-ui --timeout=120s
kubectl wait --for=condition=ready pod -l app=mysql --timeout=120s🔒 Parte 4 — Testes de Segurança
✅ Teste 1: Fluxo legítimo funcionando
🐧 Linux / Mac:
curl http://SEU_IP_DO_INGRESS/api/health⊞ Windows (PowerShell):
Invoke-WebRequest -Uri "http://SEU_IP_DO_INGRESS/api/health" | Select-Object -ExpandProperty Content❌ Teste 2: Frontend não acessa MySQL (deve ser bloqueado)
O comando vai travar por timeout — isso confirma que a NetworkPolicy está bloqueando o acesso.
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
kubectl exec deployment/auth-ui -- nc -zv -w 5 mysql-service 3306❌ Teste 3: Pod invasor sem labels (deve ser bloqueado)
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
kubectl run invasor --image=busybox:1.36 --restart=Never -- sh -c "nc -zv -w 5 auth-api-service 3000 || echo 'ACESSO NEGADO'"
kubectl logs invasor
kubectl delete pod invasor🧪 Teste 4: RBAC em ação
🐧 Linux / Mac:
# Frontend NAO pode listar Secrets — esperado: no
kubectl auth can-i get secrets \
--as=system:serviceaccount:default:frontend-service-accountBackend PODE listar pods — esperado: yes
kubectl auth can-i list pods
--as=system:serviceaccount:default:backend-service-account
Backend NAO pode deletar pods — esperado: no
kubectl auth can-i delete pods
--as=system:serviceaccount:default:backend-service-account
⊞ Windows (PowerShell):
kubectl auth can-i get secrets `
--as=system:serviceaccount:default:frontend-service-accountkubectl auth can-i list pods `
--as=system:serviceaccount:default:backend-service-account
kubectl auth can-i delete pods `
--as=system:serviceaccount:default:backend-service-account
📊 Checklist de Segurança
- ☑️ RBAC: ServiceAccounts separadas com permissões mínimas
- ☑️ NetworkPolicy deny-all: tudo bloqueado por padrão
- ☑️ NetworkPolicy frontend→backend: frontend só acessa backend na porta 3000
- ☑️ NetworkPolicy backend→MySQL: só o backend acessa MySQL na porta 3306
- ☑️ NetworkPolicy backend→Gmail: backend pode sair na porta 587
- ☑️ SecurityContext: runAsNonRoot, readOnlyRootFilesystem, capabilities drop ALL (backend e frontend)
- ☑️ Resource limits: CPU e memória em todos os containers
- ☑️ Secrets: MySQL e Gmail via kubernetes_secret, nunca hardcoded
- ☑️ .gitignore: terraform.tfvars e *.tfstate protegidos
🚨 Troubleshooting
❓ kubectl não conecta após o terraform apply
O kubeconfig pode estar apontando para uma versão antiga do doctl. Execute:
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
doctl kubernetes cluster kubeconfig save meu-cluster --set-current-context❓ Backend não conecta ao MySQL após NetworkPolicy
🐧 Linux / Mac:
kubectl get pods --show-labels | grep auth-api⊞ Windows (PowerShell):
kubectl get pods --show-labels | Select-String "auth-api"❓ NetworkPolicy não está bloqueando
🐧 Linux / Mac:
kubectl get pods -n kube-system | grep cilium⊞ Windows (PowerShell):
kubectl get pods -n kube-system | Select-String "cilium"O DOKS usa Cilium como CNI desde 2023 — NetworkPolicies são suportadas nativamente.
✅ Conclusão
Parabéns! Você blindou o projeto fiber-auth-api + auth-ui com 5 camadas de segurança:
- 🔐 RBAC granular: identidades separadas, permissões mínimas
- 🏰 Zero Trust de rede: frontend não acessa MySQL, invasores bloqueados
- 🛡️ Containers endurecidos: não-root, filesystem readonly, capabilities removidas (backend e frontend)
- 🗝️ Secrets gerenciados: MySQL e Gmail via kubernetes_secret, nunca hardcoded
- ⚡ Resource limits: DoS prevenido em todos os containers
🚀 Próximos Passos
- Aula 16: Pod Security Standards (PSS/PSA)
- Aula 17: OPA Gatekeeper
- Aula 18: Falco — monitoramento em runtime
- Aula 19: cert-manager — TLS automático com Let's Encrypt
- Aula 20: Istio Service Mesh — mTLS automático
📚 Limpeza do Ambiente
🐧 Linux / Mac e ⊞ Windows (mesmo comando):
terraform destroyIsso destrói o cluster inteiro na DigitalOcean. Confirme com yes quando solicitado.