CI/CD

From push to running container: build, ECR and replace.

A push to develop or main builds a Docker image with that environment’s settings, stores it in ECR and replaces the running container in the matching AWS account.

O fluxo completoThe full flow

O objetivo: depois de um commit chegar em develop ou main, o GitHub constrói uma imagem Docker com a configuração daquele ambiente, guarda a imagem no repositório de imagens (Amazon ECR) da conta AWS correta e troca o container em execução pelo novo. Ninguém entra em servidor, ninguém guarda chave de acesso.The goal: once a commit lands on develop or main, GitHub builds a Docker image with that environment’s configuration, stores it in the image registry (Amazon ECR) of the right AWS account and swaps the running container for the new one. Nobody logs in to a server, nobody stores an access key.

tudo isto acontece na conta AWS do ambiente (dev ou prod)all of this happens in the environment’s AWS account (dev or prod) 1 git pushgit push develop ou maindevelop or main 2 GitHub ActionsGitHub Actions build da imagemimage build 3 OIDC → STSOIDC → STS credenciais de 1 h1 h credentials 4 Amazon ECRAmazon ECR guarda a imagemstores the image 5 Amazon ECSAmazon ECS troca a taskreplaces the task Cada passo só roda se o anterior deu certo; se qualquer um falhar, o container que está no ar continua intocado.Each step runs only if the previous one succeeded; if any fails, the running container stays untouched. A imagem leva como tag o SHA do commit: cada versão é rastreável e pode ser reimplantada.The image is tagged with the commit SHA: every version is traceable and can be redeployed.
O caminho de um commit até o container novo rodando.The path from a commit to the new container running.
  1. Push. O merge de um PR em develop (ou main) registra um evento push e dispara o workflow.Push. Merging a PR into develop (or main) records a push event and triggers the workflow.
  2. Build. O job escolhe o environment pelo nome da branch e constrói a imagem passando como --build-arg as variáveis desse environment.Build. The job picks the environment from the branch name and builds the image passing that environment’s variables as --build-arg.
  3. Credenciais. O GitHub emite um token OIDC; a AWS o troca por credenciais temporárias da role da conta do ambiente (nota de OIDC).Credentials. GitHub issues an OIDC token; AWS exchanges it for temporary credentials of the environment account’s role (OIDC note).
  4. Registro. A imagem sobe para o ECR da mesma conta, com o SHA do commit como tag.Registry. The image is pushed to the ECR of the same account, tagged with the commit SHA.
  5. Troca. O workflow registra uma task definition nova apontando para a imagem nova e manda o serviço ECS usá-la. O ECS sobe o container novo, espera ele ficar saudável e para o antigo.Swap. The workflow registers a new task definition pointing to the new image and tells the ECS service to use it. ECS starts the new container, waits for it to be healthy and stops the old one.
  6. Verificação. O job espera o serviço estabilizar. Se a versão nova não ficar saudável, o job falha e o ECS pode desfazer sozinho.Check. The job waits for the service to stabilise. If the new version does not become healthy, the job fails and ECS can roll back on its own.
E o “parar e excluir o container atual”? No ECS você não roda docker stop e docker rm. O serviço faz a troca (rolling update): a documentação diz que o scheduler “substitui as tasks em execução por tasks novas”. A task antiga é parada e o container dela é descartado junto. Se você usa Docker puro numa EC2, o mesmo passo é manual: veja a alternativa com EC2.And “stop and delete the current container”? On ECS you do not run docker stop and docker rm. The service does the swap (a rolling update): the docs say the scheduler “replaces the currently running tasks with new tasks”. The old task is stopped and its container is discarded with it. If you run plain Docker on an EC2 instance, the same step is manual: see the EC2 alternative.

Uma branch, um ambiente, uma contaOne branch, one environment, one account

O desenho que o resto da nota segue. Os nomes são iguais nos dois lados; o que muda é o valor guardado em cada environment.The layout the rest of the note follows. The names are the same on both sides; what changes is the value stored in each environment.

BranchBranch GitHub EnvironmentGitHub Environment Conta AWSAWS account develop environment: develop vars: role, região, ECR, ECSvars: role, Region, ECR, ECS conta devdev account ECRECR ECS (serviço)ECS (service) main environment: production + aprovação manual+ manual approval conta prodprod account ECRECR ECS (serviço)ECS (service) Mesmo workflow, mesmos nomes de variáveis, valores diferentes: é o valor de AWS_ROLE_ARN que decide a conta.Same workflow, same variable names, different values: AWS_ROLE_ARN decides the account.
Uma branch aponta para um environment do GitHub, e o environment aponta (pela role) para uma conta AWS.One branch maps to a GitHub environment, and the environment maps (through the role) to an AWS account.
  • develop → conta dev. Deploy automático, sem aprovação.develop → dev account. Automatic deploy, no approval.
  • main → conta prod. O environment production pode exigir um revisor antes de o job continuar.main → prod account. The production environment can require a reviewer before the job continues.
  • Mais ambientes? Para um terceiro (por exemplo staging), crie outra branch, outro environment, outra conta (ou o mesmo par de contas com outro cluster) e acrescente a branch ao on.push.branches e ao mapeamento do environment: no workflow. Nada mais muda.More environments? For a third one (for example staging), create another branch, another environment, another account (or the same pair of accounts with another cluster) and add the branch to on.push.branches and to the environment: mapping in the workflow. Nothing else changes.

Antes de começarBefore you start

Esta nota continua duas outras e não repete o que elas já explicam. Cada item abaixo precisa estar pronto antes do primeiro push.This note continues two others and does not repeat what they already explain. Each item below must be ready before the first push.

Onde verWhere to lookO que precisa existirWhat must exist
Nota de AWSAWS noteOrganization e as duas contas (dev e prod), com um perfil da CLI para cada.Organization and the two accounts (dev and prod), with a CLI profile for each.
Nota de OIDC, 1 a 3OIDC note, 1 to 3Provedor OIDC e uma role de deploy em cada conta; environments develop e production no GitHub, com AWS_ROLE_ARN.OIDC provider and a deploy role in each account; develop and production environments on GitHub, with AWS_ROLE_ARN.
Nota de OIDC, pré-requisitosOIDC note, prerequisitesCluster ECS, serviço ECS rodando (mesmo com imagem de teste), task execution role e task role em cada conta.ECS cluster, ECS service running (even with a placeholder image), task execution role and task role in each account.
Nota de OIDC, 4OIDC note, 4Segredos da aplicação no Secrets Manager de cada conta.Application secrets in each account’s Secrets Manager.

O que esta nota acrescenta, nesta ordem: (1) como separar a configuração do Dockerfile por ambiente, (2) o repositório ECR de cada conta, (3) como o serviço ECS troca o container, (4) as variáveis novas dos environments, (5) o workflow completo, (6) o primeiro deploy e (7) a alternativa sem ECS.What this note adds, in this order: (1) how to split the Dockerfile’s configuration per environment, (2) each account’s ECR repository, (3) how the ECS service swaps the container, (4) the new environment variables, (5) the full workflow, (6) the first deploy and (7) the alternative without ECS.

Variáveis usadas nos comandos (troque pelos seus valores):Variables used in the commands (replace with your values):

GH_ORG=minha-org
GH_REPO=meu-repo
REGION=sa-east-1
APP=meu-app                 # ECR repository name
# profiles: --profile dev  and  --profile prod

1. Configuração por ambiente no Dockerfile1. Per-environment configuration in the Dockerfile

Você pediu uma imagem “com todas as envs corretas de dev ou prod”. Há dois tipos de configuração e cada um entra num momento diferente:You asked for an image “with all the correct dev or prod envs”. There are two kinds of configuration and each enters at a different moment:

BUILD · no GitHub ActionsBUILD · in GitHub Actions Environment variablesEnvironment variables APP_ENV, PUBLIC_API_URLAPP_ENV, PUBLIC_API_URL docker builddocker build --build-arg--build-arg Imagem no ECRImage in ECR valores gravadosvalues baked in Valor público, fica dentro da imagem.Public value, lives inside the image. RUNTIME · no ECS, quando a task iniciaRUNTIME · in ECS, when the task starts Secrets ManagerSecrets Manager na conta do ambientein the environment’s account Task execution roleTask execution role lê o segredoreads the secret Segredo: nunca entra na imagem nem passa pelo pipeline.Secret: never enters the image or goes through the pipeline. ContainerContainer em execuçãorunning
Config pública entra no build; segredo entra só na hora de rodar, vindo do Secrets Manager.Public config enters at build time; secrets enter only at run time, from Secrets Manager.
TipoKindExemploExampleEntra quando e comoEnters when and how
Pública, conhecida no buildPublic, known at build timeURL pública da API, nome do ambiente, ID de analytics, variáveis embutidas no bundle do front-endPublic API URL, environment name, analytics ID, variables embedded in the front-end bundleBuild. GitHub Environment variables → --build-arg → ARG/ENV no Dockerfile.Build. GitHub Environment variables → --build-arg → ARG/ENV in the Dockerfile.
Não secreta, só necessária ao rodarNon-secret, needed only at run timeNível de log, nome da fila, flagsLog level, queue name, flagsRuntime. Campo environment da task definition.Runtime. The task definition’s environment field.
SegredoSecretSenha do banco, chave de API, tokenDatabase password, API key, tokenRuntime. Secrets Manager → campo secrets da task definition. Nunca no build.Runtime. Secrets Manager → the task definition’s secrets field. Never at build time.

Dockerfile de exemploExample Dockerfile

Um app Node com build em duas etapas, só para mostrar onde ficam os ARG. Adapte ao seu stack; a ideia vale para qualquer linguagem.A Node app with a two-stage build, only to show where the ARGs go. Adapt it to your stack; the idea holds for any language.

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG APP_ENV
ARG PUBLIC_API_URL
RUN APP_ENV=$APP_ENV PUBLIC_API_URL=$PUBLIC_API_URL npm run build

FROM node:22-alpine
WORKDIR /app
ARG APP_ENV
ENV NODE_ENV=production APP_ENV=$APP_ENV
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/server.js"]
Segredo no build é vazamento. Valores de ARG e ENV ficam gravados nos metadados e nas camadas da imagem, e qualquer pessoa com permissão de baixá-la os lê (por exemplo com docker history). A documentação do Docker recomenda secret mounts (RUN --mount=type=secret) para segredos de build, que não ficam na imagem. Para a senha do banco, use o caminho de runtime da tabela.A secret at build time is a leak. ARG and ENV values are recorded in the image metadata and layers, and anyone allowed to pull it can read them (for example with docker history). Docker’s documentation recommends secret mounts (RUN --mount=type=secret) for build secrets, which do not stay in the image. For the database password, use the runtime path from the table.
Nota pessoal, não verificada nas fontes: reconstruir a imagem por ambiente significa que o que você testou em dev não é exatamente o artefato que roda em prod. A alternativa comum é construir uma vez e injetar tudo em runtime. Esta nota segue o seu pedido (imagem por ambiente) e mantém no build só valores públicos, o que reduz esse risco.Personal note, not verified in the sources: rebuilding the image per environment means what you tested in dev is not exactly the artifact that runs in prod. The common alternative is to build once and inject everything at run time. This note follows your request (an image per environment) and keeps only public values at build time, which reduces that risk.

2. O repositório de imagens (ECR)2. The image registry (ECR)

Cada conta tem o seu repositório ECR, com o mesmo nome nas duas. O workflow envia a imagem para o repositório da conta em que a role foi assumida, então dev e prod nunca compartilham imagens.Each account has its own ECR repository, with the same name in both. The workflow pushes the image to the repository of the account whose role was assumed, so dev and prod never share images.

Criar o repositório, passo a passoCreating the repository, step by step

  1. Na conta dev, abra Amazon ECR → Private registry → Repositories → Create repository.In the dev account, open Amazon ECR → Private registry → Repositories → Create repository.
  2. Em Repository name, informe meu-app. Ative Tag immutability: uma tag, uma vez enviada, não pode ser sobrescrita.Under Repository name, enter meu-app. Turn on Tag immutability: once pushed, a tag cannot be overwritten.
  3. Ative Scan on push para analisar vulnerabilidades a cada envio e crie o repositório.Turn on Scan on push to scan for vulnerabilities on every push, and create the repository.
  4. Repita na conta prod, com o mesmo nome.Repeat in the prod account, with the same name.
for P in dev prod; do
  aws ecr create-repository --repository-name $APP \
    --image-tag-mutability IMMUTABLE \
    --image-scanning-configuration scanOnPush=true \
    --region $REGION --profile $P
done

Limpeza automáticaAutomatic cleanup

Cada deploy acrescenta uma imagem. Uma lifecycle policy expira as mais antigas. Segundo a documentação, as imagens expiram em até 24 horas depois de cumprir o critério, e a pré-visualização (preview) mostra o que seria apagado antes de você aplicar.Every deploy adds an image. A lifecycle policy expires the oldest ones. According to the docs, images expire within 24 hours of meeting the criteria, and the preview shows what would be deleted before you apply it.

{
  "rules": [
    {
      "rulePriority": 1,
      "description": "Keep the last 30 images",
      "selection": {
        "tagStatus": "any",
        "countType": "imageCountMoreThan",
        "countNumber": 30
      },
      "action": { "type": "expire" }
    }
  ]
}
aws ecr put-lifecycle-policy --repository-name $APP \
  --lifecycle-policy-text file://lifecycle.json --profile dev
# repeat with --profile prod
Mantenha uma contagem folgada. Se a imagem da versão em execução expirar, uma task nova (autoscaling, reinício) não consegue baixá-la. Trinta imagens deixam bastante margem para voltar atrás.Keep a generous count. If the running version’s image expires, a new task (autoscaling, restart) cannot pull it. Thirty images leave plenty of room to roll back.
Permissão a mais na role de deploy. O workflow abaixo consulta o ECR antes de construir. Acrescente ecr:DescribeImages ao statement EcrPush da política da role (nota de OIDC, etapa 2), no mesmo ARN de repositório.One more permission on the deploy role. The workflow below queries ECR before building. Add ecr:DescribeImages to the EcrPush statement of the role policy (OIDC note, step 2), on the same repository ARN.

3. Como o ECS troca o container3. How ECS swaps the container

É aqui que o container antigo é parado e descartado. O comportamento é controlado por dois números do serviço, descritos na documentação do ECS:This is where the old container is stopped and discarded. The behaviour is controlled by two numbers on the service, described in the ECS documentation:

  • minimumHealthyPercent é o limite inferior de tasks saudáveis durante a troca, em % do número desejado (arredondado para cima).minimumHealthyPercent is the lower limit of healthy tasks during the swap, as a % of the desired count (rounded up).
  • maximumPercent é o limite superior de tasks em execução durante a troca (arredondado para baixo).maximumPercent is the upper limit of running tasks during the swap (rounded down).
Padrão: sobe a nova, depois derruba a antigaDefault: start the new one, then stop the old one minimumHealthyPercent 100 · maximumPercent 200 v1v1 antesbefore v1v1 v2v2 duranteduring v2v2 depoisafter Sem indisponibilidade; por alguns instantes roda o dobro de tasks.No downtime; for a few moments twice the tasks run. Para a antiga, depois sobe a novaStop the old one, then start the new one minimumHealthyPercent 0 · maximumPercent 100 v1v1 antesbefore –– duranteduring v2v2 depoisafter Há uma janela sem container, mas nunca duas versões ao mesmo tempo.There is a window with no container, but never two versions at once.
v1 é a task atual, v2 a nova. O ECS para a antiga e descarta o container dela; você não roda docker stop nem docker rm.v1 is the current task, v2 the new one. ECS stops the old one and discards its container; you never run docker stop or docker rm.

Com 1 task desejada, o padrão (100/200) sobe a nova antes de parar a antiga: sem indisponibilidade, desde que a nova passe no health check. Com 0/100 o ECS é forçado a parar a antiga antes: é o equivalente literal de “parar, excluir e criar de novo”, ao custo de uma janela fora do ar.With 1 desired task, the default (100/200) starts the new one before stopping the old one: no downtime, as long as the new one passes the health check. With 0/100 ECS is forced to stop the old one first: the literal equivalent of “stop, delete and create again”, at the cost of a window offline.

A documentação avisa: ao definir esses percentuais, garanta que o scheduler consiga parar ou iniciar pelo menos uma task. Com 1 task desejada, 100/100 trava o deploy (nem sobe a nova, nem para a antiga) e o serviço registra um evento de erro.The docs warn: when you set these percentages, make sure the scheduler can stop or start at least one task. With 1 desired task, 100/100 stalls the deploy (it can neither start the new one nor stop the old one) and the service logs an error event.

Configurar, passo a passoConfiguring, step by step

  1. No console: ECS → Clusters → seu cluster → seu serviço → Update service, seção de opções de deployment.In the console: ECS → Clusters → your cluster → your service → Update service, deployment options section.
  2. Defina Minimum healthy percent e Maximum percent conforme o desenho acima (a primeira opção é a recomendada).Set Minimum healthy percent and Maximum percent as drawn above (the first option is the recommended one).
  3. Ative o deployment circuit breaker com rollback: se as tasks novas não conseguirem iniciar, o ECS marca o deploy como falho e volta para a revisão anterior. A documentação o indica para parar um deploy quando as tasks não sobem.Turn on the deployment circuit breaker with rollback: if the new tasks cannot start, ECS marks the deploy as failed and returns to the previous revision. The docs recommend it for stopping a deployment when tasks cannot start.
  4. Repita no serviço da outra conta (em prod, mantenha o padrão 100/200 para não ter janela fora do ar).Repeat on the other account’s service (in prod, keep the default 100/200 to avoid an offline window).
aws ecs update-service --cluster meu-cluster --service meu-servico \
  --deployment-configuration "minimumHealthyPercent=100,maximumPercent=200,deploymentCircuitBreaker={enable=true,rollback=true}" \
  --profile dev

O health check decide quando a antiga pode pararThe health check decides when the old one may stop

O ECS só considera a task nova saudável se o container passar no health check. Defina um na task definition (o comando depende do que existe na imagem; este exemplo usa wget, presente na imagem Alpine):ECS only treats the new task as healthy if the container passes its health check. Define one in the task definition (the command depends on what the image contains; this example uses wget, present in the Alpine image):

"healthCheck": {
  "command": ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"],
  "interval": 15,
  "timeout": 5,
  "retries": 3,
  "startPeriod": 20
}

Se o serviço está atrás de um load balancer, o health check do target group também vale. Sem nenhum health check, o ECS só sabe que o processo iniciou, não que a aplicação responde.If the service is behind a load balancer, the target group’s health check counts too. With no health check, ECS only knows the process started, not that the application responds.

4. Variáveis novas nos environments4. New variables in the environments

A nota de OIDC criou cinco variáveis por environment. Acrescente as duas que alimentam o build. Mesmo nome nos dois environments, valor diferente.The OIDC note created five variables per environment. Add the two that feed the build. Same name in both environments, different value.

VariávelVariableEm developIn developEm productionIn production
APP_ENVAPP_ENVdevelopmentdevelopmentproductionproduction
PUBLIC_API_URLPUBLIC_API_URLhttps://api-dev.exemplo.comhttps://api-dev.example.comhttps://api.exemplo.comhttps://api.example.com
  1. No repositório: Settings → Environments → develop → Environment variables → Add variable.In the repository: Settings → Environments → develop → Environment variables → Add variable.
  2. Crie APP_ENV e PUBLIC_API_URL com os valores de dev.Create APP_ENV and PUBLIC_API_URL with the dev values.
  3. Repita em production com os valores de prod.Repeat in production with the prod values.
gh variable set APP_ENV        --env develop    --body "development"
gh variable set PUBLIC_API_URL --env develop    --body "https://api-dev.example.com"
gh variable set APP_ENV        --env production --body "production"
gh variable set PUBLIC_API_URL --env production --body "https://api.example.com"
Use variables, não secrets, para esses dois valores: são públicos (acabam dentro da imagem) e em variável você consegue conferir o que está configurado. Segredos continuam no Secrets Manager.Use variables, not secrets, for these two values: they are public (they end up inside the image) and with a variable you can check what is configured. Secrets stay in Secrets Manager.

5. O workflow completo5. The full workflow

Um único arquivo atende os dois ambientes. É o workflow da nota de OIDC com duas diferenças: as --build-arg vindas das variáveis do environment e a checagem de “imagem já existe”.One file serves both environments. It is the OIDC note’s workflow with two differences: the --build-args coming from the environment variables and the “image already exists” check.

.github/workflows/deploy.yml
Dockerfile
.aws/task-definition.json     # container named "app"
name: Build and deploy

on:
  push:
    branches:
      - develop
      - main

# One deploy at a time per branch; a newer push waits for the running one.
concurrency:
  group: deploy-${{ github.ref_name }}
  cancel-in-progress: false

permissions:
  id-token: write   # lets the job request the OIDC token
  contents: read    # actions/checkout

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.ref_name == 'main' && 'production' || 'develop' }}
    env:
      AWS_REGION: ${{ vars.AWS_REGION }}
      ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
      ECS_CLUSTER: ${{ vars.ECS_CLUSTER }}
      ECS_SERVICE: ${{ vars.ECS_SERVICE }}
      ECS_TASK_DEFINITION: .aws/task-definition.json
      CONTAINER_NAME: app
    steps:
      - uses: actions/checkout@v6

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          role-session-name: gh-${{ github.run_id }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Log in to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push the image
        id: build-image
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
          APP_ENV: ${{ vars.APP_ENV }}
          PUBLIC_API_URL: ${{ vars.PUBLIC_API_URL }}
        run: |
          IMAGE="$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
          if aws ecr describe-images --repository-name "$ECR_REPOSITORY" \
               --image-ids imageTag="$IMAGE_TAG" > /dev/null 2>&1; then
            echo "Image $IMAGE_TAG already in ECR, skipping build"
          else
            docker build \
              --build-arg APP_ENV="$APP_ENV" \
              --build-arg PUBLIC_API_URL="$PUBLIC_API_URL" \
              -t "$IMAGE" .
            docker push "$IMAGE"
          fi
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

      - name: Render the task definition
        id: render
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ${{ env.ECS_TASK_DEFINITION }}
          container-name: ${{ env.CONTAINER_NAME }}
          image: ${{ steps.build-image.outputs.image }}

      - name: Deploy to Amazon ECS (replaces the running container)
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.render.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true

O que cada parte fazWhat each part does

  • on.push.branches. Só develop e main disparam. Uma branch de feature não faz deploy.on.push.branches. Only develop and main trigger. A feature branch does not deploy.
  • environment:. A expressão escolhe production para main e develop para o resto. É isso que seleciona as variáveis (vars.*), a role e as regras de aprovação.environment:. The expression picks production for main and develop otherwise. It is what selects the variables (vars.*), the role and the approval rules.
  • Build. As variáveis do environment viram --build-arg. A imagem nasce com os valores do ambiente certo.Build. The environment’s variables become --build-arg. The image is born with the right environment’s values.
  • Checagem de existência. Com tags imutáveis, enviar de novo a mesma tag dá erro. Se você reexecutar um job do mesmo commit, o workflow pula o build e reaproveita a imagem. Consequência: se mudar uma variável do environment e reexecutar o mesmo commit, a imagem não é refeita; faça um commit novo.Existence check. With immutable tags, pushing the same tag again is an error. If you re-run a job for the same commit, the workflow skips the build and reuses the image. Consequence: if you change an environment variable and re-run the same commit, the image is not rebuilt; make a new commit.
  • Render + deploy. A primeira ação grava a imagem nova na task definition; a segunda registra a revisão e atualiza o serviço. A partir daí, o ECS faz a troca descrita na etapa 3.Render + deploy. The first action writes the new image into the task definition; the second registers the revision and updates the service. From there ECS does the swap described in step 3.
  • wait-for-service-stability. O job só termina quando o serviço estabiliza. Se a versão nova não ficar saudável, o job falha e você vê no GitHub.wait-for-service-stability. The job finishes only when the service stabilises. If the new version does not become healthy, the job fails and you see it on GitHub.
As actions não são certificadas pelo GitHub e os exemplos usam tags (@v6, @v2, @v1) por legibilidade. Em produção, fixe por SHA de commit, como recomenda a documentação do GitHub.These actions are not certified by GitHub and the examples use tags (@v6, @v2, @v1) for readability. In production, pin to a commit SHA, as GitHub’s docs recommend.

6. Primeiro deploy, passo a passo6. First deploy, step by step

  1. Confira o checklist. Repositório ECR e serviço ECS em cada conta; as sete variáveis em cada environment (as cinco da nota de OIDC mais APP_ENV e PUBLIC_API_URL); ecr:DescribeImages na política da role.Check the list. ECR repository and ECS service in each account; the seven variables in each environment (the five from the OIDC note plus APP_ENV and PUBLIC_API_URL); ecr:DescribeImages in the role policy.
  2. Commit na develop. Mergeie um PR (ou faça um commit) e abra a aba Actions. O job mostra o environment develop.Commit to develop. Merge a PR (or commit) and open the Actions tab. The job shows the develop environment.
  3. Acompanhe os passos. Credenciais, login no ECR, build e push, render, deploy. Se o de credenciais falhar, veja os problemas comuns da nota de OIDC.Follow the steps. Credentials, ECR login, build and push, render, deploy. If credentials fail, see the OIDC note’s common problems.
  4. Confirme a imagem no ECR da conta dev:Confirm the image in the dev account’s ECR:
    aws ecr describe-images --repository-name $APP --profile dev \
      --query 'sort_by(imageDetails,&imagePushedAt)[-1].{tags:imageTags,pushed:imagePushedAt}'
  5. Veja a troca acontecer. Durante o deploy, o serviço lista duas revisões; quando termina, só a nova:Watch the swap happen. During the deploy the service lists two revisions; when it ends, only the new one:
    aws ecs describe-services --cluster meu-cluster --services meu-servico --profile dev \
      --query 'services[0].deployments[].[status,rolloutState,runningCount,taskDefinition]'
    # PRIMARY   IN_PROGRESS  ...  new task definition
    # ACTIVE    COMPLETED    ...  old task definition  (disappears when done)
  6. Confirme o container novo. Em ECS → serviço → Tasks, a task antiga aparece como parada e a nova em execução. A aplicação deve responder com a configuração de dev (por exemplo a URL de API de dev).Confirm the new container. Under ECS → service → Tasks, the old task shows as stopped and the new one running. The application must answer with the dev configuration (for example the dev API URL).
  7. Promova para produção. Abra um PR de develop para main e mergeie. O job fica em “Waiting” até um revisor aprovar em Review deployments; depois constrói outra imagem, com os valores de prod, no ECR da conta prod.Promote to production. Open a PR from develop to main and merge it. The job sits in “Waiting” until a reviewer approves under Review deployments; it then builds another image, with the prod values, in the prod account’s ECR.
Se algo der errado no meio, o container que estava no ar continua rodando: o ECS só para a versão antiga depois que a nova ficou saudável (configuração padrão). Com o circuit breaker, ele volta sozinho para a revisão anterior.If something goes wrong midway, the container that was running keeps running: ECS stops the old version only after the new one is healthy (default configuration). With the circuit breaker, it returns to the previous revision on its own.

7. Alternativa: Docker puro numa EC27. Alternative: plain Docker on an EC2 instance

Se você não usa ECS e roda o container direto numa EC2, o “parar, excluir e criar” é um script na instância, e o workflow o dispara pelo AWS Systems Manager Run Command, sem abrir SSH. Os passos do build, o ECR e o OIDC são os mesmos; só mudam os dois últimos passos do workflow.If you do not use ECS and run the container directly on an EC2 instance, “stop, delete and create” is a script on the instance, and the workflow triggers it through AWS Systems Manager Run Command, with no SSH open. The build, ECR and OIDC steps are the same; only the last two workflow steps change.

GitHub ActionsGitHub Actions SSM Run CommandSSM Run Command AWS-RunShellScriptAWS-RunShellScript Instância EC2 (SSM Agent + Docker)EC2 instance (SSM Agent + Docker) 1 docker pull a imagem nova1 docker pull the new image 2 docker stop app2 docker stop app 3 docker rm app3 docker rm app 4 docker run a imagem nova4 docker run the new image a role de deploy só precisa de ssm:SendCommandthe deploy role only needs ssm:SendCommand no documento e na instânciaon the document and the instance
Sem ECS, o workflow manda a instância trocar o container, na ordem: baixar, parar, remover, rodar.Without ECS, the workflow tells the instance to swap the container, in order: pull, stop, remove, run.

Preparar a instância, uma vez por contaPreparing the instance, once per account

  1. A instância precisa de um instance profile com a política gerenciada AmazonSSMManagedInstanceCore (para o SSM Agent) e permissão de leitura no ECR (por exemplo AmazonEC2ContainerRegistryReadOnly). Em EC2 → Instances → Actions → Security → Modify IAM role.The instance needs an instance profile with the managed policy AmazonSSMManagedInstanceCore (for the SSM Agent) and read permission on ECR (for example AmazonEC2ContainerRegistryReadOnly). Under EC2 → Instances → Actions → Security → Modify IAM role.
  2. Confirme em Systems Manager → Fleet Manager que a instância aparece como gerenciada. Docker e AWS CLI instalados nela.Confirm under Systems Manager → Fleet Manager that the instance shows as managed. Docker and the AWS CLI installed on it.
  3. Salve o script abaixo em /opt/meu-app/deploy.sh (chmod +x).Save the script below at /opt/meu-app/deploy.sh (chmod +x).
  4. Na role de deploy (OIDC), troque as permissões do ECS por ssm:SendCommand (nos ARNs do documento AWS-RunShellScript e da instância) e ssm:GetCommandInvocation. Crie a variável EC2_INSTANCE_ID em cada environment.On the deploy role (OIDC), replace the ECS permissions with ssm:SendCommand (on the ARNs of the AWS-RunShellScript document and the instance) and ssm:GetCommandInvocation. Create the EC2_INSTANCE_ID variable in each environment.
#!/usr/bin/env bash
# /opt/meu-app/deploy.sh <image>
set -euo pipefail
IMAGE="$1"
REGISTRY="${IMAGE%%/*}"
REGION="$(echo "$REGISTRY" | cut -d. -f4)"

aws ecr get-login-password --region "$REGION" \
  | docker login --username AWS --password-stdin "$REGISTRY"

docker pull "$IMAGE"          # download first: shortens the downtime
docker stop app || true       # stop the running container
docker rm app || true         # delete it
docker run -d --name app --restart unless-stopped -p 80:3000 "$IMAGE"

No workflow, remova os passos “Render” e “Deploy to Amazon ECS” e acrescente este:In the workflow, remove the “Render” and “Deploy to Amazon ECS” steps and add this one:

- name: Replace the container on the instance
  env:
    IMAGE: ${{ steps.build-image.outputs.image }}
    INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }}
  run: |
    COMMAND_ID=$(aws ssm send-command \
      --document-name AWS-RunShellScript \
      --instance-ids "$INSTANCE_ID" \
      --parameters commands="bash /opt/meu-app/deploy.sh $IMAGE" \
      --query Command.CommandId --output text)
    aws ssm wait command-executed \
      --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID"
Aqui há sempre uma janela sem container (entre o docker stop e o docker run) e nenhum rollback automático: se a versão nova não subir, você precisa reexecutar o deploy de um commit anterior. É o que o ECS resolve para você. Segredos: leia-os do Secrets Manager no próprio script, com a role da instância; não os passe pelo workflow.Here there is always a window with no container (between docker stop and docker run) and no automatic rollback: if the new version does not start, you must redeploy an earlier commit. This is what ECS solves for you. Secrets: read them from Secrets Manager in the script itself, with the instance role; do not pass them through the workflow.

Voltar atrás e problemas comunsRolling back and common problems

Voltar para uma versão anteriorGoing back to an earlier version

  • Automático. Com o circuit breaker e rollback ativos, uma versão que não sobe é desfeita pelo ECS.Automatic. With the circuit breaker and rollback on, a version that does not start is undone by ECS.
  • Manual. Reverta o commit na branch (git revert) e faça o push, ou use Re-run jobs numa execução antiga que deu certo. Como as tags são imutáveis e únicas por commit, a imagem antiga ainda está no ECR e o workflow a reaproveita.Manual. Revert the commit on the branch (git revert) and push, or use Re-run jobs on an old successful run. Since tags are immutable and unique per commit, the old image is still in ECR and the workflow reuses it.

Problemas comunsCommon problems

  • “tag invalid: The image tag ... already exists” no push. O repositório tem tags imutáveis e a tag já existe. A checagem do workflow evita isso; se aparecer, confira se ecr:DescribeImages está na política (sem ela a checagem falha e o workflow tenta construir de novo).“tag invalid: The image tag ... already exists” on push. The repository has immutable tags and the tag already exists. The workflow check avoids it; if it appears, check that ecr:DescribeImages is in the policy (without it the check fails and the workflow tries to build again).
  • A task nova não sobe: CannotPullContainerError. A task execution role não pode baixar do ECR, ou a task está numa sub-rede privada sem caminho até o ECR (NAT ou VPC endpoints). Confira também se a imagem existe com aquela tag.The new task does not start: CannotPullContainerError. The task execution role cannot pull from ECR, or the task is in a private subnet with no path to ECR (NAT or VPC endpoints). Also check that the image exists with that tag.
  • O deploy fica parado e o serviço registra “unable to stop or start tasks”. Os percentuais do serviço não deixam o scheduler iniciar nem parar nenhuma task (por exemplo 100/100 com 1 task). Ajuste minimumHealthyPercent ou maximumPercent.The deploy stalls and the service logs “unable to stop or start tasks”. The service percentages do not let the scheduler start or stop any task (for example 100/100 with 1 task). Adjust minimumHealthyPercent or maximumPercent.
  • A aplicação em dev usa a URL de prod (ou o contrário). Confira as variáveis do environment e o ARN em AWS_ROLE_ARN: o ID da conta no ARN decide o destino. ARNs trocados entre os dois environments é o erro mais caro desta configuração.The application in dev uses the prod URL (or the reverse). Check the environment variables and the ARN in AWS_ROLE_ARN: the account ID in the ARN decides the destination. Swapped ARNs between the two environments is the costliest mistake in this setup.
  • Mudei uma variável e o deploy continua com o valor antigo. Reexecutar o mesmo commit reaproveita a imagem. Faça um commit novo (pode ser vazio: git commit --allow-empty).I changed a variable and the deploy still has the old value. Re-running the same commit reuses the image. Make a new commit (it can be empty: git commit --allow-empty).
  • O segredo mudou e a app não viu. O ECS lê o segredo quando o container inicia. Faça um novo deploy ou “Force new deployment” no serviço.The secret changed and the app did not see it. ECS reads the secret when the container starts. Run a new deploy or “Force new deployment” on the service.
Nota pessoal, não verificada nas fontes: esta combinação (OIDC, build com variáveis por environment, ECR com tags imutáveis e troca no ECS) não aparece num único exemplo oficial; ela junta as páginas listadas abaixo. Os comandos aws ecr create-repository e aws ecs update-service foram escritos pela referência da CLI e não foram executados aqui: confira os parâmetros com --help na sua versão.Personal note, not verified in the sources: this combination (OIDC, build with per-environment variables, ECR with immutable tags and an ECS swap) does not appear in a single official example; it joins the pages listed below. The aws ecr create-repository and aws ecs update-service commands were written from the CLI reference and were not run here: check the parameters with --help on your version.