The previous lesson drew the map: the artifact reservalia/api:a3f9c21 has to travel devstagingprod without anybody opening the AWS console. Now we are going to write the file that does it. By the end of this lesson, Reservalia will have its second workflow — .github/workflows/cd.yml — which triggers on its own when CI publishes a new artifact, deploys to dev automatically, checks with a smoke test that what was deployed is exactly what we thought, and carries on to staging. We will see how two workflows are chained together, what GitHub environments and their protection rules are, how to get access to AWS without storing a single key, and where each environment's configuration lives, which is the question that breaks the most deployments.

Contents

  1. What "deploying" actually means in ECS Fargate
  2. Chaining CI and CD: workflow_run versus workflow_call
  3. GitHub environments: protection rules and required reviewers
  4. Authenticating with AWS via OIDC, with no long-lived keys
  5. Deploying the API: task definition with the exact digest
  6. Per-environment configuration: the artifact is the same, the configuration changes
  7. Post-deployment checks: stabilisation and smoke tests
  8. Idempotency and retries
  9. Deploying the web app: S3, CloudFront and cache invalidation
  10. Reservalia's cd.yml, from start to finish
  11. Common Mistakes and Tips
  12. Exercises
  13. Conclusion

  1. What "deploying" actually means in ECS Fargate

In ECS Fargate, "deploying" is not copying files to a server: it is changing a declaration and letting the orchestrator make it real. Three pieces:

Piece What it is Analogy
Task definition Versioned JSON: image, CPU, memory, variables, secrets, port The recipe
Task One concrete execution of that recipe: a running container The plated dish
Service Keeps N tasks alive, registers them with the ALB and replaces them when the recipe changes The chef

Deploying means three steps: registering a new revision of the task definition with the new image; telling the service to use it; and waiting until it has replaced the old tasks without the ALB ever stopping receiving traffic. Exactly how they are replaced is the deployment strategy, the subject of 03-04; here we use ECS's default behaviour, which is already a reasonable rolling update.

  1. Chaining CI and CD: workflow_run versus workflow_call

Reservalia's ci.yml finishes by publishing to ECR. How does the deployment start from there? GitHub Actions offers two mechanisms and the choice has consequences.

workflow_run workflow_call
How it works CD listens for "the CI workflow has finished" CI calls CD like a function
Visibility in the UI Two separate runs A single run with all the jobs
Passing data Via the previous run's outputs or artifacts With explicit inputs: and secrets:
Manual retry You can re-run only the CD You have to re-run everything
Known trap Only works if the workflow is on the default branch None worth noting

Reservalia chooses workflow_run: it wants to be able to re-run just the deployment when it fails because of a transient AWS problem, without repeating four minutes of tests, and it wants a readable deployment history rather than jobs buried inside CI runs. (Reusable workflows with workflow_call are covered in depth in 04-05.)

# .github/workflows/cd.yml
name: CD

on:
  workflow_run:
    workflows: [CI]              # 1 · the name: of the other workflow, not its file
    types: [completed]
    branches: [main]             # 2 · only CI runs that ran on main

permissions: { id-token: write, contents: read }                # 3
concurrency: { group: cd-main, cancel-in-progress: false }      # 4 and 5
env: { TZ: Europe/Madrid, AWS_REGION: eu-west-1 }
  1. workflows: [CI] references the other workflow's name:, not the file name: a classic source of confusion.
  2. branches: [main] filters by the branch the CI ran on; without it, any CI from a PR would try to deploy.
  3. permissions: id-token: write enables the OIDC token from section 4, and contents: read is enough for the checkout.
  4. concurrency with a fixed group per branch stops two deployments treading on each other: if Diego merges two PRs back to back, the second one waits.
  5. cancel-in-progress: false is critical and the opposite of what we did in CI. Cancelling a deployment halfway leaves the service with half the tasks on each version and nobody watching.

And one essential check is missing: workflow_run fires even when CI has failed, so every job needs if: github.event.workflow_run.conclusion == 'success'. Forgetting it is mistake number one with this pattern: deploying the result of a red pipeline.

  1. GitHub environments: protection rules and required reviewers

A GitHub environment is more than a label: it is where a specific environment's secrets and variables live, and where you configure the rules a deployment must satisfy. Reservalia creates three of them in Settings → Environments:

Rule dev staging prod
Required reviewers Marta or Nuria
Deployment branches main main main
Own variables ENVIRONMENT=dev ENVIRONMENT=staging ENVIRONMENT=prod
Own secrets ARN of the dev role ARN of the staging role ARN of the prod role
URL api-dev.reservalia.com api-staging.reservalia.com api.reservalia.com

What makes them rather more than documentation:

  • A job with environment: prod stays paused until a reviewer approves it. That is, literally, the manual gate from the previous lesson; removing it on the day Reservalia moves to Continuous Deployment will mean deleting the reviewer list.
  • Secrets are scoped to the environment. A job with environment: dev cannot read the ARN of the prod role, even though the secret exists in the repository. It is the barrier against copy-and-paste mistakes.
  • deployment branches prevents deploying prod from another branch, and GitHub keeps a per-environment history of who approved and which commit was deployed: auditing for free.

  1. Authenticating with AWS via OIDC, with no long-lived keys

The tempting way to give the pipeline permissions is to create an IAM user and store AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as secrets. It is a bad idea: they are permanent credentials, they have to be rotated by hand and, if they leak into a log, they still work tomorrow. OIDC (OpenID Connect) inverts the model: on every run, GitHub issues a short-lived JWT that says "I am the cd.yml workflow of the reservalia/reservalia repository, on the main branch, with the prod environment", and AWS — which trusts GitHub as an identity provider — hands over one-hour temporary credentials if those claims match the role's trust policy.

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::…:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": { "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": "repo:reservalia/reservalia:environment:prod" } }
}

The decisive line is the sub one: this role can only be assumed by a job from that repository that declares environment: prod. A fork, another repository or a job with no environment gets an AccessDenied. Reservalia has three roles — reservalia-deploy-dev, reservalia-deploy-staging and reservalia-deploy-prod — each with its own condition and with permissions only over its own environment's resources. A frequent mistake is to use wildcards (repo:reservalia/reservalia:*): that allows the production role to be assumed from any branch and any workflow, including one somebody adds in a pull request.

  1. Deploying the API: task definition with the exact digest

  deploy-dev:
    runs-on: ubuntu-22.04
    timeout-minutes: 20
    if: github.event.workflow_run.conclusion == 'success'
    environment: { name: dev, url: 'https://api-dev.reservalia.com' }   # 1
    steps:
      - uses: actions/checkout@v4
        with: { ref: '${{ github.event.workflow_run.head_sha }}' }      # 2
      - id: meta
        run: echo "sha=$(git rev-parse --short=7 HEAD)" >> $GITHUB_OUTPUT

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_DEPLOY }}                # 3
          aws-region: ${{ env.AWS_REGION }}

      - name: Resolve the image digest
        id: image
        run: |                                                          # 4
          DIGEST=$(aws ecr describe-images --repository-name reservalia/api \
            --image-ids imageTag=${{ steps.meta.outputs.sha }} \
            --query 'imageDetails[0].imageDigest' --output text)
          echo "uri=${{ vars.ECR_REGISTRY }}/reservalia/api@${DIGEST}" >> $GITHUB_OUTPUT
  1. url: shows a link to the deployed environment in the GitHub interface.
  2. ref: head_sha is essential with workflow_run: by default the checkout would bring the default branch in its current state, which may already be a different commit. We want the exact commit that produced the artifact.
  3. secrets.AWS_ROLE_DEPLOY resolves to the dev environment's secret, so the same YAML text assumes a different role in each job. It is the pattern that avoids duplicating code per environment.
  4. We resolve the digest, we do not use the tag: a3f9c21 is immutable by convention, sha256:… is immutable by cryptography. It is "build once, promote the same digest" applied literally.

With the URI resolved, the new task definition revision is registered and the service is updated:

      - uses: aws-actions/amazon-ecs-render-task-definition@v1
        id: taskdef
        with:
          task-definition: infra/ecs/taskdef-api.json     # 5 · versioned template
          container-name: api
          image: ${{ steps.image.outputs.uri }}

      - uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.taskdef.outputs.task-definition }}
          service: reservalia-api                          # 6
          cluster: reservalia-dev
          wait-for-service-stability: true                 # 7
          wait-for-minutes: 10
  1. The infra/ecs/taskdef-api.json template is versioned in the repository: CPU, memory, ports, variables and secret references. The action replaces only the image field, so the deployment is reproducible and reviewable in a pull request rather than depending on whatever state happened to be in AWS.
  2. service and cluster: Reservalia uses the same service name across the three environments and different clusters (reservalia-dev, reservalia-staging, reservalia-prod).
  3. wait-for-service-stability turns "I have launched a deployment" into "the deployment has finished". Without it the job would go green in seconds while ECS is still starting containers that may well fail.

  1. Per-environment configuration: the artifact is the same, the configuration changes

It already came up in 02-06 as a corollary of immutability: if the artifact is the same in all three environments, it cannot contain anything environment-specific. Building an image reservalia/api:a3f9c21-prod different from the staging one destroys the whole chain, because what would reach production is a binary that was never tested. Configuration comes in from outside, at start-up, and it is of two kinds:

Non-sensitive configuration Secrets
Examples ENVIRONMENT, LOG_LEVEL, PORT DATABASE_URL, payment gateway key
Where it lives Task definition, environment block Secrets Manager, referenced from secrets
Who can see it Anyone with read access to ECS Only the running task, and it is audited
Rotating it Requires a deployment No deployment needed

The relevant fragment of infra/ecs/taskdef-api.json:

{
  "family": "reservalia-api",
  "containerDefinitions": [{
    "name": "api",
    "image": "REPLACED_BY_THE_PIPELINE",
    "environment": [
      { "name": "ENVIRONMENT", "value": "dev" },
      { "name": "LOG_LEVEL", "value": "debug" },
      { "name": "TZ", "value": "Europe/Madrid" }
    ],
    "secrets": [
      { "name": "DATABASE_URL",
        "valueFrom": "arn:aws:secretsmanager:eu-west-1:…:secret:reservalia/dev/api:DATABASE_URL::" }
    ]
  }]
}

The difference between the two blocks is substantial. environment is plain text, visible in the AWS console and in any describe-task-definition. secrets are references: ECS resolves the value at start-up against Secrets Manager using the task execution role, and the value never appears in the definition or in the pipeline logs. Rotating the database password means changing it in Secrets Manager and restarting the service: nothing has to be rebuilt or redeployed. And one operational rule that saves incidents: the application must fail to start if a mandatory variable is missing, rather than silently falling back to a default value. Better for the deployment to fall over in dev with a clear message than to start up in prod pointing at the wrong database.

  1. Post-deployment checks: stabilisation and smoke tests

wait-for-service-stability answers "has ECS finished?", not "does it work?": a container can be running and returning 500 to everything. The smoke test covers that gap, and it does not just check that the API responds: it verifies that it responds with the version we have just deployed. It is the practical use of the /version endpoint from 02-06.

      - name: Smoke test
        env: { HOST: 'https://api-dev.reservalia.com', EXPECTED: '${{ steps.meta.outputs.sha }}' }
        run: |
          for attempt in $(seq 1 10); do                       # 1
            DEPLOYED=$(curl -sf --max-time 5 "$HOST/version" | jq -r '.commit' || echo "")
            if [ "$DEPLOYED" = "$EXPECTED" ]; then
              echo "OK: $HOST is serving $DEPLOYED"; break
            fi
            echo "Attempt $attempt: seeing '$DEPLOYED', expecting '$EXPECTED'"
            sleep 10
            [ "$attempt" = "10" ] && { echo "::error::the deployment never arrived"; exit 1; }
          done

          curl -sf --max-time 5 "$HOST/health" > /dev/null     # 2
          curl -sf --max-time 5 "$HOST/api/businesses/demo/slots?date=2026-03-10" > /dev/null   # 3
  1. It retries, because DNS and the ALB take a few seconds to send all traffic to the new tasks. A smoke test without retries is a flaky test that will teach the team to ignore red. Ten attempts of ten seconds give 100 s of headroom, well below the job's timeout-minutes: 20.
  2. /health checks the dependencies, not just the process; we will come back to that distinction in 03-04.
  3. A real business route that queries the database catches the classic case of "the application starts but has no permissions on RDS". Taken together, a smoke test must be fast, stable and cover the critical path: it is not the test suite, it is the check that the deployment took effect and that the essentials respond.

  1. Idempotency and retries

An automated deployment will be re-run: because of a network failure, because somebody presses Re-run, because two merges in a row trigger two executions. The property that makes that safe is idempotency: running it twice leaves the system the same as running it once.

Operation Idempotent? Why
update-service with the same digest Yes The desired state already matches; ECS does nothing
CloudFront invalidation Yes It can be repeated with no additional effect
INSERT into the deployments table No It duplicates rows and pollutes the DORA metrics
Migration ALTER TABLE … ADD COLUMN No, unless IF NOT EXISTS It fails the second time
Sending an email to the businesses No They receive it twice

Three practical rules: use declarative operations ("I want this digest") rather than imperative ones ("add an instance"); insert into deployments with ON CONFLICT DO NOTHING on an identifier derived from the run; and protect with concurrency, as we did in section 2. On retries: they are right for transient failures (network timeout, ThrottlingException) and dangerous for deterministic ones — retrying ten times a deployment that is missing a variable only produces ten identical failures. The rule: retry the check, not the decision.

  1. Deploying the web app: S3, CloudFront and cache invalidation

apps/web is not a container: it is static files generated by Vite that get uploaded to S3 and served through CloudFront. Its own particular subtlety is the cache.

  deploy-web-dev:
    needs: [deploy-dev]
    environment: dev
    steps:
      # checkout(head_sha) · setup-node(.nvmrc) · npm ci · npm run build --workspace apps/web
      # · OIDC credentials
      - name: Hashed assets — long cache                       # 1
        run: aws s3 sync apps/web/dist/assets s3://reservalia-web-dev/assets
             --cache-control "public,max-age=31536000,immutable"

      - name: HTML — no cache                                  # 2
        run: aws s3 sync apps/web/dist s3://reservalia-web-dev
             --exclude "assets/*" --delete --cache-control "no-cache"

      - name: Invalidate CloudFront                            # 3
        run: aws cloudfront create-invalidation
             --distribution-id ${{ vars.CLOUDFRONT_DIST_ID }} --paths "/index.html" "/"
  1. The assets go first and with a one-year cache: Vite puts a hash in their names (index-4f3c21a9.js), so every build generates new files and they never need invalidating.
  2. The HTML goes afterwards and with no-cache. The order matters: uploading the new index.html before the assets leaves a few seconds of users requesting JavaScript that does not exist yet. The --delete removes what is obsolete but excludes assets/*, so as not to break anyone who has the previous HTML loaded.
  3. The invalidation only affects the HTML. Invalidating /* works, but it is slow and AWS only gives you 1,000 free paths a month.

  1. Reservalia's cd.yml, from start to finish

With the header from section 2, the workflow has three jobs: deploy-dev (~3 min: checkout of the head_sha, OIDC, digest, taskdef, deploy and smoke), deploy-web-dev (needs: [deploy-dev], Vite build, sync to S3 and invalidation) and deploy-staging (~4 min, needs both of the previous ones, environment: staging, the same digest on the reservalia-staging cluster, with a smoke test and the critical E2E tests from 02-04).

flowchart LR
    CI["ci.yml green on main<br/>publishes a3f9c21"] --> W["workflow_run"]
    W --> D["deploy-dev<br/>~3 min"]
    D --> S1{"smoke /version"}
    S1 -- ok --> WD["deploy-web-dev"]
    WD --> ST["deploy-staging<br/>same digest"]
    ST --> S2{"smoke + E2E"}
    S2 -- ok --> P["prod · pending<br/>03-04"]
    S1 -- fails --> X["red workflow<br/>rollback in 03-05"]

With the perspective of module 1: from Diego merging a PR to the change being in staging, about eleven minutes go by — four of CI, three for dev, four for staging — without anybody typing a command; the Friday ritual took three hours and only reached one environment. prod is still missing, and it is added in 03-04 with a progressive strategy, and so is what happens when the smoke test fails: today the workflow goes red and dev is left with the broken version. That gap is plugged by 03-05.

Common Mistakes and Tips

Mistake 1: forgetting if: github.event.workflow_run.conclusion == 'success'. workflow_run also fires when CI fails; without that filter you deploy the result of a red pipeline. Mistake 2: checking out without ref: head_sha, so you deploy the image from one commit and run the scripts from another.

Mistake 3: storing long-lived AWS keys as secrets. They do not expire, they have to be rotated by hand and if they leak they still work. With OIDC there is nothing to leak.

Mistake 4: baking configuration into the image. One image per environment means what reaches production is an artifact that was never tested.

Mistake 5: accepting the deployment without verifying the version. A green pipeline only tells you the command did not error; if /version still returns the previous SHA, you have not deployed anything. Mistake 6: cancel-in-progress: true in CD, which leaves the service with two versions coexisting and nobody watching.

Tip 1: version the task definition in the repository, so a change of memory or of a variable goes through a pull request. Tip 2: deploy by digest, not by tag. Tip 3: measure how long each deployment takes and treat it as a budget: one that grows from 3 to 12 minutes slows down everything behind it.

Exercises

Exercise 1

Reservalia's deployment to dev goes green, but https://api-dev.reservalia.com/version still returns b7e2d10 instead of a3f9c21. List four possible causes and how to tell them apart.

Exercise 2

A colleague proposes: "Instead of Secrets Manager, let us pass DATABASE_URL as a GitHub secret and inject it into the task definition during the deployment." Explain three concrete problems with that design.

Exercise 3

Diego merges two pull requests two minutes apart. Describe what happens with the concurrency configuration shown, what would happen with cancel-in-progress: true and what would happen with no concurrency at all.

Solutions

Solution 1. Four causes and how to tell them apart:

  1. The wrong image was deployed. Check with aws ecs describe-task-definition and look at the image field: it tells you which digest was actually registered.
  2. The new tasks did not start and ECS rolled back on its own. This shows up in aws ecs describe-services --query 'services[0].events' as tasks being born and dying; almost always a variable is missing or it cannot read a secret.
  3. The ALB is still sending traffic to the old tasks because the deregistration delay has not elapsed. You recognise it because /version alternates between the two SHAs: it is intermittent, not constant. It is resolved by retrying, as the smoke test does.
  4. The image was built without build-arg COMMIT_SHA, so the endpoint lies even though the code is the new one. If describe-task-definition shows the right digest but /version does not, the problem is in the build, not in the deployment.

Solution 2. Three problems: (1) rotation coupled to deployment — changing the password requires editing a GitHub secret and launching a full deployment, when with Secrets Manager restarting the service is enough; (2) the secret ends up written into the task definition, a document readable by anyone with permissions over ECS and preserved in all of its revisions; (3) a larger exposure surface: the value passes through the runner, where a set -x or a compromised third-party action can see it, whereas with valueFrom the runner only knows an ARN and every read is audited in CloudTrail.

A fourth argument, less obvious: if the secret only exists in GitHub, the application cannot start outside the pipeline. Configuration belongs to the environment, not to the system that deploys it.

Solution 3. With the configuration shown, the first one runs in full and the second one waits in the queue: two consecutive deployments, each complete and verified. With cancel-in-progress: true, the second would cancel the first mid-run; at the worst possible moment — during the rolling update — ECS is left with tasks from two versions, with nobody waiting for stabilisation or running the smoke test. And with no concurrency at all, both would run in parallel against the same service: the final state depends on the order in which the calls reach the AWS API, so it is possible for the second one to finish first and for the service to be left with the older commit, with both pipelines green. That is the worst option, because the failure is silent.

Conclusion

Reservalia now has automated deployment up to staging. The artifact reservalia/api:a3f9c21 leaves ECR, is deployed to dev in about three minutes, is verified with a smoke test against /version and carries on to staging with the same digest, rebuilding nothing. The web app travels in parallel to S3 and CloudFront with its cache invalidation.

The decisions that hold the design together: workflow_run to chain CI and CD while keeping separate, re-runnable executions, with the mandatory filter on conclusion == 'success'; GitHub environments as the place where secrets, variables and protection rules live, so that the same YAML assumes a different role in each environment; OIDC to obtain temporary credentials without storing keys; deployment by digest with a versioned task definition; the separation between non-sensitive configuration and secrets, with Secrets Manager resolving the latter at start-up; and the post-deployment checks — stabilisation plus smoke test — that distinguish "I have launched a deployment" from "the deployment works".

One underlying fragility remains. This whole workflow takes for granted that there is a reservalia-dev cluster, a reservalia-api service, an RDS database, an ALB and some IAM roles… and those resources were created by Nuria by hand in the AWS console months ago. Nobody knows for certain how staging differs from prod, because the only source of truth is whatever sits inside AWS. That is requirement 3 from the previous lesson — equivalent environments — and it is still unmet.

The next lesson, Infrastructure as Code and Reproducible Environments, tackles precisely that: why an automated deployment on top of hand-crafted infrastructure is still fragile, what configuration drift is, and how Reservalia's infra/ module is written once in Terraform and instantiated three times so that dev, staging and prod come out of literally the same code.

CI/CD Course: Continuous Integration and Deployment

Module 1: Introduction to CI/CD

Module 2: Continuous Integration (CI)

Module 3: Continuous Deployment (CD)

Module 4: Advanced CI/CD Practices

Module 5: Implementing CI/CD in Real Projects

Module 6: Tools and Technologies

Module 7: Practical Exercises

Module 8: Additional Resources

© Copyright 2026. All rights reserved