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.

sua conta AWS · VPCyour AWS account · VPCVisitanteVisitorexample.comCloudflareDNS · proxyALB :443cert. de origem (ACM)origin cert (ACM)/api/* → apipadrão → websubnets públicaspublic subnetsweb · nginx :80802 tasks · build do React2 tasks · React buildapi · Node :30002 tasks · JSON2 tasks · JSONsubnets privadasprivate subnets12/*/api/*3
Um domínio, um ALB, dois services. O ALB olha o caminho: /api/* vai para a API, o resto vai para o nginx com o build do React.One domain, one ALB, two services. The ALB looks at the path: /api/* goes to the API, everything else goes to nginx with the React build.
TrechoLegQuem apresenta o certificadoWho presents the certificateQuem confereWho checks it
1 · navegador → Cloudflare1 · browser → CloudflareCloudflare (Universal SSL, automático e gratuito)Cloudflare (Universal SSL, automatic and free)O navegadorThe browser
2 · Cloudflare → ALB2 · Cloudflare → ALBALB, com o certificado de origem da Cloudflare importado no ACMThe ALB, with the Cloudflare origin certificate imported into ACMA Cloudflare, no modo Full (strict)Cloudflare, in Full (strict) mode
3 · ALB → containers3 · ALB → containersNinguém: HTTP dentro da VPC (veja a callout abaixo)Nobody: HTTP inside the VPC (see the callout below)——
Por que HTTP no trecho 3. O ALB termina o TLS e fala HTTP com os containers. É o desenho mais comum e o mais simples: os containers não guardam chave privada e o certificado se renova num lugar só. Se você precisa de criptografia também dentro da VPC (conformidade, defesa em profundidade), a nota do ECS do zero mostra como levar o mesmo certificado até o container.Why HTTP on leg 3. The ALB terminates TLS and speaks HTTP to the containers. It is the most common design and the simplest: the containers hold no private key and the certificate is renewed in one place. If you also need encryption inside the VPC (compliance, defense in depth), the ECS from zero note shows how to carry the same certificate into the container.

O roteiroThe route

  1. 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.
  2. 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.
  3. 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.
  4. 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.
Custo. O ALB, as tasks Fargate e os endpoints de VPC cobram por hora enquanto existirem, mesmo sem visitas. Um ALB só para os dois services já é a economia principal deste desenho (são dois apps, mas um load balancer). Ao terminar de estudar, siga a seção 11.Cost. The ALB, the Fargate tasks and the VPC endpoints bill by the hour while they exist, even with no visitors. A single ALB for both services is already the main saving in this design (two apps, one load balancer). When you finish studying, follow section 11.
Notas vizinhas. Compra do domínio e painel da Cloudflare na nota da Cloudflare; VPC, security groups e prefix lists na nota de AWS; Dockerfile e imagens na nota de Docker; GitHub entrando na AWS sem chaves na nota de OIDC; banco RDS e segredos na nota do ECS do zero. Esta nota junta as peças para o caso de dois containers.Neighbouring notes. Buying the domain and the Cloudflare dashboard in the Cloudflare note; VPC, security groups and prefix lists in the AWS note; Dockerfiles and images in the Docker note; GitHub signing in to AWS without keys in the OIDC note; RDS database and secrets in the ECS from zero note. This note puts the pieces together for the two-container case.

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
URLsURLsexample.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 conditionpath-pattern = /api/*path-pattern = /api/*host-header = api.example.comhost-header = api.example.com
CORSCORSNão existe: é a mesma origemDoes not exist: same originObrigató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 cookiesFuncionam sem configuração extraWork with no extra setupPrecisam 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 buildNenhuma: 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 codeAs 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 itUm site e a sua própria API: o caso comumA site and its own API: the common caseA 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.

E se o front não usasse container? Um front que é só arquivo estático pode ir para o S3 com CloudFront, ou para o Cloudflare Pages, e custar quase nada. A imagem com nginx faz sentido quando você quer o mesmo fluxo de deploy para as duas partes, versionar front e back juntos, ou quando o front tem servidor (SSR). É o caso desta nota.What if the front end did not use a container? A front end that is only static files can go to S3 with CloudFront, or to Cloudflare Pages, and cost almost nothing. The nginx image makes sense when you want the same deploy flow for both parts, to version front and back together, or when the front end has a server (SSR). That is the case in this note.

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.yml

2.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-store em 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-store on 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 do X-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 in X-Forwarded-For is 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 api levou 0,5 s, em vez dos 10 s do timeout do Docker.Clean stop. On deploy ECS sends SIGTERM; the server stops accepting connections and exits. Measured locally: docker compose stop api took 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 produce dist/; 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ário nginx (uid 101), não como root, e por isso escuta na 8080 (portas abaixo de 1024 exigem root).nginx-unprivileged. Runs as the nginx user (uid 101), not root, and so listens on 8080 (ports below 1024 require root).
  • try_files ... /index.html. Sem essa linha, abrir example.com/perfil direto (ou dar F5 nela) dá 404, porque não existe arquivo perfil: quem conhece essa rota é o React, no navegador.try_files ... /index.html. Without this line, opening example.com/profile directly (or refreshing it) gives a 404, because there is no profile file: 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. O index.html fica com no-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.html gets no-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 down

Resultado 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.

Duas armadilhas que apareceram no teste. (1) Sem o 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.

  1. 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.
  2. 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.com e *.example.com, o que cobre também o www e um eventual api.. 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.com and *.example.com, which also covers www and a possible api.. Pick the validity and the PEM format.
  3. Salve o certificado em origin.pem e a chave em origin.key antes de sair da tela: a chave não pode ser vista de novo.Save the certificate as origin.pem and the key as origin.key before leaving the screen: the key cannot be viewed again.
  4. 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 types http:// is redirected at Cloudflare, before reaching AWS. That is why the ALB needs no port 80 listener.
Esse certificado só vale para a Cloudflare. Navegadores não confiam na Origin CA. Se o registro DNS ficar como DNS only (nuvem cinza) ou o proxy for pausado, o visitante vê erro de certificado. Os registros deste desenho ficam sempre com proxy (nuvem laranja). E a Cloudflare não avisa quando o certificado de origem vai vencer: anote a data no calendário.This certificate is only valid for Cloudflare. Browsers do not trust the Origin CA. If the DNS record is left as DNS only (grey cloud) or the proxy is paused, visitors get a certificate error. The records in this design are always proxied (orange cloud). And Cloudflare does not warn you before the origin certificate expires: put the date in your calendar.

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.

  1. 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. Name mural, 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.
  2. 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>
GrupoGroupEntrada permitidaAllowed inboundPor quêWhy
mural-albmural-alb443 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-web8080 de mural-alb8080 from mural-albSó o ALB fala com o nginx.Only the ALB talks to nginx.
mural-apimural-api3000 de mural-alb3000 from mural-albSó 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-vpce443 de mural-web e de mural-api443 from mural-web and mural-apiAs 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).No secretsmanager. 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 mural

6.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 sem host no Fargate é um bind mount no armazenamento efêmero da task: some quando a task para. Ele dá ao nginx o /tmp gravável que o teste local mostrou ser necessário, enquanto o resto do disco continua somente leitura.The tmp volume. A volume with no host on Fargate is a bind mount on the task's ephemeral storage: it disappears when the task stops. It gives nginx the writable /tmp the local test showed it needs, while the rest of the disk stays read-only.
  • Health check com wget e 127.0.0.1. As duas imagens são Alpine, que traz o wget do BusyBox e não traz curl. O IP evita a armadilha do localhost → IPv6.Health check with wget and 127.0.0.1. Both images are Alpine, which ships BusyBox wget and no curl. The IP avoids the localhost → IPv6 trap.
  • Nomes dos containers. web e api sã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. web and api are 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.

PrioridadePriorityCondiçãoConditionDestinoTarget
1010path-pattern /api/*path-pattern /api/*target group mural-api (porta 3000)target group mural-api (port 3000)
padrãodefaultqualquer outro pedidoany other requesttarget 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 all

7.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
done

7.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 /api puro, o pedido cai na ação padrão e volta o index.html. O padrão também diferencia maiúsculas: /API/info vai 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 gets index.html back. The pattern is also case-sensitive: /API/info goes 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-alb nem aceita a porta 80; o redirecionamento para HTTPS é feito pela Cloudflare (seção 3).No listener on 80. mural-alb does 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-count dela (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 its desired-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_API

8. DNS na Cloudflare e o caminho de cada pedido8. DNS at Cloudflare and each request's path

  1. Cloudflare → DNS → Records → Add record: tipo CNAME, nome @ (a raiz, example.com), destino = o $ALB_DNS, Proxied.Cloudflare → DNS → Records → Add record: type CNAME, name @ (the apex, example.com), target = $ALB_DNS, Proxied.
  2. Mais um CNAME, nome www, mesmo destino, Proxied. Se quiser um endereço só, crie uma Redirect Rule de www.example.com/* para https://example.com/${1} (301).Another CNAME, name www, same target, Proxied. If you want a single address, create a Redirect Rule from www.example.com/* to https://example.com/${1} (301).
CNAME na raiz? O padrão de DNS não permite CNAME no domínio raiz, e o ALB não tem IP fixo (só um nome). A Cloudflare resolve isso com CNAME flattening: ela aceita o CNAME na raiz e responde ao visitante com os IPs, que de qualquer forma são os dela, por causa do proxy. É uma das vantagens de o DNS estar na Cloudflare; no Route 53 o equivalente é o registro Alias.A CNAME at the apex? The DNS standard does not allow a CNAME at the root domain, and the ALB has no fixed IP (only a name). Cloudflare solves it with CNAME flattening: it accepts the CNAME at the apex and answers visitors with IPs, which are its own anyway, because of the proxy. It is one of the perks of having DNS at Cloudflare; on Route 53 the equivalent is an Alias record.

O que acontece quando alguém abre o siteWhat happens when someone opens the site

NavegadorBrowserCloudflareALBServiceServiceGET /HTML não é cacheadoHTML is not cachedrepassa ao ALBpasses to the ALBação padrãodefault actionwebindex.htmlGET /assets/index-….jscache HITcache HITresponde da bordaanswers from the edgenão chega à AWS (após o 1º pedido)never reaches AWS (after the 1st request)GET /api/infosem extensão: passano extension: passes+ CF-Connecting-IPregra /api/*rule /api/*apiJSON
Três pedidos de uma visita. Só o primeiro e o terceiro chegam à AWS; os arquivos com hash ficam no cache da Cloudflare depois da primeira vez.Three requests from one visit. Only the first and third reach AWS; the hashed files stay in Cloudflare's cache after the first time.
  • 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. O index.html nã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.html is not stored, and it is what points to the new names. Result: a deploy shows up immediately, with no cache purge.
Um passo além: só a sua zona da Cloudflare. A prefix list garante que o tráfego vem da Cloudflare, mas qualquer cliente da Cloudflare poderia apontar o próprio domínio para o seu ALB. Para fechar isso, crie na Cloudflare uma Transform Rule que acrescenta um cabeçalho secreto a todo pedido (por exemplo 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.

git pushmainGitHub ActionsOIDC → rolejob webbuild ./webjob apibuild ./apiECRmural-web:shaECRmural-api:shamural-webrollingmural-apirolling
Um push, dois jobs em paralelo. Cada service troca as tasks aos poucos: o site não sai do ar.One push, two jobs in parallel. Each service swaps its tasks gradually: the site never goes down.
# .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:PassRole para a muralExecutionRole) vêm das etapas 1 e 2 da nota de OIDC; as variáveis AWS_ROLE_ARN e AWS_REGION ficam no environment production do 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 (with iam:PassRole for muralExecutionRole) come from steps 1 and 2 of the OIDC note; the AWS_ROLE_ARN and AWS_REGION variables live in GitHub's production environment. The role's policy must cover both ECR repositories and both services.
  • APP_VERSION com 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_VERSION with 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/** e api/**).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 with on.push.paths: web/** and api/**).
Front e back não trocam no mesmo instante. Os dois services fazem deploy em paralelo, cada um no seu ritmo; por alguns minutos o front novo pode falar com a API velha, ou o contrário. Mudanças na API precisam ser compatíveis com a versão anterior: acrescente campos e rotas novos primeiro, passe o front a usá-los num deploy seguinte, e só depois remova o que ficou velho. É a mesma ideia de expand and contract da nota de migrações.Front and back do not switch at the same instant. Both services deploy in parallel, each at its own pace; for a few minutes the new front end may talk to the old API, or the reverse. API changes must be backward compatible: add new fields and routes first, switch the front end to them in a later deploy, and only then remove what is obsolete. It is the same expand and contract idea as in the migrations note.

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
SintomaSymptomCausa 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 404Falta 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 endO 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 systemFalta 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 workO 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/503O 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 522O 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 526O 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 errorFaltam 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.
Nota pessoal: as linhas sobre HTML no lugar de JSON, 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)
Nota pessoal, sobre o que foi testado: o código, as duas imagens, o 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.
100%