AWS · CloudflareAWS · Cloudflare
Front e back na AWS: duas imagens, um ALB, um domínio.Front and back on AWS: two images, one ALB, one domain.
Um front-end React servido pelo nginx e uma API em Node, cada um na sua imagem Docker no ECS Fargate, atrás de um único load balancer que divide o tráfego pelo caminho. O domínio foi comprado na Cloudflare, que também emite o certificado que o ALB usa.A React front end served by nginx and a Node API, each in its own Docker image on ECS Fargate, behind a single load balancer that splits traffic by path. The domain was bought at Cloudflare, which also issues the certificate the ALB uses.
O desenhoThe design
A ideia é simples: o site tem duas partes que mudam em ritmos diferentes. O front são arquivos estáticos (HTML, JS, CSS) que o navegador baixa; o back é um processo que responde JSON. Cada parte vira uma imagem Docker, cada imagem vira um service no ECS, e um único ALB decide, pelo caminho da URL, para qual delas mandar cada pedido. A Cloudflare fica na frente de tudo: é onde o domínio foi comprado, onde mora o DNS e quem emite o certificado que o ALB apresenta.The idea is simple: the site has two parts that change at different paces. The front end is static files (HTML, JS, CSS) the browser downloads; the back end is a process that answers JSON. Each part becomes a Docker image, each image becomes an ECS service, and a single ALB decides, by the URL path, which one gets each request. Cloudflare sits in front of everything: it is where the domain was bought, where DNS lives and who issues the certificate the ALB presents.
| TrechoLeg | Quem apresenta o certificadoWho presents the certificate | Quem confereWho checks it |
|---|---|---|
| 1 · navegador → Cloudflare1 · browser → Cloudflare | Cloudflare (Universal SSL, automático e gratuito)Cloudflare (Universal SSL, automatic and free) | O navegadorThe browser |
| 2 · Cloudflare → ALB2 · Cloudflare → ALB | ALB, com o certificado de origem da Cloudflare importado no ACMThe ALB, with the Cloudflare origin certificate imported into ACM | A Cloudflare, no modo Full (strict)Cloudflare, in Full (strict) mode |
| 3 · ALB → containers3 · ALB → containers | Ninguém: HTTP dentro da VPC (veja a callout abaixo)Nobody: HTTP inside the VPC (see the callout below) | —— |
O roteiroThe route
- Decidir e construir (seções 1 e 2). Caminho ou subdomínio, o código de exemplo, as duas imagens e um teste local que imita o ALB.Decide and build (sections 1 and 2). Path or subdomain, the example code, both images and a local test that imitates the ALB.
- Fundação (3 a 5). Certificado na Cloudflare, rede na AWS e os dois repositórios de imagens.Foundation (3 to 5). Certificate at Cloudflare, network on AWS and the two image repositories.
- No ar (6 a 8). Task definitions, ALB com a regra
/api/*, dois services e o DNS apontando para o ALB.Live (6 to 8). Task definitions, ALB with the/api/*rule, two services and DNS pointing at the ALB. - Rotina (9 a 11). Deploy automático das duas imagens, diagnóstico e limpeza.Routine (9 to 11). Automatic deploy of both images, troubleshooting and cleanup.
1. Caminho ou subdomínio?1. Path or subdomain?
Antes de criar qualquer coisa, decida como o navegador vai chegar na API. Há duas formas de dividir o tráfego no ALB, e a escolha muda o código do front:Before creating anything, decide how the browser will reach the API. There are two ways to split traffic on the ALB, and the choice changes the front-end code:
| Por caminho (esta nota)By path (this note) | Por subdomínioBy subdomain | |
|---|---|---|
| URLsURLs | example.com/ e example.com/api/...example.com/ and example.com/api/... | example.com e api.example.comexample.com and api.example.com |
| Condição da regra no ALBALB rule condition | path-pattern = /api/*path-pattern = /api/* | host-header = api.example.comhost-header = api.example.com |
| CORSCORS | Não existe: é a mesma origemDoes not exist: same origin | Obrigatório: a API precisa responder Access-Control-Allow-Origin e tratar o OPTIONS (preflight)Required: the API must answer Access-Control-Allow-Origin and handle OPTIONS (preflight) |
| Cookies de sessãoSession cookies | Funcionam sem configuração extraWork with no extra setup | Precisam de Domain=example.com e credentials: 'include' no fetchNeed Domain=example.com and credentials: 'include' in fetch |
| URL da API no build do frontAPI URL in the front-end build | Nenhuma: o código chama /api/... (relativo)None: the code calls /api/... (relative) | Uma variável de build (VITE_API_URL), e uma imagem diferente por ambienteA build variable (VITE_API_URL), and a different image per environment |
| Exigência no código da APIRequirement on the API code | As rotas começam com /api (o ALB repassa o caminho inteiro)Routes start with /api (the ALB forwards the full path) | NenhumaNone |
| Quando escolherWhen to choose it | Um site e a sua própria API: o caso comumA site and its own API: the common case | A API é pública e usada por outros clientes (app mobile, parceiros)The API is public and used by other clients (mobile app, partners) |
Esta nota segue por caminho. A variante por subdomínio é a mesma montagem com uma linha diferente na regra do ALB e um registro DNS a mais; ela aparece no fim da seção 7.This note goes by path. The subdomain variant is the same setup with one different line in the ALB rule and one more DNS record; it shows up at the end of section 7.
2. O projeto e as duas imagens2. The project and its two images
Um repositório, duas pastas, uma imagem por pasta. A API é de propósito sem estado (não tem banco): ela diz qual task respondeu, o que deixa visível o ALB dividindo o tráfego. Para pôr um Postgres atrás dela, siga a seção de RDS da nota do ECS.One repository, two folders, one image per folder. The API is stateless on purpose (no database): it says which task answered, which makes the ALB splitting traffic visible. To put Postgres behind it, follow the RDS section of the ECS note.
mural/
├── api/
│ ├── Dockerfile
│ ├── package.json
│ └── server.js
├── web/
│ ├── Dockerfile
│ ├── nginx.conf
│ ├── package.json (+ package-lock.json)
│ ├── vite.config.js
│ ├── index.html
│ └── src/main.jsx
├── .aws/
│ ├── task-definition.web.json
│ └── task-definition.api.json
├── compose.yaml (local test)
├── local/alb.conf (local test)
└── .github/workflows/deploy.yml2.1 A API (back)2.1 The API (back end)
Node puro, sem dependências. Repare que toda rota começa com /api: o ALB não remove o prefixo, ele entrega /api/info do jeito que chegou.Plain Node, no dependencies. Note that every route starts with /api: the ALB does not strip the prefix, it delivers /api/info as it arrived.
// api/server.js
import http from 'node:http';
import os from 'node:os';
const PORT = Number(process.env.PORT || 3000);
const VERSION = process.env.APP_VERSION || 'dev';
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
}
const server = http.createServer((req, res) => {
const { pathname, searchParams } = new URL(req.url, 'http://localhost');
// Shallow: "is the process up". The ALB and the container health check call it.
if (req.method === 'GET' && pathname === '/api/health') return json(res, 200, { ok: true });
// Which task answered: refresh the page and watch it change between the two tasks.
if (req.method === 'GET' && pathname === '/api/info') {
return json(res, 200, {
task: os.hostname(),
version: VERSION,
time: new Date().toISOString(),
// Cloudflare puts the visitor's IP here; X-Forwarded-For starts with Cloudflare's own hop.
visitor: req.headers['cf-connecting-ip'] ?? null,
});
}
if (req.method === 'GET' && pathname === '/api/hello') {
const name = (searchParams.get('name') || 'world').slice(0, 50);
return json(res, 200, { message: `hello, ${name}` });
}
return json(res, 404, { error: 'not found' });
});
server.listen(PORT, () => console.log(`api listening on ${PORT}, version ${VERSION}`));
// ECS sends SIGTERM, then SIGKILL after stopTimeout (30 s by default).
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
server.closeIdleConnections();
});// api/package.json
{
"name": "mural-api",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"scripts": { "start": "node server.js" }
}# api/Dockerfile
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json server.js ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]cache-control: no-storeem toda resposta. A Cloudflare já não guarda JSON por padrão, mas o cabeçalho deixa a intenção explícita para qualquer cache no caminho.cache-control: no-storeon every response. Cloudflare already does not store JSON by default, but the header makes the intent explicit to any cache on the way.cf-connecting-ip. Atrás de Cloudflare e ALB, o IP da conexão é o do ALB e o primeiro doX-Forwarded-Foré o da Cloudflare. O IP do visitante vem nesse cabeçalho, que o ALB repassa intacto.cf-connecting-ip. Behind Cloudflare and the ALB, the connection's IP is the ALB's and the first inX-Forwarded-Foris Cloudflare's. The visitor's IP comes in this header, which the ALB passes through untouched.- Parada limpa. No deploy o ECS manda
SIGTERM; o servidor para de aceitar conexões e sai. Medido localmente:docker compose stop apilevou 0,5 s, em vez dos 10 s do timeout do Docker.Clean stop. On deploy ECS sendsSIGTERM; the server stops accepting connections and exits. Measured locally:docker compose stop apitook 0.5 s, instead of Docker's 10 s timeout.
2.2 O front (React + Vite, servido pelo nginx)2.2 The front end (React + Vite, served by nginx)
O ponto central: o front chama /api/info, um caminho relativo. Em produção o navegador manda para o mesmo domínio da página, e o ALB encaminha para a API. Em desenvolvimento, o proxy do Vite faz o mesmo papel. A mesma imagem do front serve para dev, homologação e produção, porque ela não carrega nenhuma URL de API.The key point: the front end calls /api/info, a relative path. In production the browser sends it to the page's own domain and the ALB forwards it to the API. In development, Vite's proxy plays the same role. The same front-end image serves dev, staging and production, because it carries no API URL.
// web/vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
// In development, /api goes to the API on your machine: the same path the ALB uses in the cloud.
server: { proxy: { '/api': 'http://localhost:3000' } },
});// web/src/main.jsx
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
function App() {
const [info, setInfo] = useState(null);
const [error, setError] = useState(null);
async function load() {
try {
// Relative URL: same domain as the page. No CORS, no API URL baked into the build.
const res = await fetch('/api/info');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setInfo(await res.json());
setError(null);
} catch (err) {
setError(String(err));
}
}
useEffect(() => { load(); }, []);
return (
<main style={{ fontFamily: 'system-ui', maxWidth: 480, margin: '3rem auto' }}>
<h1>Mural</h1>
{error && <p>API error: {error}</p>}
{info && <p>Task <code>{info.task}</code> · version {info.version} · {info.time}</p>}
<button onClick={load}>Ask again</button>
</main>
);
}
createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMode>);O index.html é o padrão do Vite (um <div id="root"> e o <script type="module" src="/src/main.jsx">), e o package.json tem react, react-dom, vite e @vitejs/plugin-react. Rode npm install uma vez e versione o package-lock.json: o npm ci do Dockerfile exige esse arquivo.The index.html is Vite's default (a <div id="root"> and the <script type="module" src="/src/main.jsx">), and package.json lists react, react-dom, vite and @vitejs/plugin-react. Run npm install once and commit package-lock.json: the Dockerfile's npm ci requires it.
# web/nginx.conf
server {
listen 8080;
root /usr/share/nginx/html;
server_tokens off;
# Health check for the ALB and the container. Does not touch the disk.
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# Vite puts a hash in every file name under /assets: safe to cache for a year.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Single-page app: any other path returns index.html, which is never cached.
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}# web/Dockerfile
# Stage 1: build the static files with Node
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: serve them with nginx, as a non-root user, on port 8080
FROM nginxinc/nginx-unprivileged:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080- Dois estágios. O Node só existe para gerar o
dist/; a imagem final tem apenas nginx e os arquivos. Medido: 82 MB para o front contra 239 MB da API (que precisa do Node para rodar).Two stages. Node only exists to producedist/; the final image has only nginx and the files. Measured: 82 MB for the front end against 239 MB for the API (which needs Node to run). nginx-unprivileged. Roda como o usuárionginx(uid 101), não como root, e por isso escuta na 8080 (portas abaixo de 1024 exigem root).nginx-unprivileged. Runs as thenginxuser (uid 101), not root, and so listens on 8080 (ports below 1024 require root).try_files ... /index.html. Sem essa linha, abrirexample.com/perfildireto (ou dar F5 nela) dá 404, porque não existe arquivoperfil: quem conhece essa rota é o React, no navegador.try_files ... /index.html. Without this line, openingexample.com/profiledirectly (or refreshing it) gives a 404, because there is noprofilefile: the route is known only to React, in the browser.- Duas regras de cache. Os arquivos em
/assets/têm hash no nome (index-ChZJ-1oU.js): um conteúdo novo gera um nome novo, então podem ficar em cache por um ano. Oindex.htmlfica comno-cache, para que cada deploy chegue ao visitante na hora.Two cache rules. Files in/assets/have a hash in their name (index-ChZJ-1oU.js): new content means a new name, so they can be cached for a year.index.htmlgetsno-cache, so each deploy reaches visitors right away.
Os dois projetos levam um .dockerignore com node_modules, dist, .git, .env e *.key, para que nada local nem segredo entre na imagem.Both projects get a .dockerignore with node_modules, dist, .git, .env and *.key, so nothing local and no secret goes into the image.
2.3 Testar localmente, com um “ALB” de mentira2.3 Test locally, with a fake “ALB”
Antes de pagar por um ALB, imite as regras dele com um nginx na frente das duas imagens. Se funcionar aqui, as regras na AWS são a tradução direta. Os read_only imitam o readonlyRootFilesystem que a task definition vai usar.Before paying for an ALB, imitate its rules with an nginx in front of both images. If it works here, the AWS rules are a direct translation. The read_only flags imitate the readonlyRootFilesystem the task definition will use.
# local/alb.conf: plays the ALB's listener rules on your machine
server {
listen 80;
location /api/ { proxy_pass http://api:3000; } # rule: path-pattern /api/*
location / { proxy_pass http://web:8080; } # default action
}# compose.yaml
services:
web:
build: ./web
read_only: true
tmpfs: [/tmp] # nginx writes its temp files here
api:
build: ./api
read_only: true
environment:
APP_VERSION: local
alb:
image: nginx:1.29-alpine
volumes: ["./local/alb.conf:/etc/nginx/conf.d/default.conf:ro"]
ports: ["8000:80"]
depends_on: [web, api]docker compose up -d --build
curl -s localhost:8000/api/info # JSON from the API
curl -s -H 'CF-Connecting-IP: 203.0.113.7' localhost:8000/api/info # "visitor":"203.0.113.7"
curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/any/react/route # 200: index.html
curl -sI localhost:8000/ | grep -i cache-control # no-cache
# open http://localhost:8000 in the browser
docker compose downResultado desta execução: / devolveu o HTML com Cache-Control: no-cache; /assets/index-ChZJ-1oU.js veio com public, max-age=31536000, immutable; uma rota inventada devolveu 200 com o index.html; /assets/nao-existe.js deu 404 (e não o HTML, que quebraria o navegador com um erro confuso); e /api/info respondeu o JSON com o IP do cabeçalho.Results from this run: / returned the HTML with Cache-Control: no-cache; /assets/index-ChZJ-1oU.js came with public, max-age=31536000, immutable; a made-up route returned 200 with index.html; /assets/missing.js gave 404 (not the HTML, which would break the browser with a confusing error); and /api/info answered the JSON with the header's IP.
tmpfs: [/tmp], o nginx não sobe com o disco somente leitura: mkdir() "/tmp/proxy_temp" failed (30: Read-only file system). No Fargate a solução é um volume montado em /tmp (seção 6). (2) Dentro do container do nginx, wget http://localhost:8080/healthz deu Connection refused: localhost resolveu para o IPv6 ::1, e o nginx só escuta em IPv4. Com 127.0.0.1 funcionou. Por isso os health checks da seção 6 usam o IP, não o nome.Two traps that showed up in testing. (1) Without tmpfs: [/tmp], nginx does not start with a read-only disk: mkdir() "/tmp/proxy_temp" failed (30: Read-only file system). On Fargate the fix is a volume mounted at /tmp (section 6). (2) Inside the nginx container, wget http://localhost:8080/healthz gave Connection refused: localhost resolved to IPv6 ::1, and nginx only listens on IPv4. With 127.0.0.1 it worked. That is why the health checks in section 6 use the IP, not the name.3. Cloudflare: modo SSL e certificado de origem3. Cloudflare: SSL mode and origin certificate
Domínio comprado na Cloudflare já nasce usando o DNS dela: o Cloudflare Registrar exige os nameservers da Cloudflare, então não há nada a apontar no registrador. Faltam três ajustes no painel, todos em SSL/TLS.A domain bought at Cloudflare already uses its DNS: Cloudflare Registrar requires Cloudflare's nameservers, so there is nothing to point at the registrar. Three settings remain in the dashboard, all under SSL/TLS.
- SSL/TLS → Overview → Full (strict). A Cloudflare só conecta ao ALB se o certificado dele for válido, não vencido e cobrir o hostname, emitido por uma CA pública ou pela Origin CA da Cloudflare. Nos modos Flexible ou Full (sem strict), qualquer um no meio do caminho poderia se passar pelo seu ALB.SSL/TLS → Overview → Full (strict). Cloudflare only connects to the ALB if its certificate is valid, unexpired and covers the hostname, issued by a public CA or Cloudflare's Origin CA. In Flexible or Full (non-strict) modes, anyone in the path could impersonate your ALB.
- SSL/TLS → Origin Server → Create Certificate. Deixe a Cloudflare gerar a chave (RSA 2048 serve). Os hostnames padrão já são os certos para este desenho:
example.come*.example.com, o que cobre também owwwe um eventualapi.. Escolha a validade e o formato PEM.SSL/TLS → Origin Server → Create Certificate. Let Cloudflare generate the key (RSA 2048 is fine). The default hostnames are already right for this design:example.comand*.example.com, which also coverswwwand a possibleapi.. Pick the validity and the PEM format. - Salve o certificado em
origin.peme a chave emorigin.keyantes de sair da tela: a chave não pode ser vista de novo.Save the certificate asorigin.pemand the key asorigin.keybefore leaving the screen: the key cannot be viewed again. - SSL/TLS → Edge Certificates → Always Use HTTPS: On. O visitante que digitar
http://é redirecionado na Cloudflare, antes de chegar à AWS. Por isso o ALB não precisa de listener na porta 80.SSL/TLS → Edge Certificates → Always Use HTTPS: On. A visitor who typeshttp://is redirected at Cloudflare, before reaching AWS. That is why the ALB needs no port 80 listener.
A versão com capturas de tela de cada opção está na seção de ALB da nota da Cloudflare. O certificado entra na AWS na seção 7.The version with screenshots of each option is in the ALB section of the Cloudflare note. The certificate goes into AWS in section 7.
4. Rede e security groups4. Network and security groups
A rede é a mesma de um app só, com um security group a mais. O ALB fica nas subnets públicas; as tasks dos dois services ficam nas privadas, sem IP público. Cada service tem o seu security group, que só aceita o ALB e só na porta do seu container.The network is the same as for a single app, with one more security group. The ALB sits in the public subnets; both services' tasks sit in the private ones, with no public IP. Each service has its own security group, which only accepts the ALB and only on its container's port.
- Console: VPC → Create VPC → VPC and more. Nome
mural,10.0.0.0/16, 2 AZs, 2 subnets públicas e 2 privadas, NAT gateways: None, VPC endpoints: S3 Gateway, DNS hostnames e DNS resolution ligados.Console: VPC → Create VPC → VPC and more. Namemural,10.0.0.0/16, 2 AZs, 2 public and 2 private subnets, NAT gateways: None, VPC endpoints: S3 Gateway, DNS hostnames and DNS resolution on. - Anote os IDs nas variáveis abaixo; os comandos das próximas seções usam todas elas.Put the IDs in the variables below; the commands in the next sections use all of them.
REGION=sa-east-1
ACCOUNT_ID=<ACCOUNT_ID>
DOMAIN=example.com
VPC_ID=<vpc-id>
PUB_A=<subnet-public-a>; PUB_B=<subnet-public-b>
PRIV_A=<subnet-private-a>; PRIV_B=<subnet-private-b>| GrupoGroup | Entrada permitidaAllowed inbound | Por quêWhy |
|---|---|---|
mural-albmural-alb | 443 só dos IPs da Cloudflare (prefix list)443 only from Cloudflare's IPs (prefix list) | Quem tentar o DNS do ALB direto, pulando a Cloudflare, nem conecta.Anyone trying the ALB's DNS name directly, skipping Cloudflare, cannot even connect. |
mural-webmural-web | 8080 de mural-alb8080 from mural-alb | Só o ALB fala com o nginx.Only the ALB talks to nginx. |
mural-apimural-api | 3000 de mural-alb3000 from mural-alb | Só o ALB fala com a API. O front não chama a API por dentro: quem chama é o navegador, passando pelo ALB.Only the ALB talks to the API. The front end does not call the API internally: the browser does, through the ALB. |
mural-vpcemural-vpce | 443 de mural-web e de mural-api443 from mural-web and mural-api | As tasks baixam a imagem e mandam logs pelos endpoints, sem internet.Tasks pull the image and send logs through the endpoints, without internet. |
mksg() { aws ec2 create-security-group --group-name "$1" --description "$1" --vpc-id $VPC_ID --query GroupId --output text; }
SG_ALB=$(mksg mural-alb); SG_WEB=$(mksg mural-web); SG_API=$(mksg mural-api); SG_VPCE=$(mksg mural-vpce)
allow() { aws ec2 authorize-security-group-ingress --group-id "$1" --protocol tcp --port "$2" --source-group "$3"; }
allow $SG_WEB 8080 $SG_ALB
allow $SG_API 3000 $SG_ALB
allow $SG_VPCE 443 $SG_WEB
allow $SG_VPCE 443 $SG_API
# Cloudflare's published IPv4 ranges as a managed prefix list, allowed on 443
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}]"
# Interface endpoints: pull images from ECR and write logs without a NAT gateway
for SVC in ecr.api ecr.dkr logs; 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- Sem
secretsmanager. Como os containers não recebem certificado nem senha, este desenho não precisa do endpoint do Secrets Manager. Se a API ganhar um banco, acrescente-o (e veja a seção de rede da nota do ECS, que explica cada endpoint).Nosecretsmanager. Since the containers get no certificate or password, this design does not need the Secrets Manager endpoint. If the API gets a database, add it (and see the network section of the ECS note, which explains each endpoint). - A lista da Cloudflare muda. Revise a prefix list de tempos em tempos (
modify-managed-prefix-list). Uma faixa nova que falte aparece como erro 522 para parte dos visitantes.Cloudflare's list changes. Review the prefix list now and then (modify-managed-prefix-list). A missing new range shows up as a 522 error for some visitors. - API externa? Se a API chamar algo fora da AWS (pagamento, e-mail), as subnets privadas precisam de um NAT gateway; sem saída para a internet, a chamada só expira.External API? If the API calls something outside AWS (payments, email), the private subnets need a NAT gateway; without a way out to the internet, the call just times out.
5. Dois repositórios no ECR e as primeiras imagens5. Two ECR repositories and the first images
Uma imagem, um repositório. Tags imutáveis garantem que :abc123 sempre aponte para o mesmo conteúdo. Um service só sobe com uma imagem que exista, então envie a primeira de cada à mão, uma única vez, com a tag bootstrap; depois disso quem envia é o pipeline.One image, one repository. Immutable tags guarantee :abc123 always points to the same content. A service only starts with an image that exists, so push the first of each by hand, once, with the bootstrap tag; after that the pipeline does the pushing.
for APP in web api; do
aws ecr create-repository --repository-name mural-$APP \
--image-tag-mutability IMMUTABLE --image-scanning-configuration scanOnPush=true
done
REGISTRY=$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com
aws ecr get-login-password | docker login --username AWS --password-stdin $REGISTRY
for APP in web api; do
docker build --platform linux/amd64 -t $REGISTRY/mural-$APP:bootstrap ./$APP
docker push $REGISTRY/mural-$APP:bootstrap
done--platform linux/amd64. A task definition abaixo pede X86_64. Num Mac com Apple Silicon o docker build gera ARM por padrão, e a task para com exec format error. O runner do GitHub já é x86_64.--platform linux/amd64. The task definition below asks for X86_64. On an Apple Silicon Mac docker build produces ARM by default, and the task stops with exec format error. GitHub's runner is already x86_64.6. Roles, logs e as duas task definitions6. Roles, logs and the two task definitions
Uma única execution role serve aos dois services: ela é usada pelo agente do ECS para baixar a imagem e escrever logs, antes de o container iniciar. Nenhum dos dois apps chama APIs da AWS, então não há task role. Um cluster agrupa os dois services.A single execution role serves both services: the ECS agent uses it to pull the image and write logs, before the container starts. Neither app calls AWS APIs, so there is no task role. One cluster groups both services.
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 muralExecutionRole --assume-role-policy-document file://ecs-trust.json
aws iam attach-role-policy --role-name muralExecutionRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
for APP in web api; do
aws logs create-log-group --log-group-name /ecs/mural-$APP
aws logs put-retention-policy --log-group-name /ecs/mural-$APP --retention-in-days 14
done
aws ecs create-cluster --cluster-name mural6.1 Front: .aws/task-definition.web.json6.1 Front end: .aws/task-definition.web.json
{
"family": "mural-web",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
"executionRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/muralExecutionRole",
"volumes": [{ "name": "tmp" }],
"containerDefinitions": [
{
"name": "web",
"image": "<ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/mural-web:bootstrap",
"essential": true,
"readonlyRootFilesystem": true,
"mountPoints": [{ "sourceVolume": "tmp", "containerPath": "/tmp" }],
"portMappings": [{ "containerPort": 8080, "protocol": "tcp" }],
"healthCheck": {
"command": ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1"],
"interval": 15, "timeout": 5, "retries": 3, "startPeriod": 10
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/mural-web",
"awslogs-region": "<REGION>",
"awslogs-stream-prefix": "web"
}
}
}
]
}6.2 Back: .aws/task-definition.api.json6.2 Back end: .aws/task-definition.api.json
{
"family": "mural-api",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
"executionRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/muralExecutionRole",
"containerDefinitions": [
{
"name": "api",
"image": "<ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/mural-api:bootstrap",
"essential": true,
"readonlyRootFilesystem": true,
"stopTimeout": 30,
"portMappings": [{ "containerPort": 3000, "protocol": "tcp" }],
"environment": [
{ "name": "PORT", "value": "3000" },
{ "name": "APP_VERSION", "value": "bootstrap" }
],
"healthCheck": {
"command": ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1"],
"interval": 15, "timeout": 5, "retries": 3, "startPeriod": 10
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/mural-api",
"awslogs-region": "<REGION>",
"awslogs-stream-prefix": "api"
}
}
}
]
}aws ecs register-task-definition --cli-input-json file://.aws/task-definition.web.json
aws ecs register-task-definition --cli-input-json file://.aws/task-definition.api.json- O volume
tmp. Um volume semhostno Fargate é um bind mount no armazenamento efêmero da task: some quando a task para. Ele dá ao nginx o/tmpgravável que o teste local mostrou ser necessário, enquanto o resto do disco continua somente leitura.Thetmpvolume. A volume with nohoston Fargate is a bind mount on the task's ephemeral storage: it disappears when the task stops. It gives nginx the writable/tmpthe local test showed it needs, while the rest of the disk stays read-only. - Health check com
wgete127.0.0.1. As duas imagens são Alpine, que traz owgetdo BusyBox e não trazcurl. O IP evita a armadilha dolocalhost→ IPv6.Health check withwgetand127.0.0.1. Both images are Alpine, which ships BusyBoxwgetand nocurl. The IP avoids thelocalhost→ IPv6 trap. - Nomes dos containers.
webeapisão usados de novo no service (para ligar ao target group) e no workflow (para trocar a imagem). Os três lugares precisam bater.Container names.webandapiare used again in the service (to attach to the target group) and in the workflow (to swap the image). All three places must match. - 256 CPU e 512 MB é a menor combinação do Fargate e sobra para os dois. O nginx servindo arquivos estáticos quase não usa CPU.256 CPU and 512 MB is Fargate's smallest combination and is plenty for both. nginx serving static files barely uses CPU.
7. ALB, a regra /api/* e os dois services7. ALB, the /api/* rule and the two services
Aqui está o coração do desenho. O ALB tem um listener HTTPS na 443 com o certificado da Cloudflare, e o listener tem regras: cada regra tem uma condição e um destino. As regras são avaliadas da menor prioridade para a maior; se nenhuma bate, vale a ação padrão.This is the heart of the design. The ALB has one HTTPS listener on 443 with the Cloudflare certificate, and the listener has rules: each rule has a condition and a target. Rules are evaluated from lowest priority number to highest; if none matches, the default action applies.
| PrioridadePriority | CondiçãoCondition | DestinoTarget |
|---|---|---|
| 1010 | path-pattern /api/*path-pattern /api/* | target group mural-api (porta 3000)target group mural-api (port 3000) |
| padrãodefault | qualquer outro pedidoany other request | target group mural-web (porta 8080)target group mural-web (port 8080) |
7.1 Certificado no ACM7.1 Certificate in ACM
Importe na mesma região do ALB. O ACM exige PEM e chave sem senha. Depois de importar, a chave não precisa ficar no seu disco.Import into the same region as the ALB. ACM requires PEM and an unencrypted key. After importing, the key does not need to stay on your disk.
CERT_ARN=$(aws acm import-certificate \
--certificate fileb://origin.pem --private-key fileb://origin.key \
--query CertificateArn --output text)
rm origin.key # keep a copy only in your password manager, if at all7.2 Dois target groups7.2 Two target groups
Um por service, do tipo ip (obrigatório no modo awsvpc do Fargate), falando HTTP com o container. Cada um tem o seu health check, no caminho que o próprio app responde.One per service, of type ip (required in Fargate's awsvpc mode), speaking HTTP to the container. Each has its own health check, on the path that app itself answers.
TG_WEB=$(aws elbv2 create-target-group --name mural-web --target-type ip \
--protocol HTTP --port 8080 --vpc-id $VPC_ID \
--health-check-path /healthz --health-check-interval-seconds 15 --healthy-threshold-count 2 \
--query 'TargetGroups[0].TargetGroupArn' --output text)
TG_API=$(aws elbv2 create-target-group --name mural-api --target-type ip \
--protocol HTTP --port 3000 --vpc-id $VPC_ID \
--health-check-path /api/health --health-check-interval-seconds 15 --healthy-threshold-count 2 \
--query 'TargetGroups[0].TargetGroupArn' --output text)
for TG in $TG_WEB $TG_API; do
aws elbv2 modify-target-group-attributes --target-group-arn $TG \
--attributes Key=deregistration_delay.timeout_seconds,Value=30
done7.3 ALB, listener e a regra7.3 ALB, listener and the rule
ALB_ARN=$(aws elbv2 create-load-balancer --name mural --type application --scheme internet-facing \
--subnets $PUB_A $PUB_B --security-groups $SG_ALB \
--query 'LoadBalancers[0].LoadBalancerArn' --output text)
# HTTPS listener: Cloudflare origin certificate; default action = front end
LISTENER_ARN=$(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_WEB \
--query 'Listeners[0].ListenerArn' --output text)
# Rule: /api/* goes to the API
aws elbv2 create-rule --listener-arn $LISTENER_ARN --priority 10 \
--conditions Field=path-pattern,Values='/api/*' \
--actions Type=forward,TargetGroupArn=$TG_API
ALB_DNS=$(aws elbv2 describe-load-balancers --load-balancer-arns $ALB_ARN \
--query 'LoadBalancers[0].DNSName' --output text); echo $ALB_DNS/api/*não casa com/api. O padrão exige algo depois da barra. Se o front chamar/apipuro, o pedido cai na ação padrão e volta oindex.html. O padrão também diferencia maiúsculas:/API/infovai para o front./api/*does not match/api. The pattern needs something after the slash. If the front end calls bare/api, the request falls to the default action and getsindex.htmlback. The pattern is also case-sensitive:/API/infogoes to the front end.- Por que o front é o padrão. O front precisa responder qualquer caminho (as rotas do React), e a API só os que começam com
/api/. Deixar o caso amplo como padrão e o específico como regra é o mais simples de ler.Why the front end is the default. The front end must answer any path (React's routes), and the API only those starting with/api/. Leaving the broad case as the default and the specific one as a rule is the simplest to read. - Sem listener na 80. O
mural-albnem aceita a porta 80; o redirecionamento para HTTPS é feito pela Cloudflare (seção 3).No listener on 80.mural-albdoes not even accept port 80; the HTTPS redirect is done by Cloudflare (section 3).
7.4 Os dois services7.4 The two services
aws iam create-service-linked-role --aws-service-name ecs.amazonaws.com # once per account; ignore "already exists"
svc() { # $1 = web|api, $2 = target group, $3 = port, $4 = security group
aws ecs create-service --cluster mural --service-name mural-$1 \
--task-definition mural-$1 --desired-count 2 --launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$PRIV_A,$PRIV_B],securityGroups=[$4],assignPublicIp=DISABLED}" \
--load-balancers targetGroupArn=$2,containerName=$1,containerPort=$3 \
--health-check-grace-period-seconds 30 \
--deployment-configuration "minimumHealthyPercent=100,maximumPercent=200,deploymentCircuitBreaker={enable=true,rollback=true}"
}
svc web $TG_WEB 8080 $SG_WEB
svc api $TG_API 3000 $SG_API
aws ecs wait services-stable --cluster mural --services mural-web mural-api- Duas tasks por service, uma em cada AZ. Uma zona pode cair sem tirar o site do ar, e no deploy as novas sobem antes de as antigas saírem (100/200).Two tasks per service, one in each AZ. A zone can fail without taking the site down, and on deploy the new ones start before the old ones leave (100/200).
- Cada service escala sozinho. Se a API precisar de mais tasks, mude só o
desired-countdela (ou ponha auto scaling); o front continua com duas. Essa é a vantagem prática de separar as imagens.Each service scales on its own. If the API needs more tasks, change only itsdesired-count(or add auto scaling); the front end keeps two. That is the practical advantage of splitting the images. - Circuit breaker com rollback. Se as tasks novas não ficarem saudáveis, o ECS volta sozinho para a revisão anterior.Circuit breaker with rollback. If the new tasks do not become healthy, ECS rolls back to the previous revision by itself.
7.5 Variante: API num subdomínio7.5 Variant: API on a subdomain
Se você escolheu api.example.com na seção 1, troque só a condição da regra, crie o registro DNS api igual aos da seção 8 e ligue o CORS na API. As rotas da API podem então deixar de ter o prefixo /api.If you chose api.example.com in section 1, change only the rule's condition, create the api DNS record like the ones in section 8 and turn on CORS in the API. The API's routes can then drop the /api prefix.
aws elbv2 create-rule --listener-arn $LISTENER_ARN --priority 10 \
--conditions Field=host-header,Values=api.$DOMAIN \
--actions Type=forward,TargetGroupArn=$TG_API8. DNS na Cloudflare e o caminho de cada pedido8. DNS at Cloudflare and each request's path
- Cloudflare → DNS → Records → Add record: tipo
CNAME, nome@(a raiz,example.com), destino = o$ALB_DNS, Proxied.Cloudflare → DNS → Records → Add record: typeCNAME, name@(the apex,example.com), target =$ALB_DNS, Proxied. - Mais um
CNAME, nomewww, mesmo destino, Proxied. Se quiser um endereço só, crie uma Redirect Rule dewww.example.com/*parahttps://example.com/${1}(301).AnotherCNAME, namewww, same target, Proxied. If you want a single address, create a Redirect Rule fromwww.example.com/*tohttps://example.com/${1}(301).
O que acontece quando alguém abre o siteWhat happens when someone opens the site
- O cache padrão da Cloudflare decide pela extensão do arquivo: guarda
.js,.css, imagens e fontes, e não guarda HTML nem respostas sem extensão como/api/info. Para este desenho o padrão já está certo; você não precisa de nenhuma regra de cache.Cloudflare's default cache decides by file extension: it stores.js,.css, images and fonts, and does not store HTML or extensionless responses like/api/info. For this design the default is already right; you need no cache rule. - Por que isso combina com o nginx. Os arquivos de
/assets/mudam de nome a cada build, então um cache longo nunca serve versão velha. Oindex.htmlnão é guardado, e é ele que aponta para os nomes novos. Resultado: o deploy aparece na hora, sem purgar cache.Why that fits the nginx setup. Files in/assets/change name on every build, so a long cache never serves a stale version.index.htmlis not stored, and it is what points to the new names. Result: a deploy shows up immediately, with no cache purge.
X-Origin-Auth) e faça as regras do ALB exigirem esse cabeçalho (condição http-header), com a ação padrão devolvendo 403.One step further: only your Cloudflare zone. The prefix list ensures traffic comes from Cloudflare, but any Cloudflare customer could point their own domain at your ALB. To close that, create a Cloudflare Transform Rule that adds a secret header to every request (for example X-Origin-Auth) and make the ALB rules require that header (http-header condition), with the default action returning 403.9. Deploy das duas imagens9. Deploying both images
O workflow é o da nota de OIDC com uma diferença: uma matrix roda o mesmo job duas vezes, uma para web e outra para api. Cada execução constrói a sua pasta, envia ao seu repositório e atualiza o seu service.The workflow is the one from the OIDC note with one difference: a matrix runs the same job twice, once for web and once for api. Each run builds its folder, pushes to its repository and updates its service.
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write # OIDC: GitHub proves who it is to AWS, no stored keys
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
strategy:
fail-fast: false # a failing web deploy does not cancel the api one
matrix:
app: [web, api]
steps:
- uses: actions/checkout@v6
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- id: ecr
uses: aws-actions/amazon-ecr-login@v2
- id: image
env:
IMAGE: ${{ steps.ecr.outputs.registry }}/mural-${{ matrix.app }}:${{ github.sha }}
run: |
docker build -t "$IMAGE" ./${{ matrix.app }}
docker push "$IMAGE"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
- id: taskdef
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: .aws/task-definition.${{ matrix.app }}.json
container-name: ${{ matrix.app }}
image: ${{ steps.image.outputs.image }}
environment-variables: APP_VERSION=${{ github.sha }}
- uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.taskdef.outputs.task-definition }}
cluster: mural
service: mural-${{ matrix.app }}
wait-for-service-stability: true- Pré-requisitos. O provedor OIDC e a role de deploy (com
iam:PassRolepara amuralExecutionRole) vêm das etapas 1 e 2 da nota de OIDC; as variáveisAWS_ROLE_ARNeAWS_REGIONficam no environmentproductiondo GitHub. A política da role precisa cobrir os dois repositórios do ECR e os dois services.Prerequisites. The OIDC provider and the deploy role (withiam:PassRoleformuralExecutionRole) come from steps 1 and 2 of the OIDC note; theAWS_ROLE_ARNandAWS_REGIONvariables live in GitHub'sproductionenvironment. The role's policy must cover both ECR repositories and both services. APP_VERSIONcom o SHA. A action de render acrescenta a variável ao container; abrindo o site você vê qual commit está no ar. No nginx a variável existe mas não é usada, o que não faz mal.APP_VERSIONwith the SHA. The render action adds the variable to the container; opening the site shows which commit is live. In nginx the variable exists but is unused, which is harmless.- Só o que mudou. Este workflow publica as duas imagens a cada push, o que é simples e previsível. Se os builds ficarem lentos, filtre por pasta (por exemplo, com dois workflows e
on.push.paths:web/**eapi/**).Only what changed. This workflow ships both images on every push, which is simple and predictable. If builds get slow, filter by folder (for example, two workflows withon.push.paths:web/**andapi/**).
10. Conferir e diagnosticar10. Checking and troubleshooting
# 1) both services stable, all targets healthy
aws ecs describe-services --cluster mural --services mural-web mural-api \
--query 'services[].{name:serviceName,running:runningCount,desired:desiredCount}'
for TG in $TG_WEB $TG_API; do
aws elbv2 describe-target-health --target-group-arn $TG --query 'TargetHealthDescriptions[].TargetHealth.State'
done
# 2) through Cloudflare, end to end
curl -sI https://$DOMAIN/ | grep -i 'cache-control\|cf-cache-status' # no-cache, DYNAMIC
curl -s https://$DOMAIN/api/info # JSON; "task" alternates between 2 values
curl -s -o /dev/null -w '%{http_code}\n' https://$DOMAIN/some/react/route # 200
# 3) negative tests: both must FAIL or redirect
curl -m 8 -k https://$ALB_DNS/ # times out: the ALB only accepts Cloudflare
curl -sI http://$DOMAIN/ | head -1 # 301 to https, done by Cloudflare
# 4) logs
aws logs tail /ecs/mural-api --since 10m
aws logs tail /ecs/mural-web --since 10m| SintomaSymptom | Causa provávelLikely cause |
|---|---|
A página abre, mas o fetch('/api/info') recebe HTML em vez de JSON (Unexpected token '<')The page loads, but fetch('/api/info') gets HTML instead of JSON (Unexpected token '<') | A regra /api/* não existe ou não está no listener 443, e o pedido caiu na ação padrão (o nginx devolveu o index.html). Confira com aws elbv2 describe-rules --listener-arn $LISTENER_ARN.The /api/* rule does not exist or is not on the 443 listener, so the request fell to the default action (nginx returned index.html). Check with aws elbv2 describe-rules --listener-arn $LISTENER_ARN. |
/api/info responde {"error":"not found"}/api/info answers {"error":"not found"} | A regra funciona, mas o código da API registrou as rotas sem o prefixo /api. O ALB repassa o caminho inteiro.The rule works, but the API code registered its routes without the /api prefix. The ALB forwards the full path. |
| F5 numa rota do React dá 404Refreshing a React route gives 404 | Falta o try_files $uri $uri/ /index.html no nginx.conf.try_files $uri $uri/ /index.html is missing from nginx.conf. |
| Depois do deploy, alguns visitantes ainda veem o front antigoAfter deploying, some visitors still see the old front end | O index.html está sendo guardado em cache (no navegador ou por uma Cache Rule “Cache Everything” na Cloudflare). Ele precisa de no-cache; só /assets/ pode ter cache longo.index.html is being cached (in the browser or by a “Cache Everything” Cache Rule at Cloudflare). It needs no-cache; only /assets/ may be cached long-term. |
Task do web para logo ao iniciar; log: Read-only file systemweb task stops right after starting; log: Read-only file system | Falta o volume tmp montado em /tmp na task definition.The tmp volume mounted at /tmp is missing from the task definition. |
| Task unhealthy no ECS, mas o app parece funcionarTask unhealthy in ECS, yet the app seems to work | O health check do container usa localhost e caiu no IPv6, ou usa curl, que não existe na imagem Alpine. Use wget e 127.0.0.1.The container health check uses localhost and hit IPv6, or uses curl, which does not exist in the Alpine image. Use wget and 127.0.0.1. |
| Cloudflare 502; ALB 502/503Cloudflare 502; ALB 502/503 | O target group daquele caminho não tem target saudável: porta ou caminho de health check errados, ou o SG do service não aceita a porta vinda do mural-alb.The target group for that path has no healthy target: wrong port or health check path, or the service SG does not accept the port from mural-alb. |
| Cloudflare 521 ou 522Cloudflare 521 or 522 | O SG do ALB não tem os IPs da Cloudflare na 443, ou o listener 443 não existe.The ALB SG lacks Cloudflare's IPs on 443, or the 443 listener does not exist. |
| Cloudflare 525 ou 526Cloudflare 525 or 526 | O listener não usa o certificado de origem, o certificado não cobre o hostname (raiz e www), ou ele venceu.The listener does not use the origin certificate, the certificate does not cover the hostname (apex and www), or it expired. |
CannotPullContainerError ou exec format errorCannotPullContainerError or exec format error | Faltam os endpoints ecr.api/ecr.dkr/s3, a tag não existe, ou a imagem foi construída para ARM.The ecr.api/ecr.dkr/s3 endpoints are missing, the tag does not exist, or the image was built for ARM. |
| Erro de CORS no console (só na variante com subdomínio)CORS error in the console (subdomain variant only) | A API não devolve Access-Control-Allow-Origin: https://example.com ou não responde ao OPTIONS.The API does not return Access-Control-Allow-Origin: https://example.com or does not answer OPTIONS. |
Read-only file system e localhost → IPv6 foram reproduzidas localmente com as imagens desta nota. As outras causas são o meu diagnóstico a partir das documentações, não uma lista oficial.Personal note: the rows about HTML instead of JSON, Read-only file system and localhost → IPv6 were reproduced locally with this note's images. The other causes are my own diagnosis from the docs, not an official list.11. Apagar tudo11. Deleting everything
Na ordem inversa das dependências, para nada ficar preso e nada continuar cobrando por hora:In reverse dependency order, so nothing gets stuck and nothing keeps billing by the hour:
# Cloudflare: delete the @ and www records (dashboard). GitHub: disable the workflow.
for APP in web api; do
aws ecs update-service --cluster mural --service mural-$APP --desired-count 0
aws ecs delete-service --cluster mural --service mural-$APP --force
done
aws ecs delete-cluster --cluster mural
aws elbv2 delete-load-balancer --load-balancer-arn $ALB_ARN # takes the listener and rule with it
aws elbv2 wait load-balancers-deleted --load-balancer-arns $ALB_ARN
aws elbv2 delete-target-group --target-group-arn $TG_WEB
aws elbv2 delete-target-group --target-group-arn $TG_API
aws acm delete-certificate --certificate-arn $CERT_ARN
for APP in web api; do
aws ecr delete-repository --repository-name mural-$APP --force
aws logs delete-log-group --log-group-name /ecs/mural-$APP
done
# then: deregister the task definitions, delete muralExecutionRole,
# the VPC endpoints, the security groups, the prefix list and the VPC (in that order)compose.yaml com o “ALB” local, os cabeçalhos de cache, o fallback do React, o wget dos health checks e a parada com SIGTERM foram executados nesta máquina (Docker 29, Node 22, Vite 7, nginx 1.29). Os comandos aws, as task definitions e o workflow seguem as documentações abaixo e o padrão das notas vizinhas, mas não foram executados contra uma conta AWS para escrever esta nota.Personal note, on what was tested: the code, both images, the compose.yaml with the local “ALB”, the cache headers, the React fallback, the health check wget and the SIGTERM stop were run on this machine (Docker 29, Node 22, Vite 7, nginx 1.29). The aws commands, task definitions and workflow follow the docs below and the pattern of the neighbouring notes, but were not run against an AWS account to write this note.- AWS Docs · ALB: Listener rules (priority order, default action)
- AWS Docs · ALB: Rule condition types (path-pattern, host-header, http-header)
- AWS Docs · ALB: Target groups (ip target type, health checks)
- AWS Docs · ALB: Create an HTTPS listener (security policies, certificates)
- AWS Docs · ECS: Use an Application Load Balancer (one target group per service)
- AWS Docs · ECS: Task definition parameters (readonlyRootFilesystem, healthCheck, stopTimeout)
- AWS Docs · ECS: Bind mounts (ephemeral volumes on Fargate)
- AWS Docs · ECS: Task execution IAM role
- AWS Docs · ECS: Interface VPC endpoints
- AWS Docs · ACM: Prerequisites for importing certificates
- Cloudflare Docs · Registrar
- Cloudflare Docs · SSL mode Full (strict)
- Cloudflare Docs · Origin CA certificates
- Cloudflare Docs · Always Use HTTPS
- Cloudflare Docs · CNAME flattening
- Cloudflare Docs · Default cache behavior (cached file extensions)
- Cloudflare Docs · HTTP headers (CF-Connecting-IP, X-Forwarded-For)
- Cloudflare Docs · Cloudflare IP addresses
- Cloudflare Docs · Request header Transform Rules
- Cloudflare Docs · 5xx errors
- Vite Docs · server.proxy
- Docker Hub · nginxinc/nginx-unprivileged
- nginx Docs · try_files
- GitHub Docs · Using a matrix for your jobs
- GitHub · aws-actions/amazon-ecs-render-task-definition (environment-variables input)