AWS

ECS from zero: a to-do app behind ALB, Cloudflare and RDS.

A Node to-do API in Docker on ECS Fargate, with TLS from the visitor to the database, a deploy on every push, and every step explained for someone who has only used EC2 by hand.

O fluxo completoThe full flow

Este roteiro leva você de uma conta AWS vazia até um git push que publica uma API de to-do no ECS. O projeto é de propósito pequeno: um servidor Node nativo, um Postgres com uma tabela e quatro rotas. O que importa aqui é a infraestrutura em volta, e ela é a mesma de um projeto grande.This walkthrough takes you from an empty AWS account to a git push that publishes a to-do API on ECS. The project is small on purpose: a plain Node server, a Postgres database with one table and four routes. What matters is the infrastructure around it, and that is the same as in a large project.

sua conta AWS: VPC com subnets públicas e privadasyour AWS account: VPC with public and private subnets VisitanteVisitor navegadorbrowser CloudflareCloudflare proxy · Full (strict)proxy · Full (strict) ALB :443ALB :443 subnets públicaspublic subnets Task :8443Task :8443 Fargate · privadaFargate · private RDS :5432RDS :5432 Postgres · privadaPostgres · private 1 2 3 4 1 certificado da Cloudflare (Universal SSL), automático1 Cloudflare certificate (Universal SSL), automatic 2 certificado de origem da Cloudflare, importado no ACM e usado pelo listener do ALB2 Cloudflare origin certificate, imported into ACM and used by the ALB listener 3 o mesmo certificado, guardado no Secrets Manager e usado pelo Node dentro da task3 the same certificate, kept in Secrets Manager and used by Node inside the task 4 certificado do RDS, verificado pela aplicação com a CA da AWS (verify-full)4 the RDS certificate, verified by the application against the AWS CA (verify-full)
Quatro conexões, todas criptografadas. Nada trafega em texto puro, nem dentro da VPC.Four connections, all encrypted. Nothing travels in clear text, not even inside the VPC.

Você já fez à mão o equivalente na EC2: VPC, ALB com certificado da Cloudflare, Docker, security groups, internet gateway. O ECS troca só a parte do meio. Em vez de você acessar a máquina para rodar docker run, você descreve o container num arquivo e o ECS roda e mantém no ar. O resto (rede, ALB, certificado) segue o que você já conhece.You have already done the EC2 equivalent by hand: VPC, ALB with a Cloudflare certificate, Docker, security groups, internet gateway. ECS only replaces the middle. Instead of logging in to a machine to run docker run, you describe the container in a file and ECS runs it and keeps it up. The rest (network, ALB, certificate) is what you already know.

  1. Fundamentos (seções 1 e 2). O que é cada peça do ECS e o código do app.Foundations (sections 1 and 2). What each ECS piece is, and the app code.
  2. Infraestrutura (3 a 6). Rede, SSL ponta a ponta, banco, segredos e roles. Feito uma vez por conta.Infrastructure (3 to 6). Network, end-to-end SSL, database, secrets and roles. Done once per account.
  3. ECS (7). Cluster, task definition, target group, ALB e service. Aqui o app sobe pela primeira vez.ECS (7). Cluster, task definition, target group, ALB and service. This is where the app first comes up.
  4. Deploy automático (8). Liga o que você criou ao workflow da nota de OIDC. A partir daí, cada push troca a task.Automatic deploy (8). Connects what you built to the workflow in the OIDC note. From then on, every push replaces the task.
Custo. ALB, RDS, Fargate e endpoints de interface cobram por hora enquanto existirem, mesmo sem tráfego. Ao terminar de estudar, apague tudo na ordem da seção 11. Consulte os preços atuais na página de preços de cada serviço.Cost. The ALB, RDS, Fargate and interface endpoints bill by the hour while they exist, even without traffic. When you finish studying, delete everything in the order of section 11. Check current prices on each service’s pricing page.
Antes de ler. Esta nota não repete o que já está nas outras: contas e roles na nota de AWS, domínio e certificado de origem na nota da Cloudflare, e o OIDC com o workflow na nota de GitHub para AWS. Ela mostra como as peças se encaixam num projeto.Before you read. This note does not repeat what the others already cover: accounts and roles in the AWS note, domain and origin certificate in the Cloudflare note, and OIDC with the workflow in the GitHub to AWS note. This one shows how the pieces fit into a project.

1. ECS para quem vem da EC21. ECS for people coming from EC2

O ECS (Elastic Container Service) é o orquestrador de containers da AWS. Ele tem poucos conceitos, e quase todos têm um equivalente no que você fez à mão:ECS (Elastic Container Service) is AWS’s container orchestrator. It has few concepts, and almost all of them have an equivalent in what you did by hand:

O que você fez na EC2What you did on EC2No ECSIn ECS
Subir a instância e instalar o DockerLaunch the instance and install DockerFargate: a AWS fornece a capacidade de computação. Não há instância para você acessar, atualizar ou proteger com SSH.Fargate: AWS provides the compute. There is no instance for you to log in to, patch or secure with SSH.
docker run -p ... -e ...docker run -p ... -e ...Task definition: um JSON com imagem, portas, variáveis, segredos, CPU, memória e logs. É a “receita” e é versionada (revisão 1, 2, 3...).Task definition: a JSON with image, ports, variables, secrets, CPU, memory and logs. It is the “recipe” and it is versioned (revision 1, 2, 3...).
O container em execuçãoThe running containerTask: uma execução da task definition. É descartável: se morrer, o service cria outra.Task: one run of a task definition. It is disposable: if it dies, the service creates another.
Um script ou systemd para manter no ar e reiniciarA script or systemd to keep it up and restart itService: mantém N tasks rodando, repõe as que caem, registra no ALB e faz a troca a cada deploy.Service: keeps N tasks running, replaces the ones that die, registers them with the ALB and does the swap on each deploy.
O conjunto das suas instânciasThe set of your instancesCluster: só um agrupamento lógico de services e tasks.Cluster: just a logical grouping of services and tasks.
Target group por instância e portaTarget group by instance and portTarget group do tipo ip. No modo de rede awsvpc cada task ganha a sua própria interface de rede (ENI) e IP; a documentação exige ip, não instance.Target group of type ip. In awsvpc network mode every task gets its own network interface (ENI) and IP; the docs require ip, not instance.
Security group da instânciaThe instance’s security groupSecurity group da task (fica na ENI dela). Dá para ter uma regra por serviço, não por máquina.The task’s security group (it sits on its ENI). You can have one rule set per service, not per machine.
docker pull com login no ECRdocker pull with an ECR loginA task execution role puxa a imagem do ECR e lê os segredos antes de o container iniciar.The task execution role pulls the image from ECR and reads the secrets before the container starts.
Arquivo .env ou variáveis na máquina.env file or variables on the machineenvironment (valores comuns) e secrets (vêm do Secrets Manager) na task definition.environment (plain values) and secrets (come from Secrets Manager) in the task definition.
ssh + docker logsssh + docker logsO driver awslogs envia o stdout do container para o CloudWatch Logs.The awslogs driver sends the container’s stdout to CloudWatch Logs.

Duas roles, dois papéisTwo roles, two jobs

RoleRoleQuem usaWho uses itPara quêWhat for
Task execution roleTask execution roleO agente do ECS/Fargate, antes do container iniciarThe ECS/Fargate agent, before the container startsPuxar a imagem do ECR, escrever logs, ler os segredos do secrets. A documentação diz que essas permissões não ficam acessíveis aos containers.Pull the image from ECR, write logs, read the secrets. The docs say these permissions are not directly accessible to the containers.
Task roleTask roleO seu código, dentro do containerYour code, inside the containerChamar APIs da AWS (S3, SQS...). Este app não chama nenhuma, então a role fica sem permissões. Mantê-la separada permite dar acesso depois sem mexer na execução.Call AWS APIs (S3, SQS...). This app calls none, so the role has no permissions. Keeping it separate lets you grant access later without touching execution.

O ECS também roda em instâncias EC2 suas (“launch type EC2”). Aqui usamos Fargate para não ter servidor para manter. O resto da nota vale para os dois, exceto a parte da máquina.ECS can also run on your own EC2 instances (“EC2 launch type”). Here we use Fargate so there is no server to maintain. The rest of the note applies to both, except the machine part.

Importante para a arquitetura da imagem. A imagem precisa ser da mesma arquitetura da task (X86_64 por padrão). O runner ubuntu-latest do GitHub é x86_64, então o workflow da nota de OIDC já gera uma imagem compatível. Se um dia você buildar num Mac com Apple Silicon, use docker build --platform linux/amd64.Image architecture matters. The image must match the task’s architecture (X86_64 by default). GitHub’s ubuntu-latest runner is x86_64, so the OIDC note’s workflow already produces a compatible image. If you ever build on an Apple Silicon Mac, use docker build --platform linux/amd64.

2. O projeto de exemplo: uma API de to-do2. The example project: a to-do API

Quatro arquivos na raiz do repositório. O servidor usa os módulos http e https do Node; a única dependência é o driver pg para falar com o Postgres. Testei este código e esta imagem localmente (Docker, Postgres 16 e um certificado autoassinado): criar, listar, marcar, apagar, validação de entrada, health check, filesystem somente leitura e parada limpa com SIGTERM.Four files at the repository root. The server uses Node’s http and https modules; the only dependency is the pg driver to talk to Postgres. I ran this code and image locally (Docker, Postgres 16 and a self-signed certificate): create, list, toggle, delete, input validation, health check, read-only filesystem and a clean stop on SIGTERM.

server.jsserver.js

'use strict';

const http = require('node:http');
const https = require('node:https');
const fs = require('node:fs');
const { Pool } = require('pg');

const PORT = Number(process.env.PORT || 8443);
const MAX_BODY = 10 * 1024;

// TLS_CERT and TLS_KEY come from Secrets Manager (injected by ECS).
// Without them the server speaks plain HTTP (variant B in the note).
const tls = process.env.TLS_CERT && process.env.TLS_KEY
  ? { cert: process.env.TLS_CERT, key: process.env.TLS_KEY }
  : null;

const pool = new Pool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT || 5432),
  database: process.env.DB_NAME || 'todos',
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  // Verify the RDS certificate chain and host name (sslmode=verify-full).
  ssl: process.env.DB_SSL === 'off'
    ? false
    : { ca: fs.readFileSync(process.env.DB_CA_FILE || '/app/rds-global-bundle.pem', 'utf8') },
  max: 5,
  connectionTimeoutMillis: 5000,
});

function send(res, status, body) {
  const payload = body === undefined ? '' : JSON.stringify(body);
  res.writeHead(status, { 'content-type': 'application/json' });
  res.end(payload);
}

function readJson(req) {
  return new Promise((resolve, reject) => {
    let size = 0;
    const chunks = [];
    req.on('data', (c) => {
      size += c.length;
      if (size > MAX_BODY) { reject(Object.assign(new Error('too large'), { status: 413 })); req.destroy(); return; }
      chunks.push(c);
    });
    req.on('end', () => {
      try { resolve(JSON.parse(Buffer.concat(chunks).toString() || '{}')); }
      catch { reject(Object.assign(new Error('invalid json'), { status: 400 })); }
    });
    req.on('error', reject);
  });
}

async function handle(req, res) {
  const url = new URL(req.url, 'http://localhost');
  const m = url.pathname.match(/^\/todos(?:\/(\d+))?$/);

  // Shallow on purpose: it answers "is the process up", not "is the database up".
  if (url.pathname === '/health' && req.method === 'GET') return send(res, 200, { ok: true });

  if (m && !m[1] && req.method === 'GET') {
    const { rows } = await pool.query('SELECT id, title, done, created_at FROM todos ORDER BY id');
    return send(res, 200, rows);
  }
  if (m && !m[1] && req.method === 'POST') {
    const { title } = await readJson(req);
    if (typeof title !== 'string' || !title.trim() || title.length > 200) return send(res, 400, { error: 'title: 1 to 200 characters' });
    const { rows } = await pool.query('INSERT INTO todos (title) VALUES ($1) RETURNING id, title, done, created_at', [title.trim()]);
    return send(res, 201, rows[0]);
  }
  if (m && m[1] && req.method === 'PATCH') {
    const { done } = await readJson(req);
    if (typeof done !== 'boolean') return send(res, 400, { error: 'done: boolean' });
    const { rows } = await pool.query('UPDATE todos SET done = $1 WHERE id = $2 RETURNING id, title, done, created_at', [done, m[1]]);
    return rows[0] ? send(res, 200, rows[0]) : send(res, 404, { error: 'not found' });
  }
  if (m && m[1] && req.method === 'DELETE') {
    const { rowCount } = await pool.query('DELETE FROM todos WHERE id = $1', [m[1]]);
    return rowCount ? send(res, 204) : send(res, 404, { error: 'not found' });
  }
  return send(res, 404, { error: 'not found' });
}

const onRequest = (req, res) => {
  handle(req, res).catch((err) => {
    if (err.status) return send(res, err.status, { error: err.message });
    console.error(err);
    send(res, 500, { error: 'internal error' });
  });
};

async function main() {
  // Demo-only migration. Idempotent, so two tasks starting together do not conflict.
  await pool.query(`CREATE TABLE IF NOT EXISTS todos (
    id         integer     GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title      text        NOT NULL,
    done       boolean     NOT NULL DEFAULT false,
    created_at timestamptz NOT NULL DEFAULT now()
  )`);

  const server = tls ? https.createServer(tls, onRequest) : http.createServer(onRequest);
  server.listen(PORT, () => console.log(`listening on ${PORT} (${tls ? 'https' : 'http'})`));

  // ECS sends SIGTERM, then SIGKILL after stopTimeout (30 s by default).
  const shutdown = () => {
    server.close(async () => { await pool.end(); process.exit(0); });
    server.closeIdleConnections();
  };
  process.on('SIGTERM', shutdown);
  process.on('SIGINT', shutdown);
}

main().catch((err) => { console.error(err); process.exit(1); });
  • TLS na própria aplicação. Se TLS_CERT e TLS_KEY existirem, o servidor sobe em HTTPS; se não, em HTTP. É isso que permite a variante B sem trocar a imagem.TLS in the application itself. If TLS_CERT and TLS_KEY exist, the server starts on HTTPS; otherwise on HTTP. That is what makes variant B possible without changing the image.
  • Banco com verificação. O ssl.ca carrega o bundle de CAs da AWS; com isso o driver valida a cadeia e o nome do host do RDS. Sem ca, o pg não confia no certificado do RDS.Verified database connection. ssl.ca loads the AWS CA bundle; the driver then validates the RDS chain and host name. Without ca, pg does not trust the RDS certificate.
  • /health é rasa de propósito. Ela só diz que o processo responde. Se checasse o banco, uma queda do RDS faria o ECS matar e recriar todas as tasks sem resolver nada./health is shallow on purpose. It only says the process answers. If it checked the database, an RDS outage would make ECS kill and recreate every task without fixing anything.
  • Parada limpa. O ECS manda SIGTERM e, se o processo não sair, SIGKILL depois do stopTimeout (30 s por padrão, máximo 120 s). O handler deixa terminar as requisições em andamento.Clean stop. ECS sends SIGTERM and, if the process does not exit, SIGKILL after stopTimeout (30 s by default, 120 s maximum). The handler lets in-flight requests finish.
  • Migração no start. O CREATE TABLE IF NOT EXISTS serve para o exemplo e é seguro com duas tasks iniciando juntas. Em um projeto real, rode migrações como uma etapa separada do deploy.Migration at start. CREATE TABLE IF NOT EXISTS is fine for an example and safe with two tasks starting together. In a real project, run migrations as a separate deploy step.

package.json e Dockerfilepackage.json and Dockerfile

{
  "name": "todo-api",
  "version": "1.0.0",
  "private": true,
  "main": "server.js",
  "engines": { "node": ">=22" },
  "dependencies": { "pg": "^8.13.0" }
}
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
# CA bundle used to verify the RDS certificate (global bundle, AWS docs).
ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem /app/rds-global-bundle.pem
COPY --from=deps /app/node_modules ./node_modules
COPY package.json server.js ./
USER node
EXPOSE 8443
CMD ["node", "server.js"]

Rode npm install uma vez para gerar o package-lock.json e versione-o: o npm ci do Dockerfile exige esse arquivo. O Dockerfile usa dois estágios, roda como o usuário node (não como root) e baixa o bundle de CAs do RDS da URL oficial. Crie também um .dockerignore para que chaves e node_modules não entrem na imagem:Run npm install once to generate package-lock.json and commit it: the Dockerfile’s npm ci requires it. The Dockerfile uses two stages, runs as the node user (not root) and downloads the RDS CA bundle from the official URL. Also create a .dockerignore so keys and node_modules stay out of the image:

node_modules
.git
.github
*.pem
*.key
.env

Testar localmenteTest locally

docker build -t todo .
docker network create tnet
docker run -d --name pg --network tnet -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=todos postgres:16-alpine
# local Postgres has no TLS, so turn the DB check off (never do this in the cloud)
docker run -d --name app --network tnet -p 8443:8443 \
  -e DB_HOST=pg -e DB_USER=postgres -e DB_PASSWORD=pw -e DB_SSL=off todo
curl -s -X POST localhost:8443/todos -d '{"title":"buy milk"}'
curl -s localhost:8443/todos

3. Rede: VPC, subnets, endpoints e security groups3. Network: VPC, subnets, endpoints and security groups

Você já montou uma VPC com ALB e EC2. A diferença aqui é onde cada peça mora: o ALB fica em subnets públicas; as tasks e o banco ficam em subnets privadas, sem IP público e sem rota para o internet gateway. A task só aceita tráfego do ALB e só fala com o banco e com a AWS.You already built a VPC with an ALB and EC2. What differs here is where each piece lives: the ALB sits in public subnets; tasks and the database sit in private subnets, with no public IP and no route to the internet gateway. The task only accepts traffic from the ALB and only talks to the database and to AWS.

VPC 10.0.0.0/16VPC 10.0.0.0/16 InternetInternet só IPs CloudflareCloudflare IPs only ALBALB sg-alb · públicasg-alb · public Tasks FargateFargate tasks sg-app · privadasg-app · private RDSRDS sg-db · privadasg-db · private 443 8443 5432 VPC endpoints (sg-vpce, porta 443)VPC endpoints (sg-vpce, port 443) ecr.api · ecr.dkr · logs · secretsmanager s3 (gateway)
Quem pode falar com quem. Cada seta é uma regra de entrada que aponta para o SG de origem, não para um CIDR. A task não tem IP público: alcança ECR, logs e segredos pelos endpoints.Who may talk to whom. Each arrow is an inbound rule pointing to the source SG, not a CIDR. The task has no public IP: it reaches ECR, logs and secrets through the endpoints.

3.1 VPC e subnets3.1 VPC and subnets

  1. Console: VPC → Create VPC → VPC and more. Nome todo, IPv4 10.0.0.0/16, 2 Availability Zones, 2 subnets públicas, 2 privadas.Console: VPC → Create VPC → VPC and more. Name todo, IPv4 10.0.0.0/16, 2 Availability Zones, 2 public subnets, 2 private.
  2. NAT gateways: None e VPC endpoints: S3 Gateway. Mantenha ativos Enable DNS hostnames e Enable DNS resolution (os endpoints de interface usam DNS privado).NAT gateways: None and VPC endpoints: S3 Gateway. Keep Enable DNS hostnames and Enable DNS resolution on (interface endpoints use private DNS).
  3. O assistente cria o internet gateway, a tabela de rotas das públicas (0.0.0.0/0 → IGW) e as das privadas (sem rota para a internet, só o endpoint do S3). Os detalhes de VPC, rotas e IGW estão na nota de AWS.The wizard creates the internet gateway, the public route table (0.0.0.0/0 → IGW) and the private ones (no internet route, only the S3 endpoint). VPC, route and IGW details are in the AWS note.

Para simplificar, o RDS reaproveita as subnets privadas das tasks. Em produção, é comum uma camada separada só para dados.To keep it simple, RDS reuses the tasks’ private subnets. In production a separate data tier is common.

REGION=sa-east-1
ACCOUNT_ID=<ACCOUNT_ID>        # the account of this environment (dev or prod)
export AWS_PROFILE=dev         # one CLI profile per account
DOMAIN=todo.example.com        # the hostname that Cloudflare will serve
VPC_ID=<vpc-id>; PUB_A=<subnet-public-a>; PUB_B=<subnet-public-b>; PRIV_A=<subnet-private-a>; PRIV_B=<subnet-private-b>

3.2 Security groups encadeados3.2 Chained security groups

Quatro grupos. Cada regra de entrada aponta para o grupo de origem, não para um CIDR: se as tasks mudarem de IP, nada precisa ser editado. É o mesmo encadeamento da nota de AWS.Four groups. Each inbound rule points to the source group, not a CIDR: if the tasks change IP, nothing needs editing. It is the same chaining as in the AWS note.

GrupoGroupEntrada permitidaAllowed inboundPor quêWhy
sg-albsg-alb443 dos IPs da Cloudflare443 from Cloudflare’s IPsNinguém além da Cloudflare chega ao ALB. Quem tentar o DNS do ALB direto não conecta.Nobody but Cloudflare reaches the ALB. Anyone trying the ALB’s DNS name directly cannot connect.
sg-appsg-app8443 de sg-alb8443 from sg-albSó o ALB fala com a task.Only the ALB talks to the task.
sg-dbsg-db5432 de sg-app5432 from sg-appSó a aplicação fala com o banco.Only the application talks to the database.
sg-vpcesg-vpce443 de sg-app443 from sg-appOs endpoints de interface só atendem as tasks. A documentação exige 443 de entrada a partir das subnets privadas.The interface endpoints only serve the tasks. The docs require inbound 443 from the private subnets.
mksg() { aws ec2 create-security-group --group-name "$1" --description "$1" --vpc-id $VPC_ID --query GroupId --output text; }
SG_ALB=$(mksg todo-alb); SG_APP=$(mksg todo-app); SG_DB=$(mksg todo-db); SG_VPCE=$(mksg todo-vpce)

allow() { aws ec2 authorize-security-group-ingress --group-id "$1" --protocol tcp --port "$2" --source-group "$3"; }
allow $SG_APP 8443 $SG_ALB
allow $SG_DB 5432 $SG_APP
allow $SG_VPCE 443 $SG_APP

3.3 Só a Cloudflare chega ao ALB3.3 Only Cloudflare reaches the ALB

A Cloudflare publica os endereços de onde conecta à sua origem (ips-v4, ips-v6), e a documentação recomenda descartar o tráfego web que não venha deles. Em vez de copiar 15 regras para o security group, use uma prefix list (veja Prefix Lists na nota de AWS). Este exemplo cobre IPv4; o ALB padrão é IPv4 e a Cloudflare consegue alcançá-lo assim.Cloudflare publishes the addresses it connects to your origin from (ips-v4, ips-v6), and its docs recommend dropping web traffic that does not come from them. Instead of copying 15 rules into the security group, use a prefix list (see Prefix Lists in the AWS note). This example covers IPv4; the default ALB is IPv4 and Cloudflare can reach it that way.

PL_CF=$(aws ec2 create-managed-prefix-list --prefix-list-name cloudflare-ipv4 \
  --address-family IPv4 --max-entries 30 \
  --entries $(curl -s https://www.cloudflare.com/ips-v4 | sed 's/^/Cidr=/') \
  --query PrefixList.PrefixListId --output text)

aws ec2 authorize-security-group-ingress --group-id $SG_ALB \
  --ip-permissions "IpProtocol=tcp,FromPort=443,ToPort=443,PrefixListIds=[{PrefixListId=$PL_CF}]"
A lista da Cloudflare muda de vez em quando. Revise a prefix list periodicamente (modify-managed-prefix-list) ou automatize com um job agendado. Além disso, a documentação avisa: a Cloudflare usa 172.64.0.0/13 para se conectar, e uma rota mais ampla que aponte para um destino interno (por exemplo, 172.16.0.0/12 ou maior) causa erros 521/522.Cloudflare’s list changes now and then. Review the prefix list periodically (modify-managed-prefix-list) or automate it with a scheduled job. Also, the docs warn: Cloudflare connects from 172.64.0.0/13, and a broader route that points to an internal target (for example 172.16.0.0/12 or larger) causes 521/522 errors.

3.4 Como a task alcança a AWS sem internet3.4 How the task reaches AWS without internet

Uma task Fargate numa subnet privada precisa falar com quatro serviços antes e durante a execução. Sem NAT, cada um ganha um endpoint dentro da sua VPC. Segundo as documentações do ECS e do ECR (Fargate com plataforma 1.4.0 ou mais nova):A Fargate task in a private subnet needs to talk to four services before and during its run. Without NAT, each gets an endpoint inside your VPC. According to the ECS and ECR docs (Fargate platform 1.4.0 or newer):

Para quêWhat forEndpointEndpointTipoType
Autenticar e consultar o ECRAuthenticate to and query ECRecr.apiecr.apiInterfaceInterface
Baixar a imagem (protocolo Docker)Download the image (Docker protocol)ecr.dkr (com DNS privado ligado)ecr.dkr (with private DNS on)InterfaceInterface
Baixar as camadas da imagem, que o ECR guarda no S3Download image layers, which ECR stores in S3s3s3Gateway (o assistente já criou)Gateway (the wizard already created it)
Enviar logs ao CloudWatchSend logs to CloudWatchlogslogsInterfaceInterface
Ler segredos antes de iniciarRead secrets before startingsecretsmanagersecretsmanagerInterfaceInterface
for SVC in ecr.api ecr.dkr logs secretsmanager; do
  aws ec2 create-vpc-endpoint --vpc-endpoint-type Interface --vpc-id $VPC_ID \
    --service-name com.amazonaws.$REGION.$SVC \
    --subnet-ids $PRIV_A $PRIV_B --security-group-ids $SG_VPCE --private-dns-enabled
done

Alternativa: um NAT gateway numa subnet pública e uma rota 0.0.0.0/0 → NAT nas privadas. É mais simples de configurar, mas todo tráfego de saída passa por um dispositivo na internet pública e o NAT cobra por hora e por GB. Para uma API que só fala com serviços da AWS, os endpoints mantêm o tráfego dentro da rede da Amazon.Alternative: a NAT gateway in a public subnet and a 0.0.0.0/0 → NAT route in the private ones. It is simpler to set up, but all outbound traffic goes through a device on the public internet, and the NAT bills per hour and per GB. For an API that only talks to AWS services, endpoints keep traffic inside Amazon’s network.

Se sua aplicação chamar uma API externa (pagamento, e-mail), a task precisará de NAT além dos endpoints. Sem saída para a internet, a chamada simplesmente expira.If your application calls an external API (payments, email), the task needs a NAT in addition to the endpoints. Without an outbound path to the internet, the call just times out.

4. SSL ponta a ponta4. End-to-end SSL

Voltando à figura do início: são quatro conexões e cada uma tem o seu certificado. O trecho 1 a Cloudflare resolve sozinha. O trecho 4 já está na seção 5 (RDS). Esta seção cuida dos trechos 2 e 3, que usam o mesmo certificado de origem da Cloudflare em dois lugares.Back to the figure at the top: there are four connections and each has its own certificate. Cloudflare handles leg 1 by itself. Leg 4 is in section 5 (RDS). This section covers legs 2 and 3, which use the same Cloudflare origin certificate in two places.

TrechoLegTermina emTerminates atCertificadoCertificateQuem validaWho validates
1 · visitante → Cloudflare1 · visitor → CloudflareCloudflareCloudflareUniversal SSL da CloudflareCloudflare’s Universal SSLO navegadorThe browser
2 · Cloudflare → ALB2 · Cloudflare → ALBListener 443 do ALBALB 443 listenerOrigem da Cloudflare, importado no ACMCloudflare origin, imported into ACMA Cloudflare (Full strict)Cloudflare (Full strict)
3 · ALB → task3 · ALB → taskNode, dentro do containerNode, inside the containerO mesmo, vindo do Secrets ManagerThe same one, from Secrets ManagerNinguém (veja abaixo)Nobody (see below)
4 · task → RDS4 · task → RDSPostgreSQL no RDSPostgreSQL on RDSCA do RDSRDS CAO driver pg (verify-full)The pg driver (verify-full)
O que o trecho 3 protege (e o que não). A documentação do ALB diz que ele não valida o certificado dos targets: aceita autoassinado ou expirado, porque o tráfego dentro da VPC é autenticado no nível do pacote, sem risco de man-in-the-middle ou falsificação. Então o HTTPS até a task não serve para provar identidade; ele serve para confidencialidade: ninguém que consiga observar o tráfego da VPC vê o conteúdo. É uma defesa em profundidade e muitas vezes uma exigência de conformidade. Se você não precisa disso, a variante B é mais simples.What leg 3 protects (and what it does not). The ALB docs say it does not validate target certificates: self-signed or expired ones work, because traffic inside the VPC is authenticated at the packet level, with no man-in-the-middle or spoofing risk. So HTTPS to the task is not about proving identity; it is about confidentiality: nobody who can observe VPC traffic sees the content. It is defense in depth and often a compliance requirement. If you do not need that, variant B is simpler.

4.1 Cloudflare: modo Full (strict) e certificado de origem4.1 Cloudflare: Full (strict) mode and origin certificate

  1. Dashboard da Cloudflare → seu domínio → SSL/TLS → Overview → modo Full (strict). Nesse modo a Cloudflare só conecta à origem se o certificado for válido para o hostname e emitido por uma CA pública ou pela Origin CA da Cloudflare.Cloudflare dashboard → your domain → SSL/TLS → Overview → Full (strict) mode. In this mode Cloudflare only connects to the origin if the certificate is valid for the hostname and issued by a public CA or Cloudflare’s Origin CA.
  2. SSL/TLS → Origin Server → Create Certificate. Deixe a Cloudflare gerar a chave privada (RSA 2048 é suficiente), liste o hostname (todo.example.com) e escolha a validade.SSL/TLS → Origin Server → Create Certificate. Let Cloudflare generate the private key (RSA 2048 is enough), list the hostname (todo.example.com) and pick the validity.
  3. Copie o certificado para origin.pem e a chave para origin.key antes de sair da tela: a documentação avisa que a chave não pode ser vista depois.Copy the certificate into origin.pem and the key into origin.key before leaving the screen: the docs warn the key cannot be viewed afterwards.

O passo a passo completo, com capturas do modo e das opções, está na nota da Cloudflare. Esse certificado só é confiável para a Cloudflare: um navegador que falasse direto com o ALB o rejeitaria, o que é mais um motivo para o sg-alb aceitar só os IPs dela.The full walkthrough, with the mode and option screens, is in the Cloudflare note. This certificate is only trusted by Cloudflare: a browser talking to the ALB directly would reject it, one more reason for sg-alb to accept only its IPs.

4.2 Trecho 2: importar no ACM (para o ALB)4.2 Leg 2: import into ACM (for the ALB)

O ACM aceita certificados importados em PEM, com chave privada sem senha e de até 5 KB; RSA 2048 e ECDSA entre os suportados, e o ALB aceita todos os algoritmos do ACM.ACM accepts imported certificates in PEM, with an unencrypted private key of at most 5 KB; RSA 2048 and ECDSA are among the supported ones, and the ALB supports every ACM algorithm.

CERT_ARN=$(aws acm import-certificate \
  --certificate fileb://origin.pem --private-key fileb://origin.key \
  --query CertificateArn --output text)
echo $CERT_ARN

4.3 Trecho 3: guardar no Secrets Manager (para o Node)4.3 Leg 3: store in Secrets Manager (for Node)

O ACM não entrega a chave privada de volta, e a chave nunca deve entrar na imagem. Por isso a mesma chave vai para um segredo, e o ECS a injeta como variável de ambiente TLS_KEY quando a task inicia (é o que o server.js lê). O jq monta o JSON e cuida das quebras de linha do PEM:ACM does not hand the private key back, and the key must never go into the image. So the same key goes into a secret, and ECS injects it as the TLS_KEY environment variable when the task starts (that is what server.js reads). jq builds the JSON and handles the PEM line breaks:

aws secretsmanager create-secret --name todo/tls --description "Cloudflare origin cert for the todo app" \
  --secret-string "$(jq -n --rawfile c origin.pem --rawfile k origin.key '{tls_cert:$c, tls_key:$k}')"

rm origin.key   # keep the only other copy in your password manager, if you want one

4.4 Renovar sem derrubar nada4.4 Renewing without downtime

  • A Cloudflare não avisa quando o certificado de origem vai vencer (a documentação diz isso), e certificados importados no ACM não renovam sozinhos. Crie um lembrete no calendário com bastante antecedência.Cloudflare does not warn you before an origin certificate expires (its docs say so), and certificates imported into ACM do not renew by themselves. Put a reminder in your calendar well in advance.
  • Os segredos só são lidos quando a task inicia: a documentação do ECS avisa que, se o segredo mudar, o container não recebe o valor novo sozinho. É preciso forçar um deploy novo.Secrets are only read when the task starts: the ECS docs warn that if the secret changes, the container does not get the new value on its own. You have to force a new deployment.
# 1) create a new origin certificate in Cloudflare and save origin.pem / origin.key
# 2) re-import over the same ARN, so the listener keeps working
aws acm import-certificate --certificate-arn $CERT_ARN \
  --certificate fileb://origin.pem --private-key fileb://origin.key
# 3) update the secret
aws secretsmanager put-secret-value --secret-id todo/tls \
  --secret-string "$(jq -n --rawfile c origin.pem --rawfile k origin.key '{tls_cert:$c, tls_key:$k}')"
# 4) restart the tasks, one by one, with the rolling update
aws ecs update-service --cluster todo --service todo --force-new-deployment
Dica de segurança: o certificado de origem valer por muito tempo é conveniente, mas o risco é a chave vazar e ficar válida por anos. Escolha uma validade que você consiga renovar com disciplina.Security tip: a long-lived origin certificate is convenient, but the risk is a leaked key staying valid for years. Pick a validity you can renew with discipline.

5. Banco de dados: RDS e segredos5. Database: RDS and secrets

O RDS Postgres vai nas subnets privadas, sem acesso público, criptografado em repouso, e só aceita a porta 5432 vinda do sg-app. A senha é gerada na hora, vai direto para o Secrets Manager e nunca aparece no terminal.RDS Postgres goes in the private subnets, with no public access, encrypted at rest, and only accepts port 5432 from sg-app. The password is generated on the spot, goes straight into Secrets Manager and never shows on the terminal.

aws rds create-db-subnet-group --db-subnet-group-name todo \
  --db-subnet-group-description "todo private subnets" --subnet-ids $PRIV_A $PRIV_B

DB_PASS=$(aws secretsmanager get-random-password --password-length 32 --exclude-punctuation \
  --query RandomPassword --output text)

aws secretsmanager create-secret --name todo/db --description "todo app database login" \
  --secret-string "$(jq -n --arg u todo_admin --arg p "$DB_PASS" '{username:$u, password:$p}')"

aws rds create-db-instance --db-instance-identifier todo-db \
  --engine postgres --db-instance-class db.t4g.micro --allocated-storage 20 \
  --db-name todos --master-username todo_admin --master-user-password "$DB_PASS" \
  --db-subnet-group-name todo --vpc-security-group-ids $SG_DB \
  --no-publicly-accessible --storage-encrypted --backup-retention-period 7
unset DB_PASS

aws rds wait db-instance-available --db-instance-identifier todo-db
DB_HOST=$(aws rds describe-db-instances --db-instance-identifier todo-db \
  --query 'DBInstances[0].Endpoint.Address' --output text)
echo $DB_HOST

SSL até o banco (trecho 4)SSL to the database (leg 4)

  • Exigir SSL no servidor. Segundo a documentação do RDS, o parâmetro rds.force_ssl vem ligado (1) por padrão no PostgreSQL 15 ou mais novo; nas versões 14 e anteriores o padrão é 0 e você precisa de um parameter group próprio. Confira a versão com aws rds describe-db-instances --query "DBInstances[0].EngineVersion".Require SSL on the server. Per the RDS docs, the rds.force_ssl parameter is on (1) by default on PostgreSQL 15 or newer; on 14 and older the default is 0 and you need your own parameter group. Check the version with aws rds describe-db-instances --query "DBInstances[0].EngineVersion".
  • Verificar o servidor no cliente. Cliente que só “usa SSL” não sabe com quem fala. Com sslmode=verify-full (o que o ssl.ca do server.js faz), o driver confere a cadeia e o nome do host. Para isso ele precisa da CA da AWS, que o Dockerfile baixa do truststore.pki.rds.amazonaws.com.Verify the server on the client. A client that merely “uses SSL” does not know who it talks to. With sslmode=verify-full (what ssl.ca in server.js does), the driver checks the chain and host name. It needs AWS’s CA for that, which the Dockerfile downloads from truststore.pki.rds.amazonaws.com.
  • CA do bundle. O bundle global inclui as CAs rds-ca-rsa2048-g1 (padrão), rds-ca-rsa4096-g1 e rds-ca-ecc384-g1. A documentação manda registrar só as CAs raiz, não as intermediárias, para não quebrar quando o RDS rotaciona o certificado do servidor.Bundle CAs. The global bundle includes rds-ca-rsa2048-g1 (default), rds-ca-rsa4096-g1 and rds-ca-ecc384-g1. The docs say to register only the root CAs, not intermediates, so nothing breaks when RDS rotates the server certificate.
Usuário mestre no app é atalho de estudo. Em produção crie um usuário só com SELECT/INSERT/UPDATE/DELETE na tabela e use as credenciais mestras apenas para migrações.The master user in the app is a study shortcut. In production create a user with only SELECT/INSERT/UPDATE/DELETE on the table and use the master credentials only for migrations.
Cuidado com a senha gerenciada pelo RDS. O RDS pode guardar e rotacionar a senha mestra no Secrets Manager (--manage-master-user-password), por padrão a cada sete dias. Mas o ECS lê o segredo só quando a task inicia: depois de uma rotação, as tasks em execução continuam com a senha antiga e novas conexões falham até um deploy novo. Para usar esse recurso, o app precisa ler o segredo em tempo de execução (com a task role e o endpoint do Secrets Manager) ou você precisa de um processo que force o deploy após a rotação. Por isso este exemplo usa um segredo próprio e estático.Beware of the RDS-managed password. RDS can store and rotate the master password in Secrets Manager (--manage-master-user-password), by default every seven days. But ECS reads the secret only when the task starts: after a rotation, running tasks keep the old password and new connections fail until a new deployment. To use that feature, the app must read the secret at run time (with the task role and the Secrets Manager endpoint) or you need a process that forces a deployment after rotation. That is why this example uses its own static secret.

6. As duas roles do ECS6. The two ECS roles

Crie as duas roles em cada conta (dev e prod). A de execução usa a política gerenciada AmazonECSTaskExecutionRolePolicy (ECR e logs) e ganha uma política embutida para ler só os dois segredos do app. Como os segredos usam a chave KMS padrão do Secrets Manager, a documentação não pede kms:Decrypt.Create both roles in each account (dev and prod). The execution role uses the managed policy AmazonECSTaskExecutionRolePolicy (ECR and logs) and gets an inline policy to read only the app’s two secrets. Because the secrets use Secrets Manager’s default KMS key, the docs do not ask for kms:Decrypt.

cat > ecs-trust.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "ecs-tasks.amazonaws.com" },
    "Action": "sts:AssumeRole"
  }]
}
EOF

aws iam create-role --role-name todoExecutionRole --assume-role-policy-document file://ecs-trust.json
aws iam attach-role-policy --role-name todoExecutionRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

aws iam put-role-policy --role-name todoExecutionRole --policy-name read-todo-secrets \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": [
        "arn:aws:secretsmanager:'$REGION':'$ACCOUNT_ID':secret:todo/db-*",
        "arn:aws:secretsmanager:'$REGION':'$ACCOUNT_ID':secret:todo/tls-*"
      ]
    }]
  }'

# task role: no permissions, the app calls no AWS API
aws iam create-role --role-name todoTaskRole --assume-role-policy-document file://ecs-trust.json

O -* no fim cobre o sufixo aleatório que o Secrets Manager acrescenta ao ARN. A role de deploy do GitHub (nota de OIDC) já tem iam:PassRole para essas duas roles; ajuste o ARN dela se você usou outros nomes.The trailing -* covers the random suffix that Secrets Manager appends to the ARN. The GitHub deploy role (OIDC note) already has iam:PassRole for these two roles; adjust its ARN if you used different names.

7. ECS: cluster, task definition, ALB e service7. ECS: cluster, task definition, ALB and service

Agora as peças do ECS, na ordem em que uma depende da outra. Os comandos usam as variáveis das seções anteriores.Now the ECS pieces, in the order each depends on the next. The commands use the variables from the earlier sections.

7.1 Repositório de imagens e a primeira imagem7.1 Image repository and the first image

O repositório é o da etapa 6 da nota de OIDC (tags imutáveis). Um service precisa de uma imagem que exista para subir; como o pipeline só funciona depois que o service existe, envie a primeira imagem à mão, uma única vez, com a tag bootstrap.The repository is the one from step 6 of the OIDC note (immutable tags). A service needs an image that exists to start; since the pipeline only works once the service exists, push the first image by hand, just once, with the bootstrap tag.

aws ecr create-repository --repository-name todo --image-tag-mutability IMMUTABLE \
  --image-scanning-configuration scanOnPush=true

REGISTRY=$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com
aws ecr get-login-password | docker login --username AWS --password-stdin $REGISTRY
docker build --platform linux/amd64 -t $REGISTRY/todo:bootstrap .
docker push $REGISTRY/todo:bootstrap

7.2 Logs e cluster7.2 Logs and cluster

aws logs create-log-group --log-group-name /ecs/todo
aws logs put-retention-policy --log-group-name /ecs/todo --retention-in-days 14

aws ecs create-cluster --cluster-name todo

7.3 Task definition7.3 Task definition

Salve como .aws/task-definition.develop.json (e outro, .aws/task-definition.production.json, para a conta de produção). Troque os marcadores <...>. Os ARNs completos dos segredos, com o sufixo aleatório, saem de aws secretsmanager describe-secret --secret-id todo/db --query ARN --output text (e o mesmo para todo/tls).Save it as .aws/task-definition.develop.json (and another, .aws/task-definition.production.json, for the production account). Replace the <...> markers. The full secret ARNs, with the random suffix, come from aws secretsmanager describe-secret --secret-id todo/db --query ARN --output text (and the same for todo/tls).

{
  "family": "todo",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
  "executionRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/todoExecutionRole",
  "taskRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/todoTaskRole",
  "containerDefinitions": [
    {
      "name": "app",
      "image": "<ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/todo:bootstrap",
      "essential": true,
      "readonlyRootFilesystem": true,
      "stopTimeout": 30,
      "portMappings": [{ "containerPort": 8443, "protocol": "tcp" }],
      "environment": [
        { "name": "PORT", "value": "8443" },
        { "name": "DB_HOST", "value": "<DB_HOST>" },
        { "name": "DB_NAME", "value": "todos" }
      ],
      "secrets": [
        { "name": "DB_USER",     "valueFrom": "<TODO_DB_SECRET_ARN>:username::" },
        { "name": "DB_PASSWORD", "valueFrom": "<TODO_DB_SECRET_ARN>:password::" },
        { "name": "TLS_CERT",    "valueFrom": "<TODO_TLS_SECRET_ARN>:tls_cert::" },
        { "name": "TLS_KEY",     "valueFrom": "<TODO_TLS_SECRET_ARN>:tls_key::" }
      ],
      "healthCheck": {
        "command": ["CMD-SHELL", "node -e \"require('https').get({host:'localhost',port:8443,path:'/health',rejectUnauthorized:false},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""],
        "interval": 15,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 20
      },
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/todo",
          "awslogs-region": "<REGION>",
          "awslogs-stream-prefix": "app"
        }
      }
    }
  ]
}
  • secrets × environment. O que está em secrets vem do Secrets Manager e não aparece no JSON. A sintaxe ARN:chave:: pega uma chave do JSON do segredo (exige Fargate com plataforma 1.4.0 ou mais nova). Os dois pontos finais são as partes opcionais (estágio e versão) deixadas em branco.secrets vs environment. What is in secrets comes from Secrets Manager and does not appear in the JSON. The ARN:key:: syntax picks one key from the secret’s JSON (needs Fargate platform 1.4.0 or newer). The trailing colons are the optional parts (stage and version) left blank.
  • awsvpc e portas. Em awsvpc a task tem seu próprio IP, então a porta do container é a porta que o ALB acessa. Não existe mapeamento de porta do host.awsvpc and ports. In awsvpc the task has its own IP, so the container port is the port the ALB reaches. There is no host port mapping.
  • readonlyRootFilesystem. O container não pode escrever no próprio disco. Se um invasor entrar, não consegue gravar binários nem alterar o app. O Node não precisa de disco para este exemplo.readonlyRootFilesystem. The container cannot write to its own disk. If an attacker gets in, they cannot drop binaries or alter the app. Node does not need disk for this example.
  • Health check do container. Usa o próprio Node (a imagem Alpine não traz curl) e aceita o certificado local com rejectUnauthorized:false, já que a checagem é interna. Na variante B troque https por http.Container health check. It uses Node itself (the Alpine image has no curl) and accepts the local certificate with rejectUnauthorized:false, since the check is internal. In variant B swap https for http.
  • CPU e memória. 256 (0,25 vCPU) e 512 MB é uma combinação válida para Fargate e basta para o exemplo.CPU and memory. 256 (0.25 vCPU) and 512 MB is a valid Fargate combination and is enough for the example.
aws ecs register-task-definition --cli-input-json file://.aws/task-definition.develop.json

7.4 Target group, ALB e listener HTTPS7.4 Target group, ALB and HTTPS listener

O target group é ip e fala HTTPS com a task na 8443; o health check do ALB também é HTTPS no /health. Mesmo que o ALB seja compartilhado com outros apps, cada service usa o seu próprio target group (a documentação do ECS recomenda um por service).The target group is ip and speaks HTTPS to the task on 8443; the ALB health check is also HTTPS on /health. Even if the ALB is shared with other apps, each service uses its own target group (the ECS docs recommend one per service).

TG_ARN=$(aws elbv2 create-target-group --name todo --target-type ip \
  --protocol HTTPS --port 8443 --vpc-id $VPC_ID \
  --health-check-protocol HTTPS --health-check-path /health \
  --health-check-interval-seconds 15 --healthy-threshold-count 2 --matcher HttpCode=200 \
  --query 'TargetGroups[0].TargetGroupArn' --output text)
aws elbv2 modify-target-group-attributes --target-group-arn $TG_ARN \
  --attributes Key=deregistration_delay.timeout_seconds,Value=30

ALB_ARN=$(aws elbv2 create-load-balancer --name todo --type application --scheme internet-facing \
  --subnets $PUB_A $PUB_B --security-groups $SG_ALB \
  --query 'LoadBalancers[0].LoadBalancerArn' --output text)

aws elbv2 create-listener --load-balancer-arn $ALB_ARN --protocol HTTPS --port 443 \
  --certificates CertificateArn=$CERT_ARN --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
  --default-actions Type=forward,TargetGroupArn=$TG_ARN

aws elbv2 describe-load-balancers --load-balancer-arns $ALB_ARN --query 'LoadBalancers[0].DNSName' --output text

Não há listener na porta 80: o sg-alb nem a deixa entrar. O redirecionamento de HTTP para HTTPS fica na Cloudflare (SSL/TLS → Edge Certificates → Always Use HTTPS), antes de o tráfego chegar à sua conta.There is no listener on port 80: sg-alb does not even let it in. The HTTP-to-HTTPS redirect lives at Cloudflare (SSL/TLS → Edge Certificates → Always Use HTTPS), before traffic reaches your account.

7.5 Service7.5 Service

# once per account; the console does it for you, the CLI does not
aws iam create-service-linked-role --aws-service-name ecs.amazonaws.com   # ignore "already exists"

aws ecs create-service --cluster todo --service-name todo \
  --task-definition todo --desired-count 2 --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[$PRIV_A,$PRIV_B],securityGroups=[$SG_APP],assignPublicIp=DISABLED}" \
  --load-balancers targetGroupArn=$TG_ARN,containerName=app,containerPort=8443 \
  --health-check-grace-period-seconds 60 \
  --deployment-configuration "minimumHealthyPercent=100,maximumPercent=200,deploymentCircuitBreaker={enable=true,rollback=true}"
  • desired-count 2. Duas tasks, uma em cada subnet privada (AZs diferentes). Uma AZ pode cair sem tirar o app do ar. Com 1 task só, a troca de versão passa por um instante com a nova e a antiga juntas (100/200).desired-count 2. Two tasks, one in each private subnet (different AZs). An AZ can fail without taking the app down. With a single task, the swap briefly runs the new and old together (100/200).
  • assignPublicIp=DISABLED. A task não ganha IP público. Ela é alcançável só de dentro da VPC, via ALB.assignPublicIp=DISABLED. The task gets no public IP. It is reachable only from inside the VPC, via the ALB.
  • health-check-grace-period-seconds 60. Pela referência da API, o scheduler ignora health checks do ALB reprovados por esse tempo depois de a task iniciar. Sem ele (padrão 0) uma task que ainda está carregando seria morta antes de ficar pronta.health-check-grace-period-seconds 60. Per the API reference, the scheduler ignores failing ALB health checks for that long after the task starts. Without it (default 0), a task still booting could be killed before it is ready.
  • Circuit breaker com rollback. Se as tasks novas não ficarem saudáveis, o ECS volta para a revisão anterior sozinho. Detalhes na etapa 7 da nota de OIDC.Circuit breaker with rollback. If the new tasks do not become healthy, ECS rolls back to the previous revision on its own. Details in step 7 of the OIDC note.
  • Service-linked role. O ECS precisa dela para registrar e remover as tasks no ALB, segundo a documentação.Service-linked role. ECS needs it to register and deregister tasks with the ALB, per the docs.

7.6 DNS na Cloudflare7.6 DNS at Cloudflare

  1. Cloudflare → DNS → Records → Add record: tipo CNAME, nome todo, destino = o DNS do ALB que o último comando acima imprimiu, Proxy status: Proxied (nuvem laranja).Cloudflare → DNS → Records → Add record: type CNAME, name todo, target = the ALB DNS name that the last command above printed, Proxy status: Proxied (orange cloud).
  2. Com o proxy ligado, a Cloudflare termina o trecho 1 e abre o trecho 2 até o ALB, validando o certificado de origem (Full strict).With the proxy on, Cloudflare terminates leg 1 and opens leg 2 to the ALB, validating the origin certificate (Full strict).

8. Ligar ao deploy automático8. Connecting to automatic deploy

Tudo o que a nota de OIDC chamava de “já existe” (ECR, cluster, service, roles) agora existe. Falta entregar ao workflow os nomes certos, em cada environment do GitHub:Everything the OIDC note called “already exists” (ECR, cluster, service, roles) now exists. What remains is handing the workflow the right names, in each GitHub environment:

Variável do environmentEnvironment variableValor deste exemploValue in this example
AWS_ROLE_ARNAWS_ROLE_ARNA role de deploy via OIDC daquela conta (etapa 2 da nota de OIDC)That account’s OIDC deploy role (step 2 of the OIDC note)
AWS_REGIONAWS_REGIONsa-east-1 (a mesma de $REGION)sa-east-1 (the same as $REGION)
ECR_REPOSITORYECR_REPOSITORYtodotodo
ECS_CLUSTERECS_CLUSTERtodotodo
ECS_SERVICEECS_SERVICEtodotodo
gh variable set ECR_REPOSITORY --env develop --body "todo"
gh variable set ECS_CLUSTER    --env develop --body "todo"
gh variable set ECS_SERVICE    --env develop --body "todo"
# AWS_ROLE_ARN and AWS_REGION: see step 3 of the OIDC note; repeat all of them for --env production

Um arquivo de task definition por ambienteOne task definition file per environment

O workflow da nota de OIDC usa um único .aws/task-definition.json. Aqui o arquivo carrega o ID da conta (nos ARNs das roles e dos segredos) e o endpoint do banco, que são diferentes em dev e prod. Por isso há um arquivo por environment, e uma única linha do workflow muda para escolher o certo:The OIDC note’s workflow uses a single .aws/task-definition.json. Here the file carries the account ID (in the role and secret ARNs) and the database endpoint, which differ between dev and prod. So there is one file per environment, and a single workflow line changes to pick the right one:

    env:
      # ...
      ECS_TASK_DEFINITION: .aws/task-definition.${{ github.ref_name == 'main' && 'production' || 'develop' }}.json
      CONTAINER_NAME: app
  • A imagem do arquivo é só um marcador. A action de render troca o campo image do container app pela imagem do commit; o :bootstrap nunca mais é usado.The image in the file is just a placeholder. The render action swaps the image field of the app container for the commit’s image; :bootstrap is never used again.
  • O app não usa --build-arg. Se o seu Dockerfile não declara ARG APP_ENV, o docker build ignora o argumento extra com um aviso. Você pode apagar essas linhas do workflow, já que a configuração do app vem em tempo de execução (environment e secrets).The app does not use --build-arg. If your Dockerfile does not declare ARG APP_ENV, docker build ignores the extra argument with a warning. You can delete those lines from the workflow, since the app’s configuration arrives at run time (environment and secrets).
  • O que o pipeline não faz. Não cria rede, banco, segredos nem roles; só troca a imagem. Mudou um segredo (por exemplo, o certificado)? Rode update-service --force-new-deployment, como na seção 4.4.What the pipeline does not do. It does not create network, database, secrets or roles; it only swaps the image. Changed a secret (the certificate, say)? Run update-service --force-new-deployment, as in section 4.4.
  1. Faça merge de uma mudança em develop. O workflow constrói a imagem (a do GitHub é x86_64, como a task), envia ao ECR com o SHA como tag, registra uma revisão da task definition e atualiza o service.Merge a change into develop. The workflow builds the image (GitHub’s runner is x86_64, like the task), pushes it to ECR tagged with the SHA, registers a task definition revision and updates the service.
  2. O ECS sobe duas tasks novas, espera passarem nos health checks do container e do ALB, e só então para as antigas (100/200). Enquanto isso, o ALB só manda tráfego às saudáveis.ECS starts two new tasks, waits for them to pass the container and ALB health checks, and only then stops the old ones (100/200). Meanwhile the ALB only sends traffic to healthy ones.
  3. O job do GitHub espera o service estabilizar. Se o circuit breaker fizer rollback, o job falha e o app antigo continua no ar.The GitHub job waits for the service to stabilise. If the circuit breaker rolls back, the job fails and the old app keeps serving.

9. Conferir que funciona e que está seguro9. Checking it works and is secure

# 1) the service reached steady state, and the targets are healthy
aws ecs describe-services --cluster todo --services todo \
  --query 'services[0].{running:runningCount,desired:desiredCount,events:events[0].message}'
aws elbv2 describe-target-health --target-group-arn $TG_ARN \
  --query 'TargetHealthDescriptions[].TargetHealth.State'

# 2) the app log says it started over HTTPS
aws logs tail /ecs/todo --since 10m

# 3) through Cloudflare, end to end
curl -i https://$DOMAIN/health
curl -s -X POST https://$DOMAIN/todos -d '{"title":"first task"}'
curl -s https://$DOMAIN/todos

# 4) negative tests: both must FAIL
curl -m 8 -k https://<ALB_DNS_NAME>/health     # times out: sg-alb only accepts Cloudflare IPs
curl -m 8 http://$DOMAIN/health                # redirected to HTTPS by Cloudflare

Quando algo dá erradoWhen something goes wrong

SintomaSymptomCausa provávelLikely cause
Cloudflare 521 (web server is down) ou 522 (connection timed out)Cloudflare 521 (web server is down) or 522 (connection timed out)O sg-alb não tem os IPs da Cloudflare na 443, ou o listener 443 não existe. Verifique também a rota de 172.64.0.0/13 citada na seção 3.sg-alb lacks Cloudflare’s IPs on 443, or the 443 listener does not exist. Also check the 172.64.0.0/13 route mentioned in section 3.
Cloudflare 525 (SSL handshake failed) ou 526 (invalid SSL certificate)Cloudflare 525 (SSL handshake failed) or 526 (invalid SSL certificate)O listener do ALB não usa o certificado de origem importado, o certificado não cobre o hostname, ou o modo Full (strict) está com um certificado vencido.The ALB listener is not using the imported origin certificate, the certificate does not cover the hostname, or Full (strict) is on with an expired certificate.
Cloudflare 502, ALB 502/503Cloudflare 502, ALB 502/503Nenhum target saudável. Veja describe-target-health: se estiver unhealthy, o target group fala HTTPS e a task HTTP (ou o contrário), ou sg-app não aceita 8443 do sg-alb.No healthy target. Check describe-target-health: if unhealthy, the target group speaks HTTPS while the task speaks HTTP (or the reverse), or sg-app does not accept 8443 from sg-alb.
A task para logo depois de iniciar: ResourceInitializationError ... unable to pull secretsThe task stops right after starting: ResourceInitializationError ... unable to pull secretsA execution role não pode ler o segredo (ARN errado na política ou no valueFrom), ou falta o endpoint secretsmanager, ou o sg-vpce não aceita 443 do sg-app.The execution role cannot read the secret (wrong ARN in the policy or in valueFrom), or the secretsmanager endpoint is missing, or sg-vpce does not accept 443 from sg-app.
CannotPullContainerErrorCannotPullContainerErrorFaltam ecr.api, ecr.dkr ou o gateway do s3; o DNS privado do endpoint está desligado; ou a imagem/tag não existe no ECR.ecr.api, ecr.dkr or the s3 gateway is missing; the endpoint’s private DNS is off; or the image/tag does not exist in ECR.
Sem logs no CloudWatchNo logs in CloudWatchFalta o endpoint logs, ou o grupo /ecs/todo não existe (o awslogs não o cria sozinho nesta configuração).The logs endpoint is missing, or the /ecs/todo group does not exist (awslogs does not create it on its own in this setup).
O log mostra self-signed certificate in certificate chain ao falar com o bancoThe log shows self-signed certificate in certificate chain when talking to the databaseO bundle de CAs não foi baixado na imagem, ou o DB_CA_FILE aponta para outro arquivo. O RDS exige confiar na CA da AWS para o verify-full.The CA bundle was not downloaded into the image, or DB_CA_FILE points elsewhere. RDS needs the AWS CA trusted for verify-full.
no pg_hba.conf entry ... SSL offno pg_hba.conf entry ... SSL offO servidor exige SSL (rds.force_ssl) e o cliente tentou sem. Confira que DB_SSL não está off na task.The server requires SSL (rds.force_ssl) and the client tried without it. Make sure DB_SSL is not off in the task.
Deploy fica em loop e volta para a versão antigaDeploy loops and goes back to the old versionÉ o circuit breaker fazendo o trabalho dele. Leia os eventos do service e os logs da task nova para achar o motivo.That is the circuit breaker doing its job. Read the service events and the new task’s logs to find the reason.
Nota pessoal, não verificada nas fontes: as causas da tabela acima são o diagnóstico que faço a partir da leitura das documentações, não uma lista oficial. Das mensagens da Cloudflare, só os títulos dos erros foram conferidos na documentação.Personal note, not verified in the sources: the causes in the table above are my own diagnosis from reading the docs, not an official list. Of Cloudflare’s messages, only the error titles were checked in its documentation.

10. Variante B: HTTP dentro da VPC10. Variant B: HTTP inside the VPC

Se você não precisa da criptografia entre o ALB e a task, o ALB pode terminar o TLS e falar HTTP com o container. Fica bem mais simples: a task não precisa do certificado, o segredo todo/tls deixa de existir e não há renovação em dois lugares. O mesmo código serve, porque o server.js cai para HTTP quando não recebe TLS_CERT.If you do not need encryption between the ALB and the task, the ALB can terminate TLS and speak HTTP to the container. It is much simpler: the task needs no certificate, the todo/tls secret disappears and there is no renewal in two places. The same code works, because server.js falls back to HTTP when it gets no TLS_CERT.

O que mudaWhat changesVariante A (principal)Variant A (main)Variante BVariant B
Target groupTarget groupHTTPS · 8443 · health check HTTPSHTTPS · 8443 · HTTPS health checkHTTP · 8080 · health check HTTPHTTP · 8080 · HTTP health check
Task definitionTask definitionTLS_CERT e TLS_KEY em secrets; PORT=8443TLS_CERT and TLS_KEY in secrets; PORT=8443Sem os dois; PORT=8080; health check com httpWithout both; PORT=8080; health check with http
Security groupSecurity groupsg-app aceita 8443 do ALBsg-app accepts 8443 from the ALBsg-app aceita 8080 do ALBsg-app accepts 8080 from the ALB
Trecho ALB → taskALB → task legCriptografadoEncryptedTexto puro dentro da VPC, autenticado por pacoteClear text inside the VPC, packet-authenticated
Trechos 1, 2 e 4Legs 1, 2 and 4IguaisSameIguais (Cloudflare → ALB e task → RDS continuam em TLS)Same (Cloudflare → ALB and task → RDS remain TLS)

O target group não pode mudar de protocolo depois de criado, então migrar da A para a B (ou ao contrário) significa criar outro target group e trocar o service para ele.A target group’s protocol cannot be changed after creation, so moving from A to B (or back) means creating another target group and pointing the service at it.

11. Apagar tudo11. Deleting everything

Apague na ordem inversa da dependência, para que nada fique preso. Desfazer o que você criou evita cobranças por hora de ALB, RDS, endpoints e tasks.Delete in reverse dependency order so nothing gets stuck. Undoing what you created avoids hourly charges for the ALB, RDS, endpoints and tasks.

  1. Cloudflare: apague o registro CNAME. GitHub: apague as variáveis (ou desligue o workflow).Cloudflare: delete the CNAME record. GitHub: delete the variables (or disable the workflow).
  2. aws ecs update-service --cluster todo --service todo --desired-count 0 e depois aws ecs delete-service --cluster todo --service todo --force; então aws ecs delete-cluster --cluster todo.aws ecs update-service --cluster todo --service todo --desired-count 0 then aws ecs delete-service --cluster todo --service todo --force; then aws ecs delete-cluster --cluster todo.
  3. Apague o listener e o ALB (aws elbv2 delete-load-balancer), depois o target group. Remova o certificado do ACM (aws acm delete-certificate), que só pode ser apagado depois de sair do listener.Delete the listener and the ALB (aws elbv2 delete-load-balancer), then the target group. Remove the ACM certificate (aws acm delete-certificate), which can only be deleted once it is no longer on the listener.
  4. RDS: aws rds delete-db-instance --db-instance-identifier todo-db --skip-final-snapshot (ou tire um snapshot final se os dados importarem).RDS: aws rds delete-db-instance --db-instance-identifier todo-db --skip-final-snapshot (or take a final snapshot if the data matters).
  5. Segredos (aws secretsmanager delete-secret), repositório ECR, log group, roles IAM, as task definitions (desregistre as revisões).Secrets (aws secretsmanager delete-secret), ECR repository, log group, IAM roles, the task definitions (deregister the revisions).
  6. Por último a rede: endpoints, security groups, prefix list e a VPC (o assistente remove as subnets, rotas e o IGW junto).Network last: endpoints, security groups, prefix list and the VPC (the wizard-created subnets, routes and IGW go with it).
Nota pessoal, não verificada nas fontes: esta combinação (Cloudflare Full strict, certificado de origem no ACM e no container, Fargate em subnets privadas com endpoints, RDS com verify-full) não aparece num único exemplo oficial; ela junta as páginas listadas abaixo. O server.js, a imagem e o jq que monta o segredo foram executados localmente (Docker, Postgres 16, certificado autoassinado), com o TLS ligado e desligado. Os comandos aws e a task definition não foram executados contra uma conta AWS: foram escritos pelas referências da CLI e da API, então confira os parâmetros com --help na sua versão antes de depender deles.Personal note, not verified in the sources: this combination (Cloudflare Full strict, origin certificate in ACM and in the container, Fargate in private subnets with endpoints, RDS with verify-full) does not appear in a single official example; it joins the pages listed below. The server.js, the image and the jq that builds the secret were run locally (Docker, Postgres 16, a self-signed certificate), with TLS on and off. The aws commands and the task definition were not run against an AWS account: they were written from the CLI and API references, so check the parameters with --help on your version before relying on them.
Nota pessoal, não verificada nas fontes: a Cloudflare oferece o Authenticated Origin Pulls (mTLS) para que a origem só aceite conexões da sua conta. Não verifiquei se o ALB consegue exigi-lo, então esta nota usa a lista de IPs no security group. O ponto de partida é a página oficial de Authenticated Origin Pulls, listada abaixo.Personal note, not verified in the sources: Cloudflare offers Authenticated Origin Pulls (mTLS) so the origin only accepts connections from your account. I did not verify whether the ALB can enforce it, so this note uses the IP list in the security group. The starting point is the official Authenticated Origin Pulls page, listed below.