Day 2 · GitHub Actions II — Build, Push & Deploy¶
A first CI workflow runs a few commands and stops. Real pipelines need to carry data — a computed version tag, a build's result feeding a deploy — and to talk to the outside world with credentials, without leaking them. This covers the pieces that make that possible: variables and secrets, expressions and functions, passing outputs between steps and jobs, conditions, and connecting to AWS to run the AWS CLI — then puts them together to build a Docker image, push it to Amazon ECR, and deploy the container to a server.
Where this fits
A basic CI workflow (lint → test → build) is covered in CI/CD Fundamentals & First Pipeline. This adds the language features and credentials a real build → push → deploy pipeline needs.
Learning Objectives¶
- Use environment variables and configuration variables vs secrets, at the right scope
- Write expressions (
${{ }}) with contexts and built-in functions - Pass data with step outputs and job outputs, and reference them downstream
- Gate work with conditions (
if:) - Authenticate to AWS from a workflow (keys via secrets, with OIDC as the best practice) and run the AWS CLI
- Lab: add a Dockerfile and a workflow that build the app's image and push it to Amazon ECR — using secrets, a variable, a step output, and AWS auth
Prerequisites¶
- Day 1's
sample-apprepo with CI passing - Docker installed locally
- An AWS account and an IAM user with an access key (or permission to create one)
Theory · ~35 min¶
1. Environment variables¶
env: defines environment variables for run: steps. Set them at three scopes — workflow, job, or step — where the narrower scope wins:
env:
APP_ENV: production # workflow scope — visible to every job/step
jobs:
build:
runs-on: ubuntu-latest
env:
LOG_LEVEL: debug # job scope — this job's steps
steps:
- name: Show them
env:
GREETING: hello # step scope — only this step
run: echo "$GREETING in $APP_ENV at $LOG_LEVEL"
GitHub also injects default environment variables into every run:
| Variable | Value |
|---|---|
GITHUB_SHA |
the commit SHA that triggered the run |
GITHUB_REF |
the branch/tag ref (refs/heads/main) |
GITHUB_REPOSITORY |
owner/repo |
GITHUB_RUN_NUMBER |
an incrementing run count |
RUNNER_OS |
Linux / Windows / macOS |
The same data is available inside expressions via the github context (Section 5).
→ Reference: Store information in variables · Default environment variables
2. Variables vs secrets¶
Two ways to inject configuration, chosen by sensitivity. Both are set under Settings → Secrets and variables → Actions:
| Configuration variables | Secrets | |
|---|---|---|
| Reference | ${{ vars.NAME }} |
${{ secrets.NAME }} |
| Stored under | the Variables tab | the Secrets tab |
| Visible? | Yes, plain text | Encrypted, masked in logs as *** |
| Use for | region, image name, non-sensitive config | passwords, tokens, keys |
env:
AWS_REGION: ${{ vars.AWS_REGION }} # non-sensitive → a variable
steps:
- run: ./deploy.sh
env:
API_TOKEN: ${{ secrets.API_TOKEN }} # sensitive → a secret
env: variables vs vars — not the same thing
env:(Section 1) is defined in the workflow file and becomes a real environment variable on the runner — read it as$NAMEinrun:or${{ env.NAME }}. It's local to that workflow/job/step, and changing it means editing YAML.varsare set in the repo/org/environment settings UI, shared across all your workflows, and changed without touching code. They are read only via${{ vars.NAME }}— they are not automatically environment variables. To use one as a shell env var, surface it intoenv:first, exactly asAWS_REGIONdoes above.
Both vars and secrets come in repository, environment, and organization scopes. And every workflow automatically gets a GITHUB_TOKEN secret — a short-lived token scoped to the repo (used to push to GHCR, comment on PRs, etc.). Grant it least privilege with a permissions: block.
Secrets are masked, not bulletproof
GitHub masks secret values in logs, but a malicious step could still exfiltrate them. Only run trusted actions, and pin third-party actions to a commit SHA (see Security Best Practices).
→ Reference: Configuration variables · Using secrets · GITHUB_TOKEN
3. Conditions — if:¶
An if: on a job or a step decides whether it runs. Inside if:, the ${{ }} is optional:
deploy:
needs: build
if: github.ref == 'refs/heads/main' # only deploy from main
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
- name: Notify on failure
if: failure() # only if an earlier step failed
run: ./notify.sh
Common patterns: if: github.event_name == 'pull_request', if: github.ref == 'refs/heads/main', if: contains(github.event.head_commit.message, '[deploy]'), and status functions like failure(). What you write inside an if: is just an expression, built from functions (Section 4) and contexts (Section 5).
→ Reference: jobs.<job_id>.if
4. Expressions & functions¶
Anything inside ${{ }} is an expression, evaluated before the step runs. Expressions combine contexts (Section 5), operators (==, !=, &&, ||, !), and built-in functions:
| Function | Does |
|---|---|
contains(a, b) |
true if a contains b |
startsWith(a, b) · endsWith(a, b) |
prefix / suffix test |
format('{0}-{1}', x, y) |
string templating |
hashFiles('**/package-lock.json') |
hash of matching files — a great cache key |
toJSON(x) |
dump a whole context (debugging) |
Plus status functions used in conditions (Section 3): success(), failure(), cancelled(), always().
- run: echo "building ${{ github.repository }} @ ${{ github.sha }}"
- run: echo "this is a tag build"
if: startsWith(github.ref, 'refs/tags/')
→ Reference: Expressions & functions
5. Contexts¶
A context is an object of run data you read inside an expression with dot notation (github.sha). The ones you'll reach for most:
| Context | Holds |
|---|---|
github |
event data — github.sha, github.ref, github.actor, github.repository |
env |
your env: variables |
vars / secrets |
configuration variables / secrets |
steps |
outputs of earlier steps (Section 6) |
needs |
outputs of jobs this one depends on (Section 6) |
runner |
runner info (runner.os) |
→ Reference: Contexts
6. Passing data — step & job outputs¶
Steps and jobs are isolated, so values are passed explicitly.
Step → step: give a step an id, write name=value to the $GITHUB_OUTPUT file, then read it from the steps context:
steps:
- id: meta
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- run: echo "image tag is ${{ steps.meta.outputs.tag }}"
Job → job: a job declares outputs: (pointing at a step output); a downstream job that needs: it reads needs.<job>.outputs.<name>:
jobs:
build:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tag }} # expose it to other jobs
steps:
- id: meta
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
deploy:
needs: build # required to read its outputs
runs-on: ubuntu-latest
steps:
- run: echo "deploying tag ${{ needs.build.outputs.tag }}"
→ Reference: Pass information between jobs · jobs.<job_id>.outputs
7. Connecting to AWS & running the AWS CLI¶
To deploy to AWS, the pipeline needs credentials. The simplest way to see how it works: create an IAM user, give it an access key (an access key ID + a secret access key), store both as GitHub secrets, and hand them to aws-actions/configure-aws-credentials — which configures the AWS CLI (preinstalled on GitHub runners) for the rest of the job:
steps:
- uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} # read from GitHub secrets
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
- run: aws sts get-caller-identity # authenticated — "who am I?"
After that step, every aws command in the job is authenticated.
Never hard-code credentials — and prefer OIDC in production
Two rules that matter more than anything else here:
- Never put an access key in the YAML (or anywhere in git). Always reference it through
${{ secrets.* }}, exactly as above — the workflow file never contains the actual key. - Access keys are long-lived — if one leaks, it stays valid until you rotate it. The production best practice is OIDC: GitHub presents a short-lived, signed token and AWS exchanges it for temporary credentials, so no keys are stored at all. It's set up in Terraform in CI/CD — prefer it for anything real.
Use access keys here to learn the flow; reach for OIDC in production.
→ Reference: aws-actions/configure-aws-credentials · Configuring OIDC in AWS
Lab · ~45 min¶
Extend your sample-app repo (from Day 1) to build the API into a Docker image and push it to Amazon ECR. You'll add a Dockerfile and a new workflow file — the workflow exercises the secrets, variable, step output, and AWS authentication from the theory.
1a. Add a Dockerfile¶
In the sample-app repo, add api/Dockerfile:
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Add api/.dockerignore (node_modules, npm-debug.log), then build it once locally to confirm it works:
cd api
docker build -t sample-api .
docker run --rm -p 3000:3000 sample-api &
curl localhost:3000/healthz # {"status":"ok"}
1b. Create the ECR repository and AWS credentials¶
Amazon ECR (Elastic Container Registry) is AWS's private image registry. Create a repository for the image — console → ECR → Create repository, or the CLI:
Then, in the GitHub repo (Settings → Secrets and variables → Actions):
- Variable
AWS_REGION = us-east-1. - Secrets
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY— the access key of an IAM user allowed to push to ECR (stored as secrets, never in the YAML — see Section 7).
1c. Add the workflow¶
Add .github/workflows/ecr.yml — authenticate to AWS, log in to ECR, then build and push the image tagged with the commit SHA:
name: Build and push to ECR
on:
push:
branches: [main]
workflow_dispatch:
env:
ECR_REPOSITORY: sample-api # the ECR repo you created
IMAGE_TAG: ${{ github.sha }} # immutable tag — this commit
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Authenticate to AWS
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} # a secret
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }} # a variable
- name: Log in to Amazon ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2 # exposes the registry URL as an output
- name: Build and push
uses: docker/build-push-action@v7
with:
context: ./api
push: true
tags: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
Notice how the theory shows up: secrets for the keys, a variable for the region, environment variables for the repo name and tag, and the ECR login step output (steps.ecr.outputs.registry) — assembled inline in tags:.
1d. Push and verify¶
Open the Actions tab and watch the Build and push to ECR workflow run. When it's green, confirm the image landed:
(or open the repository in the ECR console) — the image is there, tagged with your commit SHA.
What you just built
A workflow that builds your app into a Docker image and publishes it to a private AWS registry — wired together with secrets, a variable, step outputs, and AWS authentication from the theory.
Clean up
Stored images cost a little each month. Remove the repo when you're done:
Advanced Topics¶
Powerful features to layer on once the basics click:
- Caching — cache dependencies/Docker layers between runs with a
hashFiles()key → Caching dependencies - Matrix builds — run one job across a grid (Node 20/22/24 × OS) from a single definition → Using a matrix
- Reusable workflows — factor a pipeline into a
workflow_callfile that many repos share → Reusing workflows - Environments — a named target (
staging/production) with its own secrets and a required-reviewer approval gate → Using environments - Self-hosted / custom runners — your own machines (private network access, special hardware, more compute) via
runs-on: self-hosted→ Self-hosted runners - Workflow permissions — scope the automatic
GITHUB_TOKENto least privilege with apermissions:block →permissionssyntax · Automatic token authentication
Assignment¶
Goal: run the image you pushed to ECR on a real server — pulled and started by Ansible, not by hand.
- Launch an EC2 instance (Ubuntu 24.04,
t3.micro) with a security group that allows SSH from your IP and HTTP (port 80) from anywhere. Give it an IAM role (or credentials) that can read from ECR. - Write an Ansible playbook (building on Week 3) that, on the instance:
- installs Docker,
- logs in to ECR (
aws ecr get-login-password | docker login ...), - pulls your image from ECR, and
- runs it as a container, publishing container port 3000 on host port 80.
- Run the playbook, then
curl http://<ec2-public-ip>/healthz— you should get{"status":"ok"}, served by the container Ansible deployed.
Submit: your playbook + inventory, and the curl output showing the app responding from the EC2.
Clean up
Terminate the EC2 and delete the ECR repository when you're done.
Further Reading¶
Watch
- 📺 GitHub Actions — Build & Push Docker images — the CI-pipeline-with-Docker segment of the GitHub Actions walkthrough
Reference