The previous lesson drew the map: the artifact reservalia/api:a3f9c21 has to travel dev → staging → prod 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
- What "deploying" actually means in ECS Fargate
- Chaining CI and CD:
workflow_runversusworkflow_call - GitHub environments: protection rules and required reviewers
- Authenticating with AWS via OIDC, with no long-lived keys
- Deploying the API: task definition with the exact digest
- Per-environment configuration: the artifact is the same, the configuration changes
- Post-deployment checks: stabilisation and smoke tests
- Idempotency and retries
- Deploying the web app: S3, CloudFront and cache invalidation
- Reservalia's
cd.yml, from start to finish - Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- Chaining CI and CD:
workflow_run versus workflow_call
workflow_run versus workflow_callReservalia'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 }workflows: [CI]references the other workflow'sname:, not the file name: a classic source of confusion.branches: [main]filters by the branch the CI ran on; without it, any CI from a PR would try to deploy.permissions:id-token: writeenables the OIDC token from section 4, andcontents: readis enough for the checkout.concurrencywith a fixedgroupper branch stops two deployments treading on each other: if Diego merges two PRs back to back, the second one waits.cancel-in-progress: falseis 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.
- 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: prodstays 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: devcannot read the ARN of theprodrole, even though the secret exists in the repository. It is the barrier against copy-and-paste mistakes. deployment branchesprevents deployingprodfrom another branch, and GitHub keeps a per-environment history of who approved and which commit was deployed: auditing for free.
- 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.
- 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_OUTPUTurl:shows a link to the deployed environment in the GitHub interface.ref: head_shais essential withworkflow_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.secrets.AWS_ROLE_DEPLOYresolves to thedevenvironment's secret, so the same YAML text assumes a different role in each job. It is the pattern that avoids duplicating code per environment.- We resolve the digest, we do not use the tag:
a3f9c21is 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- The
infra/ecs/taskdef-api.jsontemplate is versioned in the repository: CPU, memory, ports, variables and secret references. The action replaces only theimagefield, so the deployment is reproducible and reviewable in a pull request rather than depending on whatever state happened to be in AWS. serviceandcluster: Reservalia uses the same service name across the three environments and different clusters (reservalia-dev,reservalia-staging,reservalia-prod).wait-for-service-stabilityturns "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.
- 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.
- 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- 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. /healthchecks the dependencies, not just the process; we will come back to that distinction in 03-04.- 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.
- 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.
- 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" "/"- 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. - The HTML goes afterwards and with
no-cache. The order matters: uploading the newindex.htmlbefore the assets leaves a few seconds of users requesting JavaScript that does not exist yet. The--deleteremoves what is obsolete but excludesassets/*, so as not to break anyone who has the previous HTML loaded. - The invalidation only affects the HTML. Invalidating
/*works, but it is slow and AWS only gives you 1,000 free paths a month.
- Reservalia's
cd.yml, from start to finish
cd.yml, from start to finishWith 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:
- The wrong image was deployed. Check with
aws ecs describe-task-definitionand look at theimagefield: it tells you which digest was actually registered. - 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. - The ALB is still sending traffic to the old tasks because the deregistration delay has not elapsed. You recognise it because
/versionalternates between the two SHAs: it is intermittent, not constant. It is resolved by retrying, as the smoke test does. - The image was built without
build-arg COMMIT_SHA, so the endpoint lies even though the code is the new one. Ifdescribe-task-definitionshows the right digest but/versiondoes 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
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
