A public GitHub repository you can show in an interview: a FastAPI service with tests, a multi-stage Dockerfile, a GitHub Actions pipeline (lint, test, build, push to GHCR, deploy), Terraform for an EC2 host with remote state and OIDC-authenticated plan/apply, and a Grafana dashboard showing request rate, latency and errors from the live service — plus the commands to tear it all down so it costs nothing afterwards.
- A GitHub account and an AWS account (a new account's free tier covers everything here; expect under $1 if you tear down within a few days)
- Comfortable in a terminal:
cd,ls, editing files. Modules 1 (Linux) and 2 (Git) of the DevOps track cover this - A laptop running macOS, Windows 10/11 or Ubuntu with about 10 GB free for Docker images
- No prior Docker, Terraform or AWS experience needed — each is introduced where it is used
- Git and the GitHub CLI (gh) — version control and creating the repo, PRs and secrets from the terminal ↗
- Visual Studio Code — editor with extensions for Python, Docker, Terraform and GitHub Actions ↗
- Python 3.12 — the API is FastAPI; tests run with pytest ↗
- Docker Desktop (or Docker Engine on Linux) — build and run the container image locally, exactly as production will ↗
- Terraform 1.10 or newer — define the AWS infrastructure as code; 1.10+ has S3-native state locking ↗
- AWS CLI v2 — bootstrap the state bucket and verify what Terraform created ↗
zero-to-prod/
├── .github/workflows/
│ ├── ci.yml # lint + test on every PR
│ ├── release.yml # build, push to GHCR, deploy on merge to main
│ └── infra.yml # terraform plan on PR, apply on merge
├── app/
│ ├── __init__.py
│ └── main.py # FastAPI service with /health and /metrics
├── tests/
│ └── test_main.py
├── infra/
│ ├── main.tf # EC2 host, security group, IAM role for SSM
│ ├── variables.tf
│ ├── outputs.tf
│ ├── backend.tf # remote state in S3
│ └── user_data.sh # installs Docker and starts the stack on boot
├── deploy/
│ ├── compose.yml # app + prometheus + grafana + node-exporter
│ ├── prometheus.yml
│ ├── alerts.yml
│ └── grafana/ # provisioned datasource + dashboard
├── Dockerfile
├── .dockerignore
├── .gitignore
├── pyproject.toml
├── requirements.txt
└── README.mdTick each step as you finish it — your progress is saved in this browser only (back up or restore on the hub). Every code block has a copy button. If something goes wrong, the troubleshooting section at the end covers the usual suspects.
Install the tools
Get every tool installed and verified before writing a line of code, so later phases never stall on setup.
- Install Git, the GitHub CLI, Python 3.12, Terraform and the AWS CLI. On macOS use Homebrew (install it from brew.sh first if you do not have it).bash
brew install git gh python@3.12 hashicorp/tap/terraform awscliWindows: open PowerShell and runwinget install Git.Git GitHub.cli Python.Python.3.12 Hashicorp.Terraform Amazon.AWSCLI. Ubuntu:sudo apt install git python3.12 python3.12-venv, then follow the vendor pages linked above for gh, Terraform and the AWS CLI (their apt repositories are more current than Ubuntu's). - Install Docker Desktop from docker.com (macOS and Windows) and start it once so the daemon is running. On Ubuntu install Docker Engine with the official convenience script and add yourself to the docker group, then log out and in again.bash
curl -fsSL https://get.docker.com | sh sudo usermod -aG docker "$USER"Only the Ubuntu commands are shown; Docker Desktop is a normal installer on macOS and Windows. - Install Visual Studio Code, then add the extensions you will use: Python, Docker, HashiCorp Terraform and GitHub Actions. You can do it from the terminal.bash
code --install-extension ms-python.python code --install-extension ms-azuretools.vscode-docker code --install-extension hashicorp.terraform code --install-extension github.vscode-github-actionsIfcodeis not found on macOS, open VS Code, press Cmd+Shift+P and run "Shell Command: Install 'code' command in PATH". - Verify every tool prints a version. Fix anything that fails now.bash
git --version gh --version python3.12 --version docker --version && docker compose version terraform -version aws --versionCheck: Six version lines, no "command not found". Terraform must be 1.10 or newer for the S3 lockfile backend used later. - Log the GitHub CLI into your account. Choose HTTPS and let it authenticate git for you when asked.bash
gh auth login gh auth statusCheck:gh auth statusshows "Logged in to github.com". - Create an AWS access key for your own user (IAM → Users → your user → Security credentials → Create access key → Command Line Interface) and configure the CLI with it. Pick a region and stick to it for the whole project; the steps assume
eu-west-1, change it consistently if you prefer another.bashaws configure # AWS Access Key ID: paste it # AWS Secret Access Key: paste it # Default region name: eu-west-1 # Default output format: json aws sts get-caller-identityThis key is only for bootstrapping from your laptop. The pipeline will not use it — it authenticates with OIDC in Phase 6, which is the practice the Secrets module recommends.Check:get-caller-identityprints your account ID and user ARN.
Create the repository
A public repository with sensible defaults, opened in your editor, with a first commit on main and the branch protected.
- Create the repository on GitHub from the terminal, clone it, and enter the directory. Public is deliberate: the container image will be public too, which keeps the deployment simple, and a public repo is what you will show people.bash
gh repo create zero-to-prod --public --clone --description "FastAPI service with CI/CD, Docker, Terraform and Grafana" cd zero-to-prod - Add a
.gitignorefor Python, Terraform and editor files. Terraform state and.envfiles must never be committed.text# Python __pycache__/ *.pyc .venv/ .pytest_cache/ .ruff_cache/ # Terraform infra/.terraform/ infra/*.tfstate infra/*.tfstate.backup infra/.terraform.lock.hcl infra/*.tfvars # Local secrets and editor files .env .DS_Store .vscode/settings.json - Write a short README now and grow it as you go; a repo with a README that explains how to run it is the difference between a project and a folder.text
# zero-to-prod A small FastAPI service taken all the way to production: tests on every PR, container image on every merge, Terraform-managed AWS host, Prometheus + Grafana dashboards. ## Run locally python3.12 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt uvicorn app.main:app --reload Then open http://localhost:8000/docs - Open the folder in VS Code and make the first commit.bash
code . git add .gitignore README.md git commit -m "chore: repository skeleton" git push -u origin mainCheck: The repo page on GitHub shows the README. - Protect
mainso nothing lands without a pull request and green checks. This is what makes the pipeline meaningful.bashgh api -X PUT "repos/{owner}/zero-to-prod/branches/main/protection" \ --input - <<'JSON' { "required_status_checks": {"strict": true, "contexts": []}, "enforce_admins": false, "required_pull_request_reviews": null, "restrictions": null, "allow_force_pushes": false, "allow_deletions": false } JSON{owner}is filled in by gh automatically. We leave the required checks list empty for now and add the CI job name in Phase 5 once it exists. Reviews are not required because you are working alone; on a team you would require at least one.
Build the API
A FastAPI service with a health endpoint, a metrics endpoint and tests, developed on a feature branch and merged through a pull request.
- Create a feature branch. From here on, all work happens on branches and reaches
mainthrough pull requests.bashgit switch -c feat/api - Create and activate a virtual environment, then declare the dependencies. Pinning exact versions makes builds reproducible; these are known-good together.bash
python3.12 -m venv .venv source .venv/bin/activate cat > requirements.txt <<'EOF' fastapi==0.115.6 uvicorn[standard]==0.34.0 prometheus-fastapi-instrumentator==7.0.0 EOF cat > requirements-dev.txt <<'EOF' -r requirements.txt pytest==8.3.4 httpx==0.28.1 ruff==0.8.4 EOF pip install -r requirements-dev.txtWindows PowerShell activates with.venv\Scripts\Activate.ps1. VS Code will offer to use this interpreter; accept. - Write the application. It exposes
/(a greeting that reads its message from an environment variable, twelve-factor style),/healthfor probes, and/metricsfor Prometheus.python# app/main.py import os from fastapi import FastAPI from prometheus_fastapi_instrumentator import Instrumentator app = FastAPI(title="zero-to-prod") Instrumentator().instrument(app).expose(app, endpoint="/metrics") @app.get("/") def root(): return {"message": os.environ.get("GREETING", "hello from zero-to-prod")} @app.get("/health") def health(): return {"status": "ok"}Createapp/__init__.pyas an empty file soappis a package:touch app/__init__.py. - Run it locally and open the interactive docs. Try
/healthand/metricsin the browser.bashuvicorn app.main:app --reload # in another terminal: curl -s localhost:8000/health curl -s localhost:8000/metrics | head -5Check:/healthreturns{"status":"ok"}and/metricsprints Prometheus text starting with# HELP. - Add tests. They use FastAPI's test client, so no server needs to be running.python
# tests/test_main.py from fastapi.testclient import TestClient from app.main import app client = TestClient(app) def test_root_returns_default_greeting(): r = client.get("/") assert r.status_code == 200 assert "message" in r.json() def test_health(): assert client.get("/health").json() == {"status": "ok"} def test_metrics_exposed(): r = client.get("/metrics") assert r.status_code == 200 assert "http_requests_total" in r.textThe metrics test calls/metricsafter other requests, so the counter exists. Prometheus metric names come from the instrumentator's defaults. - Configure ruff (linter and formatter) and pytest in
pyproject.toml, then run both.toml[tool.ruff] line-length = 100 target-version = "py312" [tool.ruff.lint] select = ["E", "F", "I", "B"] [tool.pytest.ini_options] testpaths = ["tests"]Check: Runruff check . && ruff format --check . && pytest -q— three passing tests, no lint errors. - Commit on the branch, push it, and open a pull request. Merge it yourself after reading the diff on GitHub as if you were the reviewer.bash
git add . git commit -m "feat: FastAPI service with health and metrics endpoints" git push -u origin feat/api gh pr create --fill gh pr merge --squash --delete-branch git switch main && git pull--squashkeepsmainhistory to one commit per PR.--delete-branchcleans up the remote and local branch.
Containerise it
A small, non-root container image that runs the API identically on your laptop and on the server, plus a local Compose stack with Prometheus and Grafana so you can see metrics before touching the cloud.
- Start a branch and write a multi-stage Dockerfile. The first stage installs dependencies into a virtualenv; the final stage copies only that virtualenv and the app, and runs as an unprivileged user.bash
git switch -c feat/docker - Create the Dockerfile.dockerfile
# syntax=docker/dockerfile:1 FROM python:3.12-slim AS build WORKDIR /src COPY requirements.txt . RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir -r requirements.txt FROM python:3.12-slim RUN useradd --create-home --uid 10001 app WORKDIR /home/app COPY --from=build /venv /venv COPY app ./app ENV PATH="/venv/bin:$PATH" PYTHONUNBUFFERED=1 USER app EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')" CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] - Add a
.dockerignoreso the build context stays small and secrets never end up in a layer.text.git .venv .github infra deploy tests __pycache__ *.pyc .env .pytest_cache .ruff_cache README.md - Build and run the image locally, then hit it.bash
docker build -t zero-to-prod:dev . docker run --rm -p 8000:8000 -e GREETING="hello from a container" zero-to-prod:dev # another terminal: curl -s localhost:8000/Check: The response says "hello from a container".docker image ls zero-to-prodshows an image around 150 MB — slim base, no build tools. - Create the local observability stack: a Compose file that runs the app, Prometheus (scraping the app and node-exporter) and Grafana. This same file is deployed to the server later.yaml
# deploy/compose.yml services: app: image: ${APP_IMAGE:-zero-to-prod:dev} environment: GREETING: ${GREETING:-hello from compose} ports: ["80:8000"] restart: unless-stopped prometheus: image: prom/prometheus:v3.1.0 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./alerts.yml:/etc/prometheus/alerts.yml:ro - prom-data:/prometheus ports: ["9090:9090"] restart: unless-stopped node-exporter: image: prom/node-exporter:v1.8.2 pid: host volumes: ["/:/host:ro,rslave"] command: ["--path.rootfs=/host"] restart: unless-stopped grafana: image: grafana/grafana:11.4.0 environment: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin} GF_USERS_ALLOW_SIGN_UP: "false" volumes: - ./grafana/provisioning:/etc/grafana/provisioning:ro - ./grafana/dashboards:/var/lib/grafana/dashboards:ro - grafana-data:/var/lib/grafana ports: ["3000:3000"] restart: unless-stopped volumes: prom-data: grafana-data: - Tell Prometheus what to scrape. Inside Compose, services reach each other by name.yaml
# deploy/prometheus.yml global: scrape_interval: 15s evaluation_interval: 15s rule_files: - /etc/prometheus/alerts.yml scrape_configs: - job_name: app static_configs: - targets: ["app:8000"] - job_name: node static_configs: - targets: ["node-exporter:9100"] - job_name: prometheus static_configs: - targets: ["localhost:9090"] - Add one alert rule: the 5xx ratio over five minutes above 5 percent. It fires in the Prometheus UI; wiring a pager is the SRE track's job.yaml
# deploy/alerts.yml groups: - name: app rules: - alert: HighErrorRatio expr: | sum(rate(http_requests_total{job="app",status=~"5.."}[5m])) / sum(rate(http_requests_total{job="app"}[5m])) > 0.05 for: 2m labels: severity: page annotations: summary: "More than 5% of requests are failing" - alert: AppDown expr: up{job="app"} == 0 for: 1m labels: severity: page annotations: summary: "Prometheus cannot scrape the app" - Provision Grafana's datasource so nobody has to click through the UI.yaml
# deploy/grafana/provisioning/datasources/prometheus.yml apiVersion: 1 datasources: - name: Prometheus type: prometheus uid: prometheus url: http://prometheus:9090 isDefault: true editable: false - Provision the dashboard loader, pointing at the folder the dashboard JSON lives in.yaml
# deploy/grafana/provisioning/dashboards/dashboards.yml apiVersion: 1 providers: - name: zero-to-prod folder: "" type: file options: path: /var/lib/grafana/dashboards - Add the dashboard itself: request rate, p95 latency, error ratio and host CPU. Small on purpose; you will extend it later.json
{ "title": "zero-to-prod", "uid": "zero-to-prod", "schemaVersion": 39, "refresh": "10s", "time": {"from": "now-30m", "to": "now"}, "panels": [ {"type": "timeseries", "title": "Requests / s", "gridPos": {"x": 0, "y": 0, "w": 12, "h": 8}, "targets": [{"expr": "sum(rate(http_requests_total{job=\"app\"}[1m]))", "legendFormat": "rps"}]}, {"type": "timeseries", "title": "p95 latency (s)", "gridPos": {"x": 12, "y": 0, "w": 12, "h": 8}, "targets": [{"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job=\"app\"}[5m])) by (le))", "legendFormat": "p95"}]}, {"type": "stat", "title": "5xx ratio (5m)", "gridPos": {"x": 0, "y": 8, "w": 12, "h": 6}, "fieldConfig": {"defaults": {"unit": "percentunit", "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 0.05}]}}}, "targets": [{"expr": "sum(rate(http_requests_total{job=\"app\",status=~\"5..\"}[5m])) / sum(rate(http_requests_total{job=\"app\"}[5m]))"}]}, {"type": "timeseries", "title": "Host CPU busy %", "gridPos": {"x": 12, "y": 8, "w": 12, "h": 6}, "targets": [{"expr": "100 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[2m])) * 100", "legendFormat": "cpu"}]} ] }Save it asdeploy/grafana/dashboards/zero-to-prod.json. - Start the whole stack locally and generate some traffic.bash
cd deploy docker compose up -d for i in $(seq 1 200); do curl -s localhost/ > /dev/null; done cd ..Check: Open http://localhost:3000 (admin / admin), the dashboard "zero-to-prod" shows a request-rate line. http://localhost:9090/alerts lists both rules as inactive (green). - Commit and merge through a pull request, as before.bash
git add . git commit -m "feat: Dockerfile and local compose stack with Prometheus and Grafana" git push -u origin feat/docker gh pr create --fill && gh pr merge --squash --delete-branch git switch main && git pull
Continuous integration
Every pull request is linted and tested automatically; every merge to main builds the image and pushes it to the GitHub Container Registry with an immutable tag.
- Branch, then write the CI workflow. It runs on pull requests and on pushes to
main, installs the dev dependencies with pip caching, lints, and tests.bashgit switch -c ci/pipeline mkdir -p .github/workflows - Create
.github/workflows/ci.yml.yamlname: ci on: pull_request: push: branches: [main] permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip - run: pip install -r requirements-dev.txt - run: ruff check . && ruff format --check . - run: pytest -q - Create the release workflow. On every push to
mainit builds the image once, tags it with the commit SHA andlatest, and pushes to GHCR using the job's own token — no secrets to manage.yamlname: release on: push: branches: [main] paths-ignore: ["infra/**", "README.md"] permissions: contents: read packages: write id-token: write env: IMAGE: ghcr.io/${{ github.repository }} jobs: build: runs-on: ubuntu-latest outputs: tag: ${{ steps.meta.outputs.tag }} steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta run: echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - uses: docker/build-push-action@v6 with: context: . push: true tags: | ${{ env.IMAGE }}:${{ steps.meta.outputs.tag }} ${{ env.IMAGE }}:latest cache-from: type=gha cache-to: type=gha,mode=maxThedeployjob is added to this file in Phase 7, once there is a server to deploy to.id-token: writeis already declared for it. - Open the pull request and watch the
ciworkflow run on it. Fix anything red before merging.bashgit add .github git commit -m "ci: lint and test on PRs, build and push image on main" git push -u origin ci/pipeline gh pr create --fill gh pr checks --watchCheck:gh pr checksends with thetestjob passing. - Now that the check exists, make it required on
main, then merge.bashgh api -X PATCH "repos/{owner}/zero-to-prod/branches/main/protection/required_status_checks" \ -f strict=true -f 'contexts[]=test' gh pr merge --squash --delete-branch git switch main && git pull gh run watchCheck: Thereleaserun finishes green. On your GitHub profile → Packages,zero-to-prodappears with tagslatestandsha-…. - Make the package public so the server can pull it without credentials: on GitHub open the package → Package settings → Danger Zone → Change visibility → Public. Then confirm from your laptop with no login.bash
docker logout ghcr.io docker pull ghcr.io/YOUR_GITHUB_USER/zero-to-prod:latestCheck: The pull succeeds while logged out.
Infrastructure as code
An EC2 host defined in Terraform, with remote state in S3 and no SSH: the instance is managed through AWS Systems Manager, and it starts the Compose stack on boot.
- Create the S3 bucket that will hold Terraform state. Bucket names are global, so include your account ID. Versioning lets you recover a state file if something goes wrong.bash
ACCOUNT=$(aws sts get-caller-identity --query Account --output text) aws s3api create-bucket --bucket "tfstate-zero-to-prod-$ACCOUNT" --region eu-west-1 \ --create-bucket-configuration LocationConstraint=eu-west-1 aws s3api put-bucket-versioning --bucket "tfstate-zero-to-prod-$ACCOUNT" \ --versioning-configuration Status=Enabled echo "tfstate-zero-to-prod-$ACCOUNT"Inus-east-1omit the--create-bucket-configurationflag; that region rejects it. Keep the printed bucket name; you need it in the next step. - Branch and write the backend configuration. Terraform 1.10+ can lock state with a lockfile object in the same bucket, so no DynamoDB table is needed.bash
git switch -c infra/ec2 mkdir -p infra - Create
infra/backend.tfwith your bucket name.hclterraform { required_version = ">= 1.10" backend "s3" { bucket = "tfstate-zero-to-prod-123456789012" key = "ec2/terraform.tfstate" region = "eu-west-1" encrypt = true use_lockfile = true } required_providers { aws = { source = "hashicorp/aws" version = "~> 5.80" } } } provider "aws" { region = var.region } - Declare the variables.hcl
# infra/variables.tf variable "region" { type = string default = "eu-west-1" } variable "name" { type = string default = "zero-to-prod" } variable "image" { description = "Container image to run, e.g. ghcr.io/you/zero-to-prod:latest" type = string } variable "grafana_password" { type = string sensitive = true } variable "my_ip" { description = "Your public IP in CIDR form; Grafana and Prometheus are only reachable from here" type = string } - Write the main configuration: the default VPC, a security group, an IAM role that lets Systems Manager manage the instance, the latest Amazon Linux 2023 image, the instance itself and an Elastic IP.hcl
# infra/main.tf data "aws_vpc" "default" { default = true } data "aws_subnets" "default" { filter { name = "vpc-id" values = [data.aws_vpc.default.id] } } data "aws_ssm_parameter" "al2023" { name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64" } resource "aws_security_group" "app" { name = var.name description = "web in, admin UIs only from my IP" vpc_id = data.aws_vpc.default.id ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = 3000 to_port = 3000 protocol = "tcp" cidr_blocks = [var.my_ip] } ingress { from_port = 9090 to_port = 9090 protocol = "tcp" cidr_blocks = [var.my_ip] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } resource "aws_iam_role" "instance" { name = "${var.name}-instance" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } Action = "sts:AssumeRole" }] }) } resource "aws_iam_role_policy_attachment" "ssm" { role = aws_iam_role.instance.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" } resource "aws_iam_instance_profile" "instance" { name = "${var.name}-instance" role = aws_iam_role.instance.name } resource "aws_instance" "app" { ami = data.aws_ssm_parameter.al2023.value instance_type = "t3.micro" subnet_id = data.aws_subnets.default.ids[0] vpc_security_group_ids = [aws_security_group.app.id] iam_instance_profile = aws_iam_instance_profile.instance.name user_data = templatefile("${path.module}/user_data.sh", { image = var.image grafana_password = var.grafana_password }) user_data_replace_on_change = true root_block_device { volume_size = 16 } tags = { Name = var.name } } resource "aws_eip" "app" { instance = aws_instance.app.id tags = { Name = var.name } } - Write the boot script. It installs Docker, fetches the
deploy/folder from your repo, writes the environment file, and starts the stack. Because the script is a Terraform template, its${...}placeholders are filled by Terraform; shell variables use$$.bash#!/bin/bash set -euxo pipefail dnf install -y docker git systemctl enable --now docker mkdir -p /usr/local/lib/docker/cli-plugins curl -fsSL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-linux-x86_64" \ -o /usr/local/lib/docker/cli-plugins/docker-compose chmod +x /usr/local/lib/docker/cli-plugins/docker-compose rm -rf /opt/src && git clone --depth 1 https://github.com/YOUR_GITHUB_USER/zero-to-prod.git /opt/src mkdir -p /opt/app && cp -r /opt/src/deploy/. /opt/app/ cat > /opt/app/.env <<EOF APP_IMAGE=${image} GRAFANA_PASSWORD=${grafana_password} GREETING=hello from AWS EOF cd /opt/app && docker compose up -dSave asinfra/user_data.shand replaceYOUR_GITHUB_USER. Terraform substitutes${image}and${grafana_password}; there are no other${}expressions in the file, so nothing else needs escaping. - Add the outputs you will want after every apply.hcl
# infra/outputs.tf output "public_ip" { value = aws_eip.app.public_ip } output "urls" { value = { app = "http://${aws_eip.app.public_ip}/" grafana = "http://${aws_eip.app.public_ip}:3000/" prometheus = "http://${aws_eip.app.public_ip}:9090/" } } - Create a local
infra/terraform.tfvars(git-ignored) with your values, then initialise and apply from your laptop for this first run.bashcat > infra/terraform.tfvars <<EOF image = "ghcr.io/YOUR_GITHUB_USER/zero-to-prod:latest" grafana_password = "pick-something-long" my_ip = "$(curl -s https://checkip.amazonaws.com)/32" EOF cd infra terraform init terraform fmt terraform validate terraform plan terraform applyCheck:planshows about 6 resources to add. Afterapply(typeyes), the outputs print an IP. Give the instance three or four minutes to install Docker and pull images, then openhttp://IP/— the greeting says "hello from AWS". - Confirm the instance is manageable without SSH, then open Grafana on port 3000 with admin and the password you set.bash
aws ssm describe-instance-information --query 'InstanceInformationList[].{id:InstanceId,ping:PingStatus}' INSTANCE=$(aws ec2 describe-instances --filters Name=tag:Name,Values=zero-to-prod Name=instance-state-name,Values=running \ --query 'Reservations[].Instances[].InstanceId' --output text) aws ssm send-command --instance-ids "$INSTANCE" --document-name AWS-RunShellScript \ --parameters 'commands=["docker ps --format \"{{.Names}} {{.Status}}\""]' --query Command.CommandId --output textIfPingStatusis notOnlineyet, wait a minute; the SSM agent registers shortly after boot. Read the command's output in the console under Systems Manager → Run Command, or withaws ssm get-command-invocation.Check: Four containers are listed as Up. Grafana's dashboard shows traffic once you refresh the app page a few times. - Commit the infrastructure (never the tfvars or state) and merge.bash
cd .. git add infra git status # terraform.tfvars and .terraform/ must NOT appear git commit -m "infra: EC2 host with SSM access, S3 remote state, compose stack on boot" git push -u origin infra/ec2 gh pr create --fill && gh pr merge --squash --delete-branch git switch main && git pull
Let the pipeline own the infrastructure
GitHub Actions can plan and apply Terraform and deploy new images, authenticating to AWS with short-lived OIDC tokens instead of stored access keys.
- Register GitHub as an identity provider in your AWS account (once per account).bash
aws iam create-open-id-connect-provider \ --url https://token.actions.githubusercontent.com \ --client-id-list sts.amazonaws.comIf it already exists you getEntityAlreadyExists; that is fine. - Create a role that only workflows from your repository can assume. Replace the account ID and GitHub user in the trust policy.bash
ACCOUNT=$(aws sts get-caller-identity --query Account --output text) cat > trust.json <<EOF { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": {"Federated": "arn:aws:iam::$ACCOUNT:oidc-provider/token.actions.githubusercontent.com"}, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"}, "StringLike": {"token.actions.githubusercontent.com:sub": "repo:YOUR_GITHUB_USER/zero-to-prod:*"} } }] } EOF aws iam create-role --role-name github-zero-to-prod --assume-role-policy-document file://trust.json rm trust.json - Give the role what the pipeline needs and nothing more: the state bucket, the resources Terraform manages, and Run Command for deploys. For a learning project the two managed policies below are acceptable; in the Secrets module you learn to narrow them.bash
aws iam attach-role-policy --role-name github-zero-to-prod --policy-arn arn:aws:iam::aws:policy/AmazonEC2FullAccess aws iam attach-role-policy --role-name github-zero-to-prod --policy-arn arn:aws:iam::aws:policy/AmazonSSMFullAccess cat > inline.json <<EOF { "Version": "2012-10-17", "Statement": [ {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::tfstate-zero-to-prod-$ACCOUNT"}, {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::tfstate-zero-to-prod-$ACCOUNT/*"}, {"Effect": "Allow", "Action": ["iam:GetRole", "iam:PassRole", "iam:GetInstanceProfile", "iam:ListInstanceProfilesForRole", "iam:ListAttachedRolePolicies", "iam:ListRolePolicies"], "Resource": "*"}, {"Effect": "Allow", "Action": ["ssm:GetParameter", "ssm:GetParameters"], "Resource": "*"} ] } EOF aws iam put-role-policy --role-name github-zero-to-prod --policy-name state-and-deploy --policy-document file://inline.json rm inline.json echo "arn:aws:iam::$ACCOUNT:role/github-zero-to-prod"Copy the printed role ARN. Creating IAM roles from Terraform in CI would neediam:CreateRoletoo; the role already exists from your laptop apply, so the pipeline only needs to read it. - Store the non-secret configuration as repository variables and the secret as a repository secret.bash
gh variable set AWS_ROLE_ARN --body "arn:aws:iam::123456789012:role/github-zero-to-prod" gh variable set AWS_REGION --body "eu-west-1" gh variable set MY_IP --body "$(curl -s https://checkip.amazonaws.com)/32" gh secret set GRAFANA_PASSWORD --body "the-same-password-you-used" gh variable list && gh secret list - Branch and add the infrastructure workflow: plan on pull requests that touch
infra/, apply on merge tomain. The plan is posted as a PR comment so you review infrastructure changes like code.bashgit switch -c ci/infra - Create
.github/workflows/infra.yml.yamlname: infra on: pull_request: paths: ["infra/**"] push: branches: [main] paths: ["infra/**"] permissions: contents: read id-token: write pull-requests: write env: TF_VAR_image: ghcr.io/${{ github.repository }}:latest TF_VAR_grafana_password: ${{ secrets.GRAFANA_PASSWORD }} TF_VAR_my_ip: ${{ vars.MY_IP }} jobs: terraform: runs-on: ubuntu-latest defaults: run: working-directory: infra steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }} - uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.10.3" - run: terraform fmt -check - run: terraform init -input=false - run: terraform validate - id: plan run: terraform plan -no-color -input=false -out=tfplan - if: github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | const plan = `${{ steps.plan.outputs.stdout }}`.slice(0, 60000); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: '### Terraform plan\n```\n' + plan + '\n```' }); - if: github.ref == 'refs/heads/main' && github.event_name == 'push' run: terraform apply -input=false tfplan - Add the deploy job to
release.yml: after the image is pushed, tell the instance to pull and restart via Run Command. Append this job underjobs:.yamldeploy: needs: build runs-on: ubuntu-latest steps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }} - name: Roll the app to the new image run: | CMD_ID=$(aws ssm send-command \ --targets Key=tag:Name,Values=zero-to-prod \ --document-name AWS-RunShellScript \ --comment "deploy ${{ needs.build.outputs.tag }}" \ --parameters 'commands=["cd /opt/app","sed -i s#^APP_IMAGE=.*#APP_IMAGE=${{ env.IMAGE }}:${{ needs.build.outputs.tag }}# .env","docker compose pull app","docker compose up -d app","docker image prune -f"]' \ --query Command.CommandId --output text) echo "command $CMD_ID" sleep 20 aws ssm list-command-invocations --command-id "$CMD_ID" --details \ --query 'CommandInvocations[].{status:Status,out:CommandPlugins[0].Output}'The deploy pins the exactsha-…tag that was just built, so the running version is always traceable to a commit. Indent the block sodeploy:sits at the same level asbuild:. - Make a trivial change under
infra/(add a comment) so the PR exercises the plan path, then open it and read the plan comment on GitHub.bashprintf '\n# Managed by the infra workflow; plan on PR, apply on merge.\n' >> infra/main.tf git add .github infra git commit -m "ci: terraform plan/apply and SSM deploy with OIDC" git push -u origin ci/infra gh pr create --fill gh pr checks --watchCheck: Theterraformjob passes and a "Terraform plan" comment appears on the PR saying "No changes" (the comment only changed a comment). - Merge, then watch the apply and the deploy run. From now on, every merge ships.bash
gh pr merge --squash --delete-branch git switch main && git pull gh run list --limit 3 gh run watchCheck: Bothinfraandreleaseruns are green. Thedeployjob's output showsStatus: Success. - Prove the whole loop with a real change: edit the greeting, merge it, and watch it reach production with no manual step.bash
git switch -c feat/greeting sed -i.bak 's/hello from zero-to-prod/hello from the pipeline/' app/main.py && rm app/main.py.bak git commit -am "feat: new default greeting" git push -u origin feat/greeting gh pr create --fill && gh pr checks --watch && gh pr merge --squash --delete-branch git switch main && git pull && gh run watchThe live greeting is still "hello from AWS" because the server setsGREETINGexplicitly in.env— twelve-factor in action. Check/docson the server: the OpenAPI page reflects the new code, anddocker psvia Run Command shows the newsha-…tag.Check: On GitHub → Actions, the release run for your merge shows the deploy job succeeded with the new tag.
Observe it, break it, fix it
Use the dashboards and alert rules against the live service, cause a real failure, and see it show up — the habit that turns dashboards into something you actually look at.
- Generate steady traffic against the live service from your laptop and watch the request-rate and p95 panels move in Grafana (port 3000 on the instance IP).bash
IP=$(cd infra && terraform output -raw public_ip) while true; do curl -s "http://$IP/" > /dev/null; sleep 0.2; doneStop it with Ctrl+C when you are done.terraform outputworks locally because your laptop shares the remote state. - Cause errors: request a path that does not exist a few hundred times. These are 404s, not 5xx, so the error panel should stay green — a useful reminder that not every non-200 is an error worth paging on.bash
for i in $(seq 1 300); do curl -s -o /dev/null "http://$IP/missing"; doneCheck: In Prometheus (port 9090) runsum by (status) (rate(http_requests_total[1m])); a404series appears, the 5xx ratio stays 0. - Now cause a real outage: stop the app container through Run Command and watch
AppDowngo from inactive to pending to firing in Prometheus → Alerts.bashaws ssm send-command --targets Key=tag:Name,Values=zero-to-prod --document-name AWS-RunShellScript \ --parameters 'commands=["cd /opt/app && docker compose stop app"]' --query Command.CommandId --output textCheck: Within about 90 seconds theAppDownalert is firing (red) and the Grafana request panel drops to nothing. - Restore service the way the pipeline would, and confirm the alert resolves.bash
aws ssm send-command --targets Key=tag:Name,Values=zero-to-prod --document-name AWS-RunShellScript \ --parameters 'commands=["cd /opt/app && docker compose up -d app"]' --query Command.CommandId --output textCheck:AppDownreturns to inactive; traffic resumes on the dashboard. - Write down what you saw in
README.mdunder a "Runbook" heading: how to check the app is up, where the dashboards are, how to roll back (re-run the deploy job with an oldersha-tag, ordocker compose up -dwith an olderAPP_IMAGE). Commit it through a PR. This is the document your future self opens at 2 a.m. - When you are finished experimenting, tear the infrastructure down so it stops costing money. The state bucket and IAM role can stay (they are free) for the next time you rebuild with one
terraform apply.bashcd infra terraform destroyCheck:aws ec2 describe-instances --filters Name=tag:Name,Values=zero-to-prod --query 'Reservations[].Instances[].State.Name'showsterminated. The EC2 console shows no running instances and no Elastic IPs.
Troubleshooting
gh repo createsays the name already exists- You created it earlier. Run
gh repo clone zero-to-prodinstead, or pick another name and use it consistently in every later command. docker: permission deniedon Ubuntu- You added yourself to the docker group but have not logged out and in. Run
newgrp dockerfor the current shell or log out and back in. - The
releaseworkflow fails atdocker/login-actionwith 403 - The job needs
packages: writepermission (it is in the file) and the repository must allow Actions to write packages: Settings → Actions → General → Workflow permissions → Read and write. - The server pulls the image but the app container exits immediately
- Through Run Command, execute
cd /opt/app && docker compose logs app --tail 50. A common cause is a private package: make the GHCR package public (Phase 5, last step). terraform initfails withAccessDeniedon the S3 bucket- The bucket name in
backend.tfmust match the one you created, and your CLI must be in the same account. Runaws s3 lsand compare. - Terraform reports the AMI parameter is not found
- That SSM parameter exists in every region; the usual cause is the CLI region and
var.regiondisagreeing. Set both to the same region. - Grafana on port 3000 does not load but the app on port 80 does
- Ports 3000 and 9090 are only open to
my_ip. Your public IP changed (different network, VPN). Updatemy_ipinterraform.tfvarsor theMY_IPrepo variable and apply again. configure-aws-credentialsfails withNot authorized to perform sts:AssumeRoleWithWebIdentity- The trust policy's
subcondition must matchrepo:YOUR_USER/zero-to-prod:*exactly, including case, and the OIDC provider must exist in the account. Print the role's trust policy withaws iam get-role --role-name github-zero-to-prod. - The plan comment on the PR is empty
hashicorp/setup-terraformwraps the binary so that step outputs capture stdout; make sure the plan step hasid: planand you did not disable the wrapper withterraform_wrapper: false.- The
deployjob succeeds but the running version does not change - Read the Run Command output printed by the job. If
seddid not update.env, check thatAPP_IMAGE=is at the start of a line in/opt/app/.env; thendocker compose up -d appis a no-op because the image reference did not change.
Where to go from here
- Swap the single EC2 host for a managed platform: the Terraform an AWS environment project puts the same container behind a load balancer on ECS with a real VPC.
- Add a staging environment: a second Terraform workspace and a
releasejob that deploys to staging first, then to production after a manual approval (GitHub Environments). - Do the SRE track's SLOs from scratch project against this service: turn the error and latency panels into SLIs, an SLO, and burn-rate alerts that page.
- Harden it with the Cybersecurity track: scan the image in CI, sign it with Sigstore, and restrict the IAM role to exactly the actions Terraform needs.
- Replace
latestin the Terraformimagevariable with a pinnedsha-tag so a rebuilt server always starts the version that was last deployed.
Did a step fail or feel unclear? Tell me which one →