Education › DevOps Engineering › Guided project

Zero to production: API with CI/CD, Docker, Terraform and dashboards

Start from an empty GitHub repository and finish with a small Python API that is tested on every pull request, built into a container image on every merge, deployed by a pipeline onto AWS infrastructure you defined in Terraform, and watched by Prometheus and Grafana.

Beginner to intermediate about 8 hours 8 phases · 62 steps 0 / 62 done
What you will have at the end

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.

Before you start
  • 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
Tools you will install
  • 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 ↗
Repository layout at the end
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.md

Tick 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.

Phase 1

Install the tools

Get every tool installed and verified before writing a line of code, so later phases never stall on setup.

  1. 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 awscli
    Windows: open PowerShell and run winget 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).
  2. 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.
  3. 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-actions
    If code is not found on macOS, open VS Code, press Cmd+Shift+P and run "Shell Command: Install 'code' command in PATH".
  4. 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 --version
    Check: Six version lines, no "command not found". Terraform must be 1.10 or newer for the S3 lockfile backend used later.
  5. Log the GitHub CLI into your account. Choose HTTPS and let it authenticate git for you when asked.
    bash
    gh auth login
    gh auth status
    Check: gh auth status shows "Logged in to github.com".
  6. 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.
    bash
    aws 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-identity
    This 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-identity prints your account ID and user ARN.
Phase 2

Create the repository

A public repository with sensible defaults, opened in your editor, with a first commit on main and the branch protected.

  1. 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
  2. Add a .gitignore for Python, Terraform and editor files. Terraform state and .env files 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
  3. 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
  4. 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 main
    Check: The repo page on GitHub shows the README.
  5. Protect main so nothing lands without a pull request and green checks. This is what makes the pipeline meaningful.
    bash
    gh 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.
Phase 3

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.

  1. Create a feature branch. From here on, all work happens on branches and reaches main through pull requests.
    bash
    git switch -c feat/api
  2. 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.txt
    Windows PowerShell activates with .venv\Scripts\Activate.ps1. VS Code will offer to use this interpreter; accept.
  3. Write the application. It exposes / (a greeting that reads its message from an environment variable, twelve-factor style), /health for probes, and /metrics for 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"}
    Create app/__init__.py as an empty file so app is a package: touch app/__init__.py.
  4. Run it locally and open the interactive docs. Try /health and /metrics in the browser.
    bash
    uvicorn app.main:app --reload
    # in another terminal:
    curl -s localhost:8000/health
    curl -s localhost:8000/metrics | head -5
    Check: /health returns {"status":"ok"} and /metrics prints Prometheus text starting with # HELP.
  5. 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.text
    The metrics test calls /metrics after other requests, so the counter exists. Prometheus metric names come from the instrumentator's defaults.
  6. 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: Run ruff check . && ruff format --check . && pytest -q — three passing tests, no lint errors.
  7. 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
    --squash keeps main history to one commit per PR. --delete-branch cleans up the remote and local branch.
Phase 4

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.

  1. 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
  2. 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"]
  3. Add a .dockerignore so 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
  4. 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-prod shows an image around 150 MB — slim base, no build tools.
  5. 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:
  6. 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"]
  7. 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"
  8. 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
  9. 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
  10. 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 as deploy/grafana/dashboards/zero-to-prod.json.
  11. 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).
  12. 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
Phase 5

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.

  1. 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.
    bash
    git switch -c ci/pipeline
    mkdir -p .github/workflows
  2. Create .github/workflows/ci.yml.
    yaml
    name: 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
  3. Create the release workflow. On every push to main it builds the image once, tags it with the commit SHA and latest, and pushes to GHCR using the job's own token — no secrets to manage.
    yaml
    name: 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=max
    The deploy job is added to this file in Phase 7, once there is a server to deploy to. id-token: write is already declared for it.
  4. Open the pull request and watch the ci workflow run on it. Fix anything red before merging.
    bash
    git 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 --watch
    Check: gh pr checks ends with the test job passing.
  5. Now that the check exists, make it required on main, then merge.
    bash
    gh 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 watch
    Check: The release run finishes green. On your GitHub profile → Packages, zero-to-prod appears with tags latest and sha-….
  6. 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:latest
    Check: The pull succeeds while logged out.
Phase 6

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.

  1. 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"
    In us-east-1 omit the --create-bucket-configuration flag; that region rejects it. Keep the printed bucket name; you need it in the next step.
  2. 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
  3. Create infra/backend.tf with your bucket name.
    hcl
    terraform {
      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
    }
  4. 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
    }
  5. 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 }
    }
  6. 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 -d
    Save as infra/user_data.sh and replace YOUR_GITHUB_USER. Terraform substitutes ${image} and ${grafana_password}; there are no other ${} expressions in the file, so nothing else needs escaping.
  7. 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/"
      }
    }
  8. Create a local infra/terraform.tfvars (git-ignored) with your values, then initialise and apply from your laptop for this first run.
    bash
    cat > 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 apply
    Check: plan shows about 6 resources to add. After apply (type yes), the outputs print an IP. Give the instance three or four minutes to install Docker and pull images, then open http://IP/ — the greeting says "hello from AWS".
  9. 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 text
    If PingStatus is not Online yet, wait a minute; the SSM agent registers shortly after boot. Read the command's output in the console under Systems Manager → Run Command, or with aws 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.
  10. 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
Phase 7

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.

  1. 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.com
    If it already exists you get EntityAlreadyExists; that is fine.
  2. 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
  3. 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 need iam:CreateRole too; the role already exists from your laptop apply, so the pipeline only needs to read it.
  4. 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
  5. Branch and add the infrastructure workflow: plan on pull requests that touch infra/, apply on merge to main. The plan is posted as a PR comment so you review infrastructure changes like code.
    bash
    git switch -c ci/infra
  6. Create .github/workflows/infra.yml.
    yaml
    name: 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
  7. 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 under jobs:.
    yaml
      deploy:
        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 exact sha-… tag that was just built, so the running version is always traceable to a commit. Indent the block so deploy: sits at the same level as build:.
  8. 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.
    bash
    printf '\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 --watch
    Check: The terraform job passes and a "Terraform plan" comment appears on the PR saying "No changes" (the comment only changed a comment).
  9. 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 watch
    Check: Both infra and release runs are green. The deploy job's output shows Status: Success.
  10. 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 watch
    The live greeting is still "hello from AWS" because the server sets GREETING explicitly in .env — twelve-factor in action. Check /docs on the server: the OpenAPI page reflects the new code, and docker ps via Run Command shows the new sha-… tag.
    Check: On GitHub → Actions, the release run for your merge shows the deploy job succeeded with the new tag.
Phase 8

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.

  1. 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; done
    Stop it with Ctrl+C when you are done. terraform output works locally because your laptop shares the remote state.
  2. 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"; done
    Check: In Prometheus (port 9090) run sum by (status) (rate(http_requests_total[1m])); a 404 series appears, the 5xx ratio stays 0.
  3. Now cause a real outage: stop the app container through Run Command and watch AppDown go from inactive to pending to firing in Prometheus → Alerts.
    bash
    aws 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 text
    Check: Within about 90 seconds the AppDown alert is firing (red) and the Grafana request panel drops to nothing.
  4. 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 text
    Check: AppDown returns to inactive; traffic resumes on the dashboard.
  5. Write down what you saw in README.md under 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 older sha- tag, or docker compose up -d with an older APP_IMAGE). Commit it through a PR. This is the document your future self opens at 2 a.m.
  6. 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.
    bash
    cd infra
    terraform destroy
    Check: aws ec2 describe-instances --filters Name=tag:Name,Values=zero-to-prod --query 'Reservations[].Instances[].State.Name' shows terminated. The EC2 console shows no running instances and no Elastic IPs.
Help

Troubleshooting

gh repo create says the name already exists
You created it earlier. Run gh repo clone zero-to-prod instead, or pick another name and use it consistently in every later command.
docker: permission denied on Ubuntu
You added yourself to the docker group but have not logged out and in. Run newgrp docker for the current shell or log out and back in.
The release workflow fails at docker/login-action with 403
The job needs packages: write permission (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 init fails with AccessDenied on the S3 bucket
The bucket name in backend.tf must match the one you created, and your CLI must be in the same account. Run aws s3 ls and 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.region disagreeing. 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). Update my_ip in terraform.tfvars or the MY_IP repo variable and apply again.
configure-aws-credentials fails with Not authorized to perform sts:AssumeRoleWithWebIdentity
The trust policy's sub condition must match repo:YOUR_USER/zero-to-prod:* exactly, including case, and the OIDC provider must exist in the account. Print the role's trust policy with aws iam get-role --role-name github-zero-to-prod.
The plan comment on the PR is empty
hashicorp/setup-terraform wraps the binary so that step outputs capture stdout; make sure the plan step has id: plan and you did not disable the wrapper with terraform_wrapper: false.
The deploy job succeeds but the running version does not change
Read the Run Command output printed by the job. If sed did not update .env, check that APP_IMAGE= is at the start of a line in /opt/app/.env; then docker compose up -d app is a no-op because the image reference did not change.
Next

Where to go from here

Did a step fail or feel unclear? Tell me which one →