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.
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.
- 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.
- 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.
- 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.
- 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.
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 EC2 | No ECSIn ECS |
|---|---|
| Subir a instância e instalar o DockerLaunch the instance and install Docker | Fargate: 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 container | Task: 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 it | Service: 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 instances | Cluster: 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 port | Target 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 group | Security 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 login | A 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 machine | environment (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 logs | O 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
| RoleRole | Quem usaWho uses it | Para quêWhat for |
|---|---|---|
| Task execution roleTask execution role | O agente do ECS/Fargate, antes do container iniciarThe ECS/Fargate agent, before the container starts | Puxar 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 role | O seu código, dentro do containerYour code, inside the container | Chamar 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.
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_CERTeTLS_KEYexistirem, 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. IfTLS_CERTandTLS_KEYexist, 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.cacarrega o bundle de CAs da AWS; com isso o driver valida a cadeia e o nome do host do RDS. Semca, opgnão confia no certificado do RDS.Verified database connection.ssl.caloads the AWS CA bundle; the driver then validates the RDS chain and host name. Withoutca,pgdoes 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./healthis 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
SIGTERMe, se o processo não sair,SIGKILLdepois dostopTimeout(30 s por padrão, máximo 120 s). O handler deixa terminar as requisições em andamento.Clean stop. ECS sendsSIGTERMand, if the process does not exit,SIGKILLafterstopTimeout(30 s by default, 120 s maximum). The handler lets in-flight requests finish. - Migração no start. O
CREATE TABLE IF NOT EXISTSserve 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 EXISTSis 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.
3.1 VPC e subnets3.1 VPC and subnets
- Console: VPC → Create VPC → VPC and more. Nome
todo, IPv410.0.0.0/16, 2 Availability Zones, 2 subnets públicas, 2 privadas.Console: VPC → Create VPC → VPC and more. Nametodo, IPv410.0.0.0/16, 2 Availability Zones, 2 public subnets, 2 private. - 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).
- 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.
| GrupoGroup | Entrada permitidaAllowed inbound | Por quêWhy |
|---|---|---|
sg-albsg-alb | 443 dos IPs da Cloudflare443 from Cloudflare’s IPs | Ningué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-app | 8443 de sg-alb8443 from sg-alb | Só o ALB fala com a task.Only the ALB talks to the task. |
sg-dbsg-db | 5432 de sg-app5432 from sg-app | Só a aplicação fala com o banco.Only the application talks to the database. |
sg-vpcesg-vpce | 443 de sg-app443 from sg-app | Os 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}]"
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 for | EndpointEndpoint | TipoType |
|---|---|---|
| Autenticar e consultar o ECRAuthenticate to and query ECR | ecr.apiecr.api | InterfaceInterface |
| 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 S3 | s3s3 | Gateway (o assistente já criou)Gateway (the wizard already created it) |
| Enviar logs ao CloudWatchSend logs to CloudWatch | logslogs | InterfaceInterface |
| Ler segredos antes de iniciarRead secrets before starting | secretsmanagersecretsmanager | InterfaceInterface |
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.
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.
| TrechoLeg | Termina emTerminates at | CertificadoCertificate | Quem validaWho validates |
|---|---|---|---|
| 1 · visitante → Cloudflare1 · visitor → Cloudflare | CloudflareCloudflare | Universal SSL da CloudflareCloudflare’s Universal SSL | O navegadorThe browser |
| 2 · Cloudflare → ALB2 · Cloudflare → ALB | Listener 443 do ALBALB 443 listener | Origem da Cloudflare, importado no ACMCloudflare origin, imported into ACM | A Cloudflare (Full strict)Cloudflare (Full strict) |
| 3 · ALB → task3 · ALB → task | Node, dentro do containerNode, inside the container | O mesmo, vindo do Secrets ManagerThe same one, from Secrets Manager | Ninguém (veja abaixo)Nobody (see below) |
| 4 · task → RDS4 · task → RDS | PostgreSQL no RDSPostgreSQL on RDS | CA do RDSRDS CA | O driver pg (verify-full)The pg driver (verify-full) |
4.1 Cloudflare: modo Full (strict) e certificado de origem4.1 Cloudflare: Full (strict) mode and origin certificate
- 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.
- 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. - Copie o certificado para
origin.peme a chave paraorigin.keyantes de sair da tela: a documentação avisa que a chave não pode ser vista depois.Copy the certificate intoorigin.pemand the key intoorigin.keybefore 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
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_sslvem 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 comaws rds describe-db-instances --query "DBInstances[0].EngineVersion".Require SSL on the server. Per the RDS docs, therds.force_sslparameter 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 withaws 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 ossl.cadoserver.jsfaz), o driver confere a cadeia e o nome do host. Para isso ele precisa da CA da AWS, que o Dockerfile baixa dotruststore.pki.rds.amazonaws.com.Verify the server on the client. A client that merely “uses SSL” does not know who it talks to. Withsslmode=verify-full(whatssl.cainserver.jsdoes), the driver checks the chain and host name. It needs AWS’s CA for that, which the Dockerfile downloads fromtruststore.pki.rds.amazonaws.com. - CA do bundle. O bundle global inclui as CAs
rds-ca-rsa2048-g1(padrão),rds-ca-rsa4096-g1erds-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 includesrds-ca-rsa2048-g1(default),rds-ca-rsa4096-g1andrds-ca-ecc384-g1. The docs say to register only the root CAs, not intermediates, so nothing breaks when RDS rotates the server certificate.
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.--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á emsecretsvem do Secrets Manager e não aparece no JSON. A sintaxeARN: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.secretsvsenvironment. What is insecretscomes from Secrets Manager and does not appear in the JSON. TheARN: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.awsvpce portas. Emawsvpca 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.awsvpcand ports. Inawsvpcthe 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 comrejectUnauthorized:false, já que a checagem é interna. Na variante B troquehttpsporhttp.Container health check. It uses Node itself (the Alpine image has nocurl) and accepts the local certificate withrejectUnauthorized:false, since the check is internal. In variant B swaphttpsforhttp. - 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
- Cloudflare → DNS → Records → Add record: tipo
CNAME, nometodo, destino = o DNS do ALB que o último comando acima imprimiu, Proxy status: Proxied (nuvem laranja).Cloudflare → DNS → Records → Add record: typeCNAME, nametodo, target = the ALB DNS name that the last command above printed, Proxy status: Proxied (orange cloud). - 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 variable | Valor deste exemploValue in this example |
|---|---|
AWS_ROLE_ARNAWS_ROLE_ARN | A 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_REGION | sa-east-1 (a mesma de $REGION)sa-east-1 (the same as $REGION) |
ECR_REPOSITORYECR_REPOSITORY | todotodo |
ECS_CLUSTERECS_CLUSTER | todotodo |
ECS_SERVICEECS_SERVICE | todotodo |
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
imagedo containerapppela imagem do commit; o:bootstrapnunca mais é usado.The image in the file is just a placeholder. The render action swaps theimagefield of theappcontainer for the commit’s image;:bootstrapis never used again. - O app não usa
--build-arg. Se o seu Dockerfile não declaraARG APP_ENV, odocker buildignora 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 (environmentesecrets).The app does not use--build-arg. If your Dockerfile does not declareARG APP_ENV,docker buildignores the extra argument with a warning. You can delete those lines from the workflow, since the app’s configuration arrives at run time (environmentandsecrets). - 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)? Runupdate-service --force-new-deployment, as in section 4.4.
- 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 intodevelop. 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. - 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.
- 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
| SintomaSymptom | Causa 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/503 | Nenhum 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 secrets | A 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. |
CannotPullContainerErrorCannotPullContainerError | Faltam 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 CloudWatch | Falta 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 database | O 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 off | O 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. |
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 changes | Variante A (principal)Variant A (main) | Variante BVariant B |
|---|---|---|
| Target groupTarget group | HTTPS · 8443 · health check HTTPSHTTPS · 8443 · HTTPS health check | HTTP · 8080 · health check HTTPHTTP · 8080 · HTTP health check |
| Task definitionTask definition | TLS_CERT e TLS_KEY em secrets; PORT=8443TLS_CERT and TLS_KEY in secrets; PORT=8443 | Sem os dois; PORT=8080; health check com httpWithout both; PORT=8080; health check with http |
| Security groupSecurity group | sg-app aceita 8443 do ALBsg-app accepts 8443 from the ALB | sg-app aceita 8080 do ALBsg-app accepts 8080 from the ALB |
| Trecho ALB → taskALB → task leg | CriptografadoEncrypted | Texto puro dentro da VPC, autenticado por pacoteClear text inside the VPC, packet-authenticated |
| Trechos 1, 2 e 4Legs 1, 2 and 4 | IguaisSame | Iguais (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.
- 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).
aws ecs update-service --cluster todo --service todo --desired-count 0e depoisaws ecs delete-service --cluster todo --service todo --force; entãoaws ecs delete-cluster --cluster todo.aws ecs update-service --cluster todo --service todo --desired-count 0thenaws ecs delete-service --cluster todo --service todo --force; thenaws ecs delete-cluster --cluster todo.- 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. - 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). - 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). - 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).
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.- AWS Docs · ECS: Use an Application Load Balancer (target type ip for awsvpc, one target group per service)
- AWS Docs · ELB: Target groups for ALB (HTTPS targets, certificates are not validated, ip targets)
- AWS Docs · ECS: Pass Secrets Manager secrets through environment variables (valueFrom syntax, platform 1.4.0, no rotation pickup)
- AWS Docs · ECS: Task execution IAM role (managed policy, Secrets Manager permissions)
- AWS Docs · ECS: Interface VPC endpoints (Fargate: ECR, Secrets Manager and CloudWatch Logs)
- AWS Docs · ECR: Interface VPC endpoints (ecr.api, ecr.dkr, S3 gateway, logs)
- AWS Docs · ECS: Task definition parameters for Fargate (stopTimeout, readonlyRootFilesystem, healthCheck)
- AWS Docs · ECS API: CreateService (healthCheckGracePeriodSeconds)
- AWS Docs · ACM: Prerequisites for importing certificates
- AWS Docs · RDS: Using SSL with a PostgreSQL DB instance (rds.force_ssl, verify-full)
- AWS Docs · RDS: Using SSL/TLS to encrypt a connection (CA bundle, root CAs only)
- AWS Docs · RDS: Password management with Secrets Manager (default 7-day rotation)
- Cloudflare Docs · Origin CA certificates
- Cloudflare Docs · Authenticated Origin Pulls
- Cloudflare Docs · Cloudflare IP addresses
- Cloudflare Docs · 5xx errors
- Node.js Docs · HTTPS (createServer, server.close)