Laptop to Production

Project 5: The pipeline

A commit reaches production with nobody touching the console.

Time
8 to 10 hours, over two or three sittings
What you buildA Jenkins pipeline: lint, test, scan and a Terraform plan on every pull request; build, push, deploy, smoke test, approval, canary with abort criteria and one-click rollback; two operations jobs; and a runbook.
What it provesA commit reaches production with nobody touching the console.

QuickCart releases twice a week, and every release is Sam’s afternoon. He builds the image on his laptop, pushes it, clicks through the ECS console, refreshes the site, and hopes. When Sam is on holiday, nobody releases. Last month he pushed an image built from a branch he’d forgotten to update, and production ran week-old code for two days before anyone noticed.

In this project you take Sam’s afternoon away. You build a Jenkins pipeline that checks every pull request, and releases every merge: to dev first, then to production as a canary that rolls itself back if it misbehaves. You add two operations jobs that replace the riskiest console tasks, safe SQL and log extraction, and you write the runbook that tells the next person how to use all of it.

What you’ll have at the end

Event
Canary
  1. Versionshort commit IDwaiting
  2. Linthadolint, shellcheck, fmtwaiting
  3. Testpytest and ruffwaiting
  4. Build imagetagged with the versionwaiting
  5. Scanno critical findingswaiting
  6. Terraform plandev and prod, savedwaiting
  7. Push to ECRonce per versionskipped
  8. Deploy to devrolling releaseskipped
  9. Smoke test/health says the versionskipped
  10. Approvala person says goskipped
  11. Canary 10%bake, then checkskipped
  12. Canary 50%bake, then checkskipped
  13. Promotestable gets the versionskipped
Pull requestA pull request runs the checks and a Terraform plan, and stops. Nothing is pushed or deployed. Choose Run the pipeline.

What to notice: a pull request and a merge run the same checks, but only a merge can release. Set the canary to Raises errors and merge: the pipeline stops itself at 10% and puts everything back, and nobody touched anything.

PieceWhat it does
Jenkins controlleran EC2 instance with no open ports. You reach its web page through Session Manager. It has no AWS permissions at all
Jenkins agenta second EC2 instance that runs every build, with Docker and Terraform. Its IAM role is the only AWS identity the pipeline has: no access keys anywhere
The Jenkinsfilethe pipeline, in the repository, next to the code it releases
scripts/the release, canary, rollback, SQL and log scripts the pipeline calls. You can read and test each one on its own
Three extra jobsone-click rollback, safe SQL in a transaction, and log extraction for a time window
The runbookwhat to do, and what not to do, for each operation

The repository ends up like this:

quickcart/
quickcart/
├── Jenkinsfile             the release pipeline
├── app/                    the order service: app.py, requirements.txt,
│                           requirements-dev.txt, test_app.py, Dockerfile
├── ops/                    the SQL tool: run_sql.py and its Dockerfile
├── scripts/                stack-outputs.sh, deploy.sh, smoke.sh, weights.sh,
│                           canary.sh, rollback.sh, run-sql-task.sh, extract-logs.sh
├── jenkins/                rollback, ops-sql and ops-logs .Jenkinsfile
├── infra/                  Project 4's quickcart-infra, unchanged
└── ci/                     Terraform for Jenkins itself

Before you start

ReadYou’ll use it for
Jenkins from zerocontrollers, agents, plugins, credentials and backups
The Jenkinsfileevery directive in the pipeline
Continuous integrationthe pull request checks
Continuous deliveryapproval, smoke tests and rollback
Canary from a pipelinethe abort criteria
Running SQL safelywhy the SQL job works the way it does
Runbooksthe last stage
Project 4the environments this pipeline releases to
ToolFor
Terraform, the AWS CLI and gitas in Project 4
The Session Manager plugin for the AWS CLIopening Jenkins without an open port. Jenkins from zero shows how to install it on macOS, Linux and Windows (WSL)
A GitHub accounta private repository for the code

Cost

WhatPrice in us-east-1 (prices change; check the pricing pages)Per day
Jenkins controller and agent, 2 t3.mediumabout $0.042 an hour eachabout $2.00
Their disks, 30 and 50 GB gp3, and daily snapshotsabout $0.08 per GB a monthpennies
Their public IPv4 addresses$0.005 an hour eachabout $0.24
Project 4’s dev environment, with EKS offsee Project 4about $3
Project 4’s prod environmentsee Project 4about $7
Canary test traffic, logs, metrics, ECR storagea few centspennies

Prepare the environments

  1. Turn EKS off in dev

    In infra/envs/dev/terraform.tfvars, set enable_eks = false. If dev is still running with EKS on from Project 4, terraform destroy it first, because switching EKS off in place fails (see Project 4’s troubleshooting).

    The pipeline plans dev on every pull request. With EKS on, the plan would need its own access to the Kubernetes cluster, for no benefit.

  2. Build dev and prod

    From infra/envs/dev, then from infra/envs/prod, run Project 4’s commands:

    Terminal
    terraform init
    PartWhat it means
    terraform initconnect to that environment’s state and download the providers
    Terminal
    terraform apply
    PartWhat it means
    terraform applybuild the environment once you type yes
    You should see

    Apply complete! in each. terraform plan -detailed-exitcode then exits with 0 in each.

You should see

Checkpoint: curl -k on each environment’s ecs_url with /health answers {"status":"ok","version":"v1"}.

Build Jenkins with Terraform

Jenkins is infrastructure too, so it’s built from code, in the ci/ folder, with its own state. Every file below goes in ci/.

  1. Where it lives and what it runs on

    main.tf
    terraform {
      required_version = ">= 1.10.0"
    
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 6.0"
        }
        tls = {
          source  = "hashicorp/tls"
          version = "~> 4.0"
        }
      }
    
      backend "s3" {
        bucket       = "quickcart-tfstate-111122223333"
        key          = "ci/terraform.tfstate"
        region       = "us-east-1"
        encrypt      = true
        use_lockfile = true
      }
    }
    
    provider "aws" {
      region = "us-east-1"
    
      default_tags {
        tags = {
          Project   = "quickcart"
          ManagedBy = "terraform"
        }
      }
    }
    
    data "aws_caller_identity" "current" {}
    
    data "aws_vpc" "default" {
      default = true
    }
    
    data "aws_subnet" "ci" {
      vpc_id            = data.aws_vpc.default.id
      availability_zone = "us-east-1a"
      default_for_az    = true
    }
    
    data "aws_ssm_parameter" "al2023" {
      name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
    }
    
    locals {
      account_id = data.aws_caller_identity.current.account_id
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    backend "s3" → key = "ci/terraform.tfstate"Jenkins gets its own state, separate from every environment. The other settings are as in Terraform state——
    data "aws_vpc" → default = trueJenkins runs in the account’s default VPC, away from the environments it deploys to—a dedicated tooling VPC in a real company
    data "aws_subnet" → availability_zone, default_for_azthe default subnet in us-east-1a——
    data "aws_ssm_parameter" → namethe newest Amazon Linux 2023 image ID, published by AWS in Parameter Store. No image ID is typed into the codean image ID that is different in every region and goes stale—
    local.account_idyour account number, read, not typed——
  2. Firewalls with no way in

    network.tf
    resource "aws_security_group" "controller" {
      name        = "quickcart-jenkins-controller"
      description = "Jenkins controller: no inbound at all; reached through Session Manager"
      vpc_id      = data.aws_vpc.default.id
    }
    
    resource "aws_security_group" "agent" {
      name        = "quickcart-jenkins-agent"
      description = "Jenkins agent: SSH from the controller only"
      vpc_id      = data.aws_vpc.default.id
    }
    
    resource "aws_vpc_security_group_egress_rule" "controller_out" {
      security_group_id = aws_security_group.controller.id
      description       = "Plugins, GitHub, and SSH to the agent"
      cidr_ipv4         = "0.0.0.0/0"
      ip_protocol       = "-1"
    }
    
    resource "aws_vpc_security_group_egress_rule" "agent_out" {
      security_group_id = aws_security_group.agent.id
      description       = "GitHub, ECR, AWS APIs and package downloads"
      cidr_ipv4         = "0.0.0.0/0"
      ip_protocol       = "-1"
    }
    
    resource "aws_vpc_security_group_ingress_rule" "agent_ssh" {
      security_group_id            = aws_security_group.agent.id
      description                  = "SSH from the Jenkins controller only"
      referenced_security_group_id = aws_security_group.controller.id
      ip_protocol                  = "tcp"
      from_port                    = 22
      to_port                      = 22
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    aws_security_group.controller with no ingress rulenothing on the internet can even try to reach Jenkins. You’ll open its page through Session Manager instead—never: a public Jenkins is a favourite target
    controller_out, agent_out egress to anywhereboth can download plugins and packages, reach GitHub and call AWSa Terraform-made group has no outbound rules, and nothing works—
    agent_ssh → referenced_security_group_idonly the controller may SSH to the agent. That’s how the controller starts builds on itthe controller can’t reach the agent—
  3. Roles instead of keys

    iam.tf
    data "aws_iam_policy_document" "ec2_assume" {
      statement {
        actions = ["sts:AssumeRole"]
    
        principals {
          type        = "Service"
          identifiers = ["ec2.amazonaws.com"]
        }
      }
    }
    
    resource "aws_iam_role" "controller" {
      name               = "quickcart-jenkins-controller"
      assume_role_policy = data.aws_iam_policy_document.ec2_assume.json
    }
    
    resource "aws_iam_role_policy_attachment" "controller_ssm" {
      role       = aws_iam_role.controller.name
      policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
    }
    
    resource "aws_iam_instance_profile" "controller" {
      name = "quickcart-jenkins-controller"
      role = aws_iam_role.controller.name
    }
    
    resource "aws_iam_role" "agent" {
      name               = "quickcart-jenkins-agent"
      assume_role_policy = data.aws_iam_policy_document.ec2_assume.json
    }
    
    resource "aws_iam_role_policy_attachment" "agent" {
      for_each = toset([
        "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
        "arn:aws:iam::aws:policy/ReadOnlyAccess",
      ])
    
      role       = aws_iam_role.agent.name
      policy_arn = each.value
    }
    
    data "aws_iam_policy_document" "agent_deploy" {
      statement {
        sid       = "EcrLogin"
        actions   = ["ecr:GetAuthorizationToken"]
        resources = ["*"]
      }
    
      statement {
        sid = "EcrPush"
        actions = [
          "ecr:BatchCheckLayerAvailability",
          "ecr:InitiateLayerUpload",
          "ecr:UploadLayerPart",
          "ecr:CompleteLayerUpload",
          "ecr:PutImage",
        ]
        resources = ["arn:aws:ecr:us-east-1:${local.account_id}:repository/quickcart-*"]
      }
    
      statement {
        sid = "EcsRelease"
        actions = [
          "ecs:RegisterTaskDefinition",
          "ecs:UpdateService",
          "ecs:RunTask",
        ]
        resources = ["*"]
      }
    
      statement {
        sid       = "PassOnlyExecutionRoles"
        actions   = ["iam:PassRole"]
        resources = ["arn:aws:iam::${local.account_id}:role/quickcart-*-ecs-execution"]
    
        condition {
          test     = "StringEquals"
          variable = "iam:PassedToService"
          values   = ["ecs-tasks.amazonaws.com"]
        }
      }
    
      statement {
        sid       = "CanaryWeights"
        actions   = ["elasticloadbalancing:ModifyListener"]
        resources = ["arn:aws:elasticloadbalancing:us-east-1:${local.account_id}:listener/app/quickcart-*"]
      }
    
      statement {
        sid       = "TerraformPlanLock"
        actions   = ["s3:PutObject", "s3:DeleteObject"]
        resources = ["arn:aws:s3:::quickcart-tfstate-${local.account_id}/*.tflock"]
      }
    }
    
    resource "aws_iam_role_policy" "agent_deploy" {
      name   = "deploy"
      role   = aws_iam_role.agent.id
      policy = data.aws_iam_policy_document.agent_deploy.json
    }
    
    resource "aws_iam_instance_profile" "agent" {
      name = "quickcart-jenkins-agent"
      role = aws_iam_role.agent.name
    }

    This is the most important file in the project. There is no AWS access key anywhere. The agent’s instance profile gives builds short-lived credentials that rotate by themselves, the same way ECS tasks get theirs. Who can do what: IAM explains roles.

    SettingWhat it doesIf you leave it outWhen you’d change it
    aws_iam_role.controller with only AmazonSSMManagedInstanceCorethe controller can talk to Session Manager, and nothing else. If Jenkins itself is ever broken into, the attacker gets no AWS access—never add deploy rights here
    aws_iam_instance_profilethe wrapper that lets an EC2 instance use a rolethe instance can’t use the role—
    aws_iam_role.agent → ReadOnlyAccessbuilds can read everything: what terraform plan needsplans fail on the first resource they readscope it down to the services in your plan once they’re stable
    EcrLogin, EcrPushlog in to ECR and push, only to quickcart-* repositoriespushes are refused—
    EcsReleaseregister task definitions, update services and run one-off tasksreleases are refusedlimit resources to the clusters’ services
    PassOnlyExecutionRoles → iam:PassRole with the iam:PassedToService conditiona task definition may name the environments’ execution roles, and only for ECS tasks. Without this limit, PassRole is a classic way to gain more power than you were givenregistering a task definition is refused—
    CanaryWeights → ModifyListenerchange the listener weights, only on QuickCart’s load balancersthe canary can’t shift traffic—
    TerraformPlanLockwrite and delete the .tflock file next to state, so plans take the lockplans fail to lock. The agent cannot write the state itself: it can plan, never apply—
  4. The two instances

    instances.tf
    resource "tls_private_key" "agent_ssh" {
      algorithm = "ED25519"
    }
    
    resource "aws_ssm_parameter" "agent_ssh_key" {
      name        = "/quickcart/jenkins/agent-ssh-key"
      description = "The private key the controller uses to reach the agent"
      type        = "SecureString"
      value       = tls_private_key.agent_ssh.private_key_openssh
    }
    
    resource "aws_instance" "controller" {
      ami                    = data.aws_ssm_parameter.al2023.value
      instance_type          = "t3.medium"
      subnet_id              = data.aws_subnet.ci.id
      vpc_security_group_ids = [aws_security_group.controller.id]
      iam_instance_profile   = aws_iam_instance_profile.controller.name
    
      metadata_options {
        http_tokens = "required"
      }
    
      root_block_device {
        volume_type = "gp3"
        volume_size = 30
        encrypted   = true
      }
    
      volume_tags = {
        Name   = "quickcart-jenkins-controller"
        Backup = "jenkins-home"
      }
    
      user_data = <<-EOT
        #!/bin/bash
        set -euxo pipefail
        dnf install -y java-21-amazon-corretto-headless fontconfig git
        curl -fsSL -o /etc/yum.repos.d/jenkins.repo https://pkg.jenkins.io/rpm-stable/jenkins.repo
        dnf install -y jenkins
        systemctl enable --now jenkins
      EOT
    
      tags = { Name = "quickcart-jenkins-controller" }
    
      lifecycle {
        ignore_changes = [ami, user_data]
      }
    }
    
    resource "aws_instance" "agent" {
      ami                    = data.aws_ssm_parameter.al2023.value
      instance_type          = "t3.medium"
      subnet_id              = data.aws_subnet.ci.id
      vpc_security_group_ids = [aws_security_group.agent.id]
      iam_instance_profile   = aws_iam_instance_profile.agent.name
    
      metadata_options {
        http_tokens                 = "required"
        http_put_response_hop_limit = 2
      }
    
      root_block_device {
        volume_type = "gp3"
        volume_size = 50
        encrypted   = true
      }
    
      user_data = <<-EOT
        #!/bin/bash
        set -euxo pipefail
        dnf install -y java-21-amazon-corretto-headless docker git jq dnf-plugins-core
        dnf config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo
        dnf install -y terraform
        systemctl enable --now docker
        useradd --create-home jenkins
        usermod -aG docker jenkins
        install -d -m 700 -o jenkins -g jenkins /home/jenkins/.ssh
        echo "${tls_private_key.agent_ssh.public_key_openssh}" > /home/jenkins/.ssh/authorized_keys
        chown jenkins:jenkins /home/jenkins/.ssh/authorized_keys
        chmod 600 /home/jenkins/.ssh/authorized_keys
      EOT
    
      tags = { Name = "quickcart-jenkins-agent" }
    
      lifecycle {
        ignore_changes = [ami, user_data]
      }
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    tls_private_key → algorithm = "ED25519"an SSH key pair for controller-to-agent connections——
    aws_ssm_parameter → type = "SecureString"stores the private key encrypted in Parameter Store, where only admins can read it——
    ami, instance_type, subnet_id, vpc_security_group_ids, iam_instance_profilewhat to run, how big, where, which firewall, which rolerequired, or the wrong defaultsa bigger agent for bigger builds
    metadata_options → http_tokens = "required"only IMDSv2, which stops a class of credential-stealing attacksIMDSv1 is allowednever
    http_put_response_hop_limit = 2 (agent)lets containers the agent runs reach the instance’s credentials too. Trivy doesn’t need them, but docker build steps sometimes docalls from inside containers time out—
    root_block_device → volume_type, volume_size, encryptedan encrypted disk, big enough for Jenkins’ data and Docker’s images8 GB, which fills up within days—
    volume_tags → Backup = "jenkins-home"marks the controller’s disk for the backup policynothing is backed up—
    user_dataa script the instance runs once, at first boot: installs Java 21 and Jenkins from the official repository on the controller; Docker, Terraform, git, jq and the jenkins user on the agentyou’d install everything by hand—
    ${tls_private_key.agent_ssh.public_key_openssh}Terraform writes the public key into the agent’s authorized_keysthe controller can’t log in—
    lifecycle → ignore_changes = [ami, user_data]a newer Amazon Linux image, published weekly, doesn’t replace Jenkins and lose its datathe next plan after an AMI release replaces both instances: never cleanupgrade on purpose, with a backup
  5. Backups of JENKINS_HOME

    Everything Jenkins knows (jobs, build history, credentials, plugins) lives in JENKINS_HOME, /var/lib/jenkins on the controller’s disk. Lose the disk and you lose all of it.

    backup.tf
    data "aws_iam_policy_document" "dlm_assume" {
      statement {
        actions = ["sts:AssumeRole"]
    
        principals {
          type        = "Service"
          identifiers = ["dlm.amazonaws.com"]
        }
      }
    }
    
    resource "aws_iam_role" "dlm" {
      name               = "quickcart-jenkins-backup"
      assume_role_policy = data.aws_iam_policy_document.dlm_assume.json
    }
    
    resource "aws_iam_role_policy_attachment" "dlm" {
      role       = aws_iam_role.dlm.name
      policy_arn = "arn:aws:iam::aws:policy/service-role/AWSDataLifecycleManagerServiceRole"
    }
    
    resource "aws_dlm_lifecycle_policy" "jenkins_home" {
      description        = "Daily snapshot of the Jenkins controller disk kept for 7 days"
      execution_role_arn = aws_iam_role.dlm.arn
      state              = "ENABLED"
    
      policy_details {
        resource_types = ["VOLUME"]
        target_tags    = { Backup = "jenkins-home" }
    
        schedule {
          name      = "daily"
          copy_tags = true
    
          create_rule {
            interval      = 24
            interval_unit = "HOURS"
            times         = ["03:00"]
          }
    
          retain_rule {
            count = 7
          }
        }
      }
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    aws_iam_role.dlm with AWS’s managed policylets Data Lifecycle Manager take and delete snapshotsthe policy can’t run—
    aws_dlm_lifecycle_policy → description, execution_role_arn, statea snapshot policy, switched on——
    resource_types, target_tagsacts on every volume tagged Backup = jenkins-home——
    schedule → create_ruleone snapshot every 24 hours, at 03:00 UTC—more often if builds change a lot
    retain_rule → count = 7keeps the last seven, deleting older onessnapshots pile up and cost money—
    copy_tagssnapshots carry the volume’s tags, so you can find themuntagged snapshots—
    outputs.tf
    output "controller_id" {
      description = "Use it to open Jenkins through Session Manager"
      value       = aws_instance.controller.id
    }
    
    output "agent_private_ip" {
      description = "The address the controller uses to reach the agent"
      value       = aws_instance.agent.private_ip
    }
    
    output "agent_role_arn" {
      description = "The role builds run with. Add it to eks_admin_role_arns if an environment runs EKS"
      value       = aws_iam_role.agent.arn
    }
  6. Apply it

    Terminal
    cd ci
    PartWhat it means
    cd ciinto the Jenkins folder
    Terminal
    terraform init
    PartWhat it means
    terraform initconnect to ci/terraform.tfstate and download the AWS and TLS providers
    Terminal
    terraform apply
    PartWhat it means
    terraform applybuild Jenkins once you type yes
    You should see

    Apply complete! and three outputs: controller_id, like i-0example12345678, agent_private_ip and agent_role_arn. Give the instances five minutes to finish installing.

You should see

Checkpoint: EC2 > Instances shows quickcart-jenkins-controller and quickcart-jenkins-agent running, and Systems Manager > Fleet Manager lists both as online.

Set Jenkins up

This is the only part done by hand, once, in Jenkins’ own web page. Nothing here touches the AWS console.

  1. Open Jenkins through Session Manager

    Terminal
    aws ssm start-session --target CONTROLLER_ID --document-name AWS-StartPortForwardingSession --parameters portNumber=8080,localPortNumber=8080
    PartWhat it means
    aws ssm start-sessionopen a Session Manager connection to an instance
    --target CONTROLLER_IDthe controller. Use the controller_id output
    --document-name AWS-StartPortForwardingSessionforward a port, instead of opening a shell
    --parameters portNumber=8080,localPortNumber=8080Jenkins’ port on the instance appears as port 8080 on your computer
    You should see
    Starting session with SessionId: you-0a1b2c3d4e5f67890
    Port 8080 opened for sessionId you-0a1b2c3d4e5f67890.
    Waiting for connections...

    Leave it running and open http://localhost:8080 in your browser. The traffic travels inside the encrypted Session Manager connection.

  2. Unlock it

    Jenkins asks for an initial password stored on the controller. In a second terminal, open a shell on it:

    Terminal
    aws ssm start-session --target CONTROLLER_ID
    PartWhat it means
    aws ssm start-session --targeta shell on the instance, with no SSH and no open port

    Then, in that shell:

    Terminal
    sudo cat /var/lib/jenkins/secrets/initialAdminPassword
    PartWhat it means
    sudo catprint a file only root can read
    /var/lib/jenkins/secrets/initialAdminPasswordthe one-time password, inside JENKINS_HOME

    Paste it into the page. Choose Install suggested plugins, then create the first admin user with the username admin. The pipeline’s approval steps name that user.

  3. Add the plugins the pipeline needs

    Manage Jenkins > Plugins > Available plugins. Search for and tick Lockable Resources, then choose Install. The suggested set already includes Pipeline, Git, GitHub Branch Source, SSH Build Agents, Timestamper and Workspace Cleanup.

    PluginUsed for
    Pipelinethe Jenkinsfile itself
    GitHub Branch Sourcefinding branches and pull requests in the repository
    SSH Build Agentsthe controller starting the agent over SSH
    Lockable Resourceslock('quickcart-prod'): one release per environment at a time
    Timestamper, Workspace Cleanuptimestamps() and cleanWs()
  4. Store the agent's SSH key in Jenkins

    The controller has no permission to read the key, on purpose. Read it from your computer, where your admin login can:

    Terminal
    aws ssm get-parameter --name /quickcart/jenkins/agent-ssh-key --with-decryption --region us-east-1 --query Parameter.Value --output text
    PartWhat it means
    aws ssm get-parameterread a Parameter Store value
    --name /quickcart/jenkins/agent-ssh-keythe one Terraform stored
    --with-decryptiondecrypt the SecureString
    --region us-east-1where it is
    --query Parameter.Value --output textprint only the key

    It prints a block starting -----BEGIN OPENSSH PRIVATE KEY-----.

    Manage Jenkins > Credentials > System > Global credentials > Add Credentials

    FieldEnter
    KindSSH Username with private key
    IDagent-ssh
    Usernamejenkins
    Private KeyEnter directly, then paste the whole key

    Choose Create. The key now lives in Jenkins’ encrypted credentials store, not in any file or repository. Close the terminal window that showed it.

  5. Connect the agent

    Manage Jenkins > Nodes > New Node: name aws-agent, Permanent Agent, Create.

    FieldEnter
    Number of executors2
    Remote root directory/home/jenkins/agent
    Labelsaws
    UsageOnly build jobs with label expressions matching this node
    Launch methodLaunch agents via SSH
    Hostthe agent_private_ip output
    Credentialsjenkins (agent-ssh)
    Host Key Verification StrategyManually trusted key Verification Strategy

    Choose Save. Then set the built-in node to run nothing: Manage Jenkins > Nodes > Built-In Node > Configure, Number of executors 0. Builds on the controller would run with the controller’s powers, next to its secrets.

    You should see

    aws-agent shows as connected, with two idle executors, and the built-in node has none.

  6. Add the two locks

    Manage Jenkins > System > Lockable Resources Manager > Add Lockable Resource, twice: quickcart-dev and quickcart-prod. Choose Save.

    If you skip this, the lock step creates them the first time it’s used; declaring them makes them visible.

You should see

Checkpoint: Jenkins opens at http://localhost:8080 through Session Manager, aws-agent is connected with the label aws, and the built-in node has no executors.

Put the code in one repository

  1. Make the repository

    Create a new, private, empty repository on GitHub called quickcart. On your computer, make the folders:

    Terminal
    mkdir -p quickcart/app quickcart/ops quickcart/scripts quickcart/jenkins quickcart/ci
    PartWhat it means
    mkdir -pmake the folders, and any parents

    Copy Project 3’s app.py and requirements.txt into quickcart/app/, Project 4’s whole quickcart-infra folder to quickcart/infra, and the ci/ files from the last stage into quickcart/ci/. Put Project 4’s .gitignore at the top.

  2. Tests, and a Dockerfile that runs them

    app/test_app.py
    from app import app
    
    
    def test_health_says_ok():
        response = app.test_client().get("/health")
        assert response.status_code == 200
        assert response.get_json()["status"] == "ok"
    
    
    def test_orders_lists_three():
        response = app.test_client().get("/orders")
        assert response.status_code == 200
        assert response.get_json()["count"] == 3
    app/requirements-dev.txt
    pytest==9.1.1
    ruff==0.16.9

    The Dockerfile now has three stages. Tests run inside the build, so the agent needs nothing but Docker, and nothing the tests write ends up in Jenkins’ workspace.

    app/Dockerfile
    FROM python:3.12-slim AS base
    WORKDIR /app
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt
    COPY app.py .
    
    FROM base AS test
    COPY requirements-dev.txt test_app.py ./
    RUN pip install --no-cache-dir -r requirements-dev.txt \
        && ruff check . \
        && python -m pytest -q -p no:cacheprovider
    
    FROM base AS release
    RUN useradd --create-home --uid 1001 appuser
    USER 1001
    ARG APP_VERSION=v1
    ENV APP_VERSION=$APP_VERSION
    EXPOSE 8080
    CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--graceful-timeout", "20", "app:app"]
    StageWhat it does
    basePython, the libraries and the code: shared by the other two
    testadds the test tools, runs ruff (a linter) and pytest. -p no:cacheprovider stops pytest writing a cache. docker build --target test fails if any test fails
    releasethe image that ships: the same as Project 3’s, without the test tools
  3. The release scripts

    The pipeline’s steps are ordinary shell scripts in scripts/. You can run any of them from your own terminal, which makes them easy to test and to use when Jenkins itself is down. Make each one executable after creating it with chmod +x scripts/*.sh.

    All of them read the environment’s Terraform outputs from one file, made by this script. That keeps every address and name in Project 4’s code, not typed into the pipeline:

    scripts/stack-outputs.sh
    #!/usr/bin/env bash
    # Write an environment's Terraform outputs to stack-ENV.json.
    # Usage: scripts/stack-outputs.sh ENV
    set -euo pipefail
    env="$1"
    terraform -chdir="infra/envs/$env" init -input=false -no-color > /dev/null
    terraform -chdir="infra/envs/$env" output -json stack > "stack-$env.json"
    echo "wrote stack-$env.json"
    LineWhat it does
    set -euo pipefailstop at the first failing command, at any unset variable, and at a failure anywhere in a pipe. Every script starts with it
    terraform -chdir="infra/envs/$env" initset up that environment’s folder without cd-ing into it
    output -json stack > "stack-$env.json"save the stack’s outputs as JSON

    The rolling release, used for dev and for promoting a canary:

    scripts/deploy.sh
    #!/usr/bin/env bash
    # Release an image to one ECS service, and wait until it is stable.
    # Usage: scripts/deploy.sh ENV stable|canary IMAGE [COUNT]
    set -euo pipefail
    env="$1"
    lane="$2"
    image="$3"
    count="${4:-}"
    stack="stack-$env.json"
    cluster=$(jq -r '.cluster_name' "$stack")
    service=$(jq -r --arg lane "$lane" '.services[$lane]' "$stack")
    
    current=$(aws ecs describe-services --cluster "$cluster" --services "$service" \
      --query 'services[0].taskDefinition' --output text)
    aws ecs describe-task-definition --task-definition "$current" --query 'taskDefinition' > current-task.json
    
    # Keep only the fields register-task-definition accepts, with the new image.
    jq --arg image "$image" '
      .containerDefinitions[0].image = $image
      | {family, executionRoleArn, taskRoleArn, networkMode, containerDefinitions,
         requiresCompatibilities, cpu, memory, runtimePlatform}
      | with_entries(select(.value != null))' current-task.json > new-task.json
    
    new=$(aws ecs register-task-definition --cli-input-json file://new-task.json \
      --query 'taskDefinition.taskDefinitionArn' --output text)
    echo "registered $new"
    
    if [ -n "$count" ]; then
      aws ecs update-service --cluster "$cluster" --service "$service" \
        --task-definition "$new" --desired-count "$count" > /dev/null
    else
      aws ecs update-service --cluster "$cluster" --service "$service" \
        --task-definition "$new" > /dev/null
    fi
    
    aws ecs wait services-stable --cluster "$cluster" --services "$service"
    
    running=$(aws ecs describe-services --cluster "$cluster" --services "$service" \
      --query 'services[0].taskDefinition' --output text)
    if [ "$running" != "$new" ]; then
      echo "FAILED: $service is running $running, not $new. The circuit breaker rolled it back."
      exit 1
    fi
    echo "$service is stable on $new"
    LineWhat it does
    jq -r '.cluster_name' and '.services[$lane]'read the cluster and the stable or canary service’s name from the outputs
    describe-services then describe-task-definitionfetch the recipe the service runs now
    the jq filtercopy it, change only the image, and keep only the fields a new registration accepts
    register-task-definitionsave it as a new revision
    update-service, with --desired-count for the canarystart the release
    aws ecs wait services-stablewait until the release finishes, up to 10 minutes
    the last checkif the circuit breaker rolled back, the service is on the old revision. Waiting succeeds either way, so the script checks which one is running and fails the build if it’s the old one

    The smoke test:

    scripts/smoke.sh
    #!/usr/bin/env bash
    # Check an environment answers /health with the expected version.
    # Usage: scripts/smoke.sh ENV VERSION
    set -euo pipefail
    env="$1"
    version="$2"
    url=$(jq -r '.ecs_url' "stack-$env.json")
    for attempt in 1 2 3 4 5 6; do
      body=$(curl --silent --insecure --max-time 5 "$url/health" || true)
      if echo "$body" | jq -e --arg v "$version" '.status == "ok" and .version == $v' > /dev/null 2>&1; then
        echo "smoke test passed on attempt $attempt: $body"
        exit 0
      fi
      echo "attempt $attempt: got '$body', waiting"
      sleep 10
    done
    echo "FAILED: $url/health never reported version $version"
    exit 1
    LineWhat it does
    for attempt in 1 2 3 4 5 6up to six tries, ten seconds apart
    jq -e '.status == "ok" and .version == $v'passes only if /health says ok and reports exactly this build’s version. An old task answering ok doesn’t count

    Shifting traffic:

    scripts/weights.sh
    #!/usr/bin/env bash
    # Split the HTTPS listener's traffic between the stable and canary target groups.
    # Usage: scripts/weights.sh ENV STABLE_PERCENT CANARY_PERCENT
    set -euo pipefail
    env="$1"
    stable="$2"
    canary="$3"
    stack="stack-$env.json"
    jq -n \
      --arg s "$(jq -r '.target_group_arns.stable' "$stack")" \
      --arg c "$(jq -r '.target_group_arns.canary' "$stack")" \
      --argjson sw "$stable" --argjson cw "$canary" \
      '[{Type: "forward", ForwardConfig: {TargetGroups: [
          {TargetGroupArn: $s, Weight: $sw},
          {TargetGroupArn: $c, Weight: $cw}]}}]' > actions.json
    aws elbv2 modify-listener --listener-arn "$(jq -r '.https_listener_arn' "$stack")" \
      --default-actions file://actions.json > /dev/null
    echo "traffic: stable $stable%, canary $canary%"
    LineWhat it does
    jq -n … > actions.jsonbuild the listener’s new forward action, with both target groups and their weights
    aws elbv2 modify-listener --default-actions file://actions.jsonapply it. Traffic shifts within seconds

    The canary. The abort criteria are written at the top, before any code, where a reviewer reads them first:

    scripts/canary.sh
    #!/usr/bin/env bash
    # Release IMAGE to production as a canary, and promote it only if it stays healthy.
    # Usage: scripts/canary.sh ENV IMAGE
    #
    # Abort criteria, checked after every step. Any one of them stops the release:
    #   1. every canary target is healthy in its target group
    #   2. at least 20 requests reached the canary during the step
    #   3. no more than 1% of the canary's requests returned a 5xx error
    set -euo pipefail
    env="$1"
    image="$2"
    bake_seconds="${BAKE_SECONDS:-300}"
    stack="stack-$env.json"
    url=$(jq -r '.ecs_url' "$stack")
    canary_tg=$(jq -r '.target_group_arns.canary' "$stack")
    lb_dim=$(jq -r '.alb_arn_suffix' "$stack")
    tg_dim=$(jq -r '.target_group_arn_suffixes.canary' "$stack")
    
    abort() {
      echo "ABORT: $1"
      scripts/weights.sh "$env" 100 0
      aws ecs update-service --cluster "$(jq -r '.cluster_name' "$stack")" \
        --service "$(jq -r '.services.canary' "$stack")" --desired-count 0 > /dev/null
      echo "rolled back: all traffic is on the stable version, the canary is scaled to 0"
      exit 1
    }
    
    sum_metric() {
      aws cloudwatch get-metric-statistics --namespace AWS/ApplicationELB --metric-name "$1" \
        --dimensions Name=LoadBalancer,Value="$lb_dim" Name=TargetGroup,Value="$tg_dim" \
        --start-time "$2" --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
        --period 60 --statistics Sum --query 'sum(Datapoints[].Sum)' --output text
    }
    
    check_step() {
      local since="$1"
      local unhealthy requests errors
      # The backticks are JMESPath's way of writing the text "healthy", not shell.
      # shellcheck disable=SC2016
      unhealthy=$(aws elbv2 describe-target-health --target-group-arn "$canary_tg" \
        --query 'length(TargetHealthDescriptions[?TargetHealth.State!=`healthy`])' --output text)
      [ "$unhealthy" = "0" ] || abort "$unhealthy canary targets are not healthy"
      requests=$(sum_metric RequestCount "$since")
      errors=$(sum_metric HTTPCode_Target_5XX_Count "$since")
      echo "canary since $since: $requests requests, $errors server errors"
      awk -v r="$requests" 'BEGIN { exit !(r >= 20) }' || abort "only $requests requests reached the canary"
      awk -v r="$requests" -v e="$errors" 'BEGIN { exit !(e / r <= 0.01) }' || abort "error rate above 1%"
    }
    
    step() {
      local percent="$1"
      local since
      since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
      scripts/weights.sh "$env" $((100 - percent)) "$percent"
      echo "baking for $bake_seconds seconds, with test traffic"
      local end=$((SECONDS + bake_seconds))
      while [ "$SECONDS" -lt "$end" ]; do
        curl --silent --insecure --output /dev/null --max-time 3 "$url/orders" || true
      done
      echo "waiting 120 seconds for CloudWatch to catch up"
      sleep 120
      check_step "$since"
    }
    
    scripts/deploy.sh "$env" canary "$image" 1
    step 10
    step 50
    echo "canary passed every check: promoting"
    scripts/deploy.sh "$env" stable "$image"
    scripts/weights.sh "$env" 100 0
    aws ecs update-service --cluster "$(jq -r '.cluster_name' "$stack")" \
      --service "$(jq -r '.services.canary' "$stack")" --desired-count 0 > /dev/null
    echo "promoted: stable runs $image"
    PartWhat it does
    abort()puts 100% back on stable, scales the canary to 0, and fails the build
    sum_metric()adds up one ALB metric for the canary’s target group since a given time
    check_step()the three criteria: every canary target healthy, at least 20 requests, and at most 1% server errors
    awk … 'BEGIN { exit !(…) }'bash can’t compare decimals, so awk does the sum and exits 0 for true
    step()set the weights, send test traffic for BAKE_SECONDS (5 minutes by default), wait 2 minutes for CloudWatch, then check
    the last linescanary with one task; 10%; 50%; then release to stable, all traffic back to stable, canary to zero

    And the rollback:

    scripts/rollback.sh
    #!/usr/bin/env bash
    # Put the stable service back on the previous revision of its task definition.
    # Usage: scripts/rollback.sh ENV
    set -euo pipefail
    env="$1"
    stack="stack-$env.json"
    cluster=$(jq -r '.cluster_name' "$stack")
    service=$(jq -r '.services.stable' "$stack")
    
    scripts/weights.sh "$env" 100 0
    aws ecs update-service --cluster "$cluster" --service "$(jq -r '.services.canary' "$stack")" \
      --desired-count 0 > /dev/null
    
    current=$(aws ecs describe-services --cluster "$cluster" --services "$service" \
      --query 'services[0].taskDefinition' --output text)
    family_rev="${current##*/}"
    family="${family_rev%:*}"
    revision="${family_rev##*:}"
    if [ "$revision" -le 1 ]; then
      echo "FAILED: $family_rev is the first revision; there is nothing to go back to"
      exit 1
    fi
    previous="$family:$((revision - 1))"
    echo "rolling $service back from $family_rev to $previous"
    aws ecs update-service --cluster "$cluster" --service "$service" --task-definition "$previous" > /dev/null
    aws ecs wait services-stable --cluster "$cluster" --services "$service"
    echo "$service is stable on $previous"
    LineWhat it does
    weights.sh "$env" 100 0 and canary to 0first, stop any canary that’s running
    ${current##*/}, ${family_rev%:*}, ${family_rev##*:}cut …/quickcart-prod-orders:7 into the family quickcart-prod-orders and the revision 7
    previous="$family:$((revision - 1))"the revision before it: the version that ran before the last release
    update-service and wait services-stablea normal rolling release, back to that revision
You should see

Checkpoint: shellcheck scripts/*.sh prints nothing, and docker build --target test app ends with the tests passing.

Write the Jenkinsfile

  1. The pipeline

    Jenkinsfile
    pipeline {
      agent none
    
      options {
        buildDiscarder(logRotator(numToKeepStr: '30'))
        disableConcurrentBuilds()
        timestamps()
        timeout(time: 2, unit: 'HOURS')
      }
    
      environment {
        AWS_REGION = 'us-east-1'
        AWS_ACCOUNT_ID = '111122223333'
        IMAGE_REPO = "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/quickcart-orders"
      }
    
      stages {
        stage('Build and check') {
          agent { label 'aws' }
          options { timeout(time: 30, unit: 'MINUTES') }
          stages {
            stage('Version') {
              steps {
                script {
                  env.IMAGE_TAG = sh(script: 'git rev-parse --short=7 HEAD', returnStdout: true).trim()
                }
                echo "This build is version ${env.IMAGE_TAG}"
              }
            }
            stage('Lint') {
              steps {
                sh 'docker run --rm -i hadolint/hadolint:v2.15.1 < app/Dockerfile'
                sh 'docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:v0.10.0 scripts/deploy.sh scripts/canary.sh scripts/weights.sh scripts/smoke.sh scripts/rollback.sh scripts/stack-outputs.sh scripts/run-sql-task.sh scripts/extract-logs.sh'
                sh 'terraform fmt -check -recursive infra'
              }
            }
            stage('Test') {
              steps {
                sh 'docker build --target test app'
              }
            }
            stage('Build image') {
              steps {
                sh 'docker build --target release --build-arg APP_VERSION="$IMAGE_TAG" -t "$IMAGE_REPO:$IMAGE_TAG" app'
              }
            }
            stage('Scan image') {
              steps {
                sh 'docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL --ignore-unfixed "$IMAGE_REPO:$IMAGE_TAG"'
              }
            }
            stage('Terraform plan') {
              when { changeRequest() }
              steps {
                script {
                  for (envName in ['dev', 'prod']) {
                    dir("infra/envs/${envName}") {
                      sh 'terraform init -input=false -no-color'
                      sh "terraform plan -input=false -no-color -lock-timeout=5m > ../../../plan-${envName}.txt"
                    }
                  }
                }
                archiveArtifacts artifacts: 'plan-*.txt'
              }
            }
            stage('Push image') {
              when { branch 'main' }
              steps {
                sh '''
                  if aws ecr describe-images --repository-name quickcart-orders --image-ids imageTag="$IMAGE_TAG" > /dev/null 2>&1; then
                    echo "$IMAGE_TAG is already in ECR; not pushing again"
                  else
                    aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "${IMAGE_REPO%%/*}"
                    docker push "$IMAGE_REPO:$IMAGE_TAG"
                  fi
                '''
              }
            }
          }
          post {
            always { cleanWs() }
          }
        }
    
        stage('Deploy to dev') {
          when { branch 'main' }
          agent { label 'aws' }
          options {
            lock('quickcart-dev')
            timeout(time: 20, unit: 'MINUTES')
          }
          steps {
            sh 'scripts/stack-outputs.sh dev'
            sh 'scripts/deploy.sh dev stable "$IMAGE_REPO:$IMAGE_TAG"'
            sh 'scripts/smoke.sh dev "$IMAGE_TAG"'
          }
          post {
            always { cleanWs() }
          }
        }
    
        stage('Approve production') {
          when { branch 'main' }
          options { timeout(time: 1, unit: 'HOURS') }
          input {
            message 'Dev passed its smoke test. Start the production canary?'
            ok 'Start the canary'
            submitter 'admin'
          }
          steps {
            echo "Approved: ${env.IMAGE_TAG} goes to production"
          }
        }
    
        stage('Canary to production') {
          when { branch 'main' }
          agent { label 'aws' }
          options {
            lock('quickcart-prod')
            timeout(time: 45, unit: 'MINUTES')
          }
          steps {
            sh 'scripts/stack-outputs.sh prod'
            sh 'scripts/canary.sh prod "$IMAGE_REPO:$IMAGE_TAG"'
            sh 'scripts/smoke.sh prod "$IMAGE_TAG"'
          }
          post {
            always { cleanWs() }
          }
        }
      }
    
      post {
        success {
          echo "Build ${env.BUILD_NUMBER} of ${env.BRANCH_NAME} passed."
        }
        failure {
          echo "Build ${env.BUILD_NUMBER} of ${env.BRANCH_NAME} failed. Open the stage that failed and read its log from the bottom up."
        }
      }
    }

    Every directive is explained in The Jenkinsfile. What each one does here:

    LineWhat it doesWhy
    agent none at the topno stage holds an agent unless it asks for onethe approval can wait an hour without blocking a build machine
    buildDiscarder(logRotator(numToKeepStr: '30'))keep the last 30 builds per branchold builds fill the controller’s disk
    disableConcurrentBuilds()one build per branch at a time; the next one queuestwo releases of main at once would race
    timestamps()every log line gets a time“how long did the canary bake?” is answerable
    timeout(time: 2, unit: 'HOURS')a stuck build is killeda hung build blocks everything behind it
    environmentthe region, account and image repository, set once—
    stage('Build and check') → agent { label 'aws' } with nested stagesall the checks run on one agent, in one workspacethe image built in one stage is the one scanned and pushed in the next
    env.IMAGE_TAG = sh(… returnStdout: true).trim()the short commit ID becomes the version, for every later stage—
    LintHadolint and ShellCheck run from pinned Docker images; Terraform checks formatting—
    Test → docker build --target test appruns the tests inside the build—
    Build image → --build-arg APP_VERSION="$IMAGE_TAG"stamps the version into the image, so /health reports itthe smoke test checks it
    Scan image → Trivy with --exit-code 1 --severity CRITICAL --ignore-unfixedfails the build on a critical problem that has a fix availableproblems with no fix yet would block every build
    when { changeRequest() }the plan runs only for pull requests—
    dir("infra/envs/…") and -lock-timeout=5mplan dev and prod, waiting up to 5 minutes for a lock—
    archiveArtifactsthe plans are saved with the build, for the reviewer—
    when { branch 'main' }push and release only from maina pull request can never release
    if aws ecr describe-images … in Push imageskip the push if this version is already in ECRthe repository is immutable, so a rebuild of the same commit would fail. Now running it twice is safe
    post { always { cleanWs() } }empty the workspace after each agent stagestale files from one build can’t leak into the next
    lock('quickcart-dev'), lock('quickcart-prod')one release per environment at a time, across every job, including rollback and SQL—
    input { … submitter 'admin' }waits for the admin user to approve. Anyone else’s click is refusedadd more users, comma-separated
    options { timeout(time: 1, unit: 'HOURS') } on the approvalan unapproved release is abandoned after an houra forgotten approval would block the next release
    post { success … failure … }a summary line at the end of every buildconnect a chat notification here
  2. Commit and push

    Terminal
    cd quickcart
    PartWhat it means
    cd quickcartinto the repository
    Terminal
    git init -b main
    PartWhat it means
    git initstart a repository
    -b maincall the first branch main, which the Jenkinsfile releases from
    Terminal
    git add .
    PartWhat it means
    git add .stage every file, except those .gitignore excludes
    Terminal
    git commit -m "QuickCart pipeline"
    PartWhat it means
    git commit -mrecord the files, with a message
    Terminal
    git remote add origin REPO_URL
    PartWhat it means
    git remote add origintell git where the GitHub copy lives
    REPO_URLyour repository’s address: on GitHub, choose Code and copy the HTTPS or SSH address
    Terminal
    git push -u origin main
    PartWhat it means
    git push -u origin mainupload main, and remember origin as its home
You should see

Checkpoint: GitHub shows the repository with Jenkinsfile at the top.

Connect Jenkins to GitHub

  1. A token for Jenkins

    On GitHub: Settings > Developer settings > Personal access tokens > Fine-grained tokens > Generate new token.

    FieldEnter
    Token namequickcart-jenkins
    Expiration30 days
    Repository accessOnly select repositories: quickcart
    PermissionsContents: read-only; Pull requests: read-only; Metadata: read-only; Commit statuses: read and write

    In Jenkins: Manage Jenkins > Credentials > System > Global credentials > Add Credentials

    FieldEnter
    KindUsername with password
    Usernameyour GitHub username
    Passwordthe token
    IDgithub
  2. A multibranch pipeline

    New Item, name quickcart, Multibranch Pipeline, OK.

    FieldEnter
    Branch SourcesAdd source > GitHub
    Credentialsgithub
    Repository HTTPS URLyour repository’s HTTPS address, from GitHub’s Code button
    Behaviourskeep Discover branches and Discover pull requests from origin
    Build Configurationby Jenkinsfile, script path Jenkinsfile
    Scan Multibranch Pipeline Triggerstick Periodically if not otherwise run, interval 1 minute

    Choose Save. Jenkins scans the repository and starts a build of main.

  3. Watch the first release

    The first main build runs every stage: checks, push, dev, smoke test, then waits at Approve production. Open the build, and in the paused stage choose Start the canary. The canary takes about 20 minutes: two 5-minute bakes and two 2-minute waits for metrics, plus the releases.

    You should see

    In the build’s log, near the end of the canary stage:

    Console output, roughly
    canary passed every check: promoting
    traffic: stable 100%, canary 0%
    promoted: stable runs 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:3f9c2e1
    smoke test passed on attempt 1: {"status":"ok","version":"3f9c2e1"}

    Your version will be your own commit’s ID. Every stage is green.

You should see

Checkpoint: curl -k on prod’s ecs_url with /health reports your commit’s short ID as the version.

Add the operations jobs

Each job is a plain Pipeline item that reads its script from the repository. For each one: New Item, the name, Pipeline, OK; then under Pipeline, Definition Pipeline script from SCM, SCM Git, the repository URL and github credentials, Branch */main, and the Script Path below. Run each once with Build Now so Jenkins learns its parameters; that first run fails harmlessly.

  1. One-click rollback

    Name quickcart-rollback, script path jenkins/rollback.Jenkinsfile.

    jenkins/rollback.Jenkinsfile
    pipeline {
      agent { label 'aws' }
    
      options {
        disableConcurrentBuilds()
        timestamps()
        timeout(time: 20, unit: 'MINUTES')
      }
    
      parameters {
        choice(name: 'ENV', choices: ['prod', 'dev'], description: 'Which environment to put back on its previous version')
      }
    
      environment {
        AWS_REGION = 'us-east-1'
      }
    
      stages {
        stage('Roll back') {
          options { lock("quickcart-${params.ENV}") }
          steps {
            sh "scripts/stack-outputs.sh ${params.ENV}"
            sh "scripts/rollback.sh ${params.ENV}"
          }
        }
      }
    
      post {
        always { cleanWs() }
      }
    }
    LineWhat it does
    parameters → choice(name: 'ENV', choices: ['prod', 'dev'])the one choice: which environment. prod is first, because that’s where rollbacks matter
    lock("quickcart-${params.ENV}")waits for any release to that environment to finish, and blocks new ones while it runs
    scripts/rollback.shthe script from earlier: stop any canary, then go back one revision

    Build with Parameters > ENV: prod > Build is the whole procedure.

  2. Safe SQL in a transaction

    Someone needs to fix three orders stuck in pending. The dangerous way is a database client on a laptop and a typed UPDATE. This job does it the safe way: the SQL runs inside a transaction, a preview always rolls back, and a commit only happens if the number of rows touched matches what the preview showed. Running SQL safely explains why each rule exists.

    The database is only reachable from the app subnets, so the SQL runs as a one-off Fargate task there, with the app’s security group, reading the password from the same secret the order service uses.

    ops/run_sql.py
    """Run one SQL statement in a transaction. Preview always rolls back.
    
    Commit only happens when MODE is COMMIT and the number of rows the
    statement touched is exactly EXPECTED_ROWS. Anything else rolls back.
    """
    import os
    import sys
    
    import pymysql
    
    ALLOWED = ("UPDATE", "DELETE", "INSERT")
    
    
    def main():
        mode = os.environ["MODE"]
        ticket = os.environ["TICKET"]
        expected = os.environ.get("EXPECTED_ROWS", "")
        sql = os.environ["SQL"].strip().rstrip(";").strip()
    
        first_word = sql.split(None, 1)[0].upper() if sql else ""
        if first_word not in ALLOWED:
            print(f"REFUSED: only {', '.join(ALLOWED)} are allowed, not {first_word or 'nothing'}")
            return 2
        if ";" in sql:
            print("REFUSED: one statement at a time")
            return 2
    
        conn = pymysql.connect(
            host=os.environ["DB_HOST"],
            user=os.environ["DB_USER"],
            password=os.environ["DB_PASSWORD"],
            database=os.environ["DB_NAME"],
            autocommit=False,
            connect_timeout=10,
            ssl_ca="/app/global-bundle.pem",
            ssl_verify_cert=True,
            ssl_verify_identity=True,
        )
        try:
            with conn.cursor() as cur:
                cur.execute("SET SESSION innodb_lock_wait_timeout = 10")
                rows = cur.execute(sql)
            print(f"ticket {ticket}: the statement touched {rows} rows")
            if mode == "COMMIT" and expected.isdigit() and rows == int(expected):
                conn.commit()
                print("COMMITTED")
                return 0
            conn.rollback()
            if mode == "COMMIT":
                print(f"ROLLED BACK: expected {expected} rows, got {rows}")
                return 1
            print("ROLLED BACK: preview only, nothing changed")
            return 0
        finally:
            conn.close()
    
    
    if __name__ == "__main__":
        sys.exit(main())
    PartWhat it does
    ALLOWED and the first-word checkonly UPDATE, DELETE and INSERT. In MySQL, statements like DROP or ALTER commit by themselves, so no transaction could undo them
    if ";" in sqlone statement per run. A semicolon inside a quoted value is refused too: split the work instead
    autocommit=Falsenothing is saved until commit() is called
    ssl_ca, ssl_verify_cert, ssl_verify_identitythe connection is encrypted and the server’s certificate is checked against AWS’s RDS certificate bundle
    innodb_lock_wait_timeout = 10give up after 10 seconds if the rows are locked, rather than queue behind live orders
    rows = cur.execute(sql)the number of rows the statement touched
    the commit ruleCOMMIT mode and a matching count commits. Every other path rolls back
    ops/Dockerfile
    FROM python:3.12-slim
    WORKDIR /app
    RUN pip install --no-cache-dir pymysql==1.2.3
    ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem /app/global-bundle.pem
    COPY run_sql.py .
    RUN chmod 644 /app/global-bundle.pem && useradd --create-home --uid 1001 ops
    USER 1001
    CMD ["python", "run_sql.py"]
    LineWhat it does
    pip install … pymysql==1.2.3the MySQL client library, pinned
    ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pemAWS’s certificate bundle for RDS, so the connection can be verified
    chmod 644the downloaded file is readable by the non-root user
    USER 1001runs as a normal user

    The script that runs it as a task:

    scripts/run-sql-task.sh
    #!/usr/bin/env bash
    # Run ops/run_sql.py once, as a Fargate task next to the order service.
    # Usage: scripts/run-sql-task.sh ENV IMAGE
    # Reads MODE, SQL, EXPECTED_ROWS and TICKET from the environment.
    set -euo pipefail
    env="$1"
    image="$2"
    stack="stack-$env.json"
    cluster=$(jq -r '.cluster_name' "$stack")
    log_group=$(jq -r '.ops_log_group' "$stack")
    
    jq -n --arg env "$env" --arg image "$image" --arg region "$AWS_REGION" \
      --arg role "$(jq -r '.execution_role_arn' "$stack")" \
      --arg host "$(jq -r '.db_address' "$stack")" \
      --arg secret "$(jq -r '.db_secret_arn' "$stack")" \
      --arg logs "$log_group" '{
        family: "quickcart-\($env)-ops",
        requiresCompatibilities: ["FARGATE"], networkMode: "awsvpc",
        cpu: "256", memory: "512", executionRoleArn: $role,
        runtimePlatform: {operatingSystemFamily: "LINUX", cpuArchitecture: "X86_64"},
        containerDefinitions: [{
          name: "ops", image: $image, essential: true,
          environment: [{name: "DB_HOST", value: $host}, {name: "DB_USER", value: "quickcart_admin"},
                        {name: "DB_NAME", value: "quickcart"}],
          secrets: [{name: "DB_PASSWORD", valueFrom: ($secret + ":password::")}],
          logConfiguration: {logDriver: "awslogs", options: {
            "awslogs-group": $logs, "awslogs-region": $region, "awslogs-stream-prefix": "ops"}}
        }]}' > ops-task.json
    
    jq -n --arg mode "$MODE" --arg sql "$SQL" --arg rows "$EXPECTED_ROWS" --arg ticket "$TICKET" \
      '{containerOverrides: [{name: "ops", environment: [
         {name: "MODE", value: $mode}, {name: "SQL", value: $sql},
         {name: "EXPECTED_ROWS", value: $rows}, {name: "TICKET", value: $ticket}]}]}' > overrides.json
    
    jq --arg sg "$(jq -r '.app_security_group_id' "$stack")" \
      '{awsvpcConfiguration: {subnets: .app_subnet_ids, securityGroups: [$sg], assignPublicIp: "DISABLED"}}' \
      "$stack" > network.json
    
    task_def=$(aws ecs register-task-definition --cli-input-json file://ops-task.json \
      --query 'taskDefinition.taskDefinitionArn' --output text)
    task=$(aws ecs run-task --cluster "$cluster" --launch-type FARGATE --task-definition "$task_def" \
      --network-configuration file://network.json --overrides file://overrides.json \
      --query 'tasks[0].taskArn' --output text)
    echo "started $task"
    
    aws ecs wait tasks-stopped --cluster "$cluster" --tasks "$task"
    code=$(aws ecs describe-tasks --cluster "$cluster" --tasks "$task" \
      --query 'tasks[0].containers[0].exitCode' --output text)
    
    echo "---- output ----"
    aws logs get-log-events --log-group-name "$log_group" \
      --log-stream-name "ops/ops/${task##*/}" --start-from-head \
      --query 'events[].[message]' --output text
    echo "---- exit code: $code ----"
    [ "$code" = "0" ]
    PartWhat it does
    the first jq -nbuilds a task definition: the ops image, the environment’s execution role, the database host, and DB_PASSWORD from the secret. It’s registered fresh each run, so it always matches the environment’s current outputs
    the second jq -nthe run’s parameters, passed as environment variables. --arg escapes the SQL safely, whatever quotes it contains
    the third jqthe network: the app subnets and security group, no public IP
    run-task, then wait tasks-stoppedrun it once and wait until it finishes
    describe-tasks … exitCode0 means preview done or committed; anything else fails the build
    get-log-events … "ops/ops/${task##*/}"print what the tool said. The log stream is named prefix, container, then task ID

    And the job, named quickcart-ops-sql, script path jenkins/ops-sql.Jenkinsfile:

    jenkins/ops-sql.Jenkinsfile
    pipeline {
      agent { label 'aws' }
    
      options {
        disableConcurrentBuilds()
        timestamps()
        timeout(time: 30, unit: 'MINUTES')
      }
    
      parameters {
        choice(name: 'ENV', choices: ['dev', 'prod'], description: 'Which database')
        choice(name: 'MODE', choices: ['PREVIEW', 'COMMIT'], description: 'PREVIEW always rolls back. COMMIT only commits if the row count matches EXPECTED_ROWS')
        text(name: 'SQL', defaultValue: '', description: 'One UPDATE, DELETE or INSERT statement')
        string(name: 'EXPECTED_ROWS', defaultValue: '', description: 'COMMIT only: the row count the preview showed')
        string(name: 'TICKET', defaultValue: '', description: 'The ticket that asked for this change')
      }
    
      environment {
        AWS_REGION = 'us-east-1'
        OPS_REPO = "111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-ops"
      }
    
      stages {
        stage('Check the request') {
          steps {
            script {
              if (!params.TICKET?.trim()) {
                error('A ticket is required, so every change can be traced back to a request.')
              }
              if (!params.SQL?.trim()) {
                error('SQL is empty.')
              }
              if (params.MODE == 'COMMIT' && !(params.EXPECTED_ROWS ==~ /\d+/)) {
                error('COMMIT needs EXPECTED_ROWS: run a PREVIEW first and copy its row count.')
              }
            }
            echo "${params.MODE} on ${params.ENV} for ticket ${params.TICKET}"
          }
        }
    
        stage('Second person approves') {
          when { expression { params.MODE == 'COMMIT' && params.ENV == 'prod' } }
          steps {
            input(message: "Commit this change to production? Ticket ${params.TICKET}, expecting ${params.EXPECTED_ROWS} rows.", ok: 'Commit it', submitter: 'admin')
          }
        }
    
        stage('Build the ops tool') {
          steps {
            script {
              env.OPS_TAG = sh(script: 'git rev-parse --short=7 HEAD', returnStdout: true).trim()
            }
            sh '''
              if ! aws ecr describe-images --repository-name quickcart-ops --image-ids imageTag="$OPS_TAG" > /dev/null 2>&1; then
                docker build -t "$OPS_REPO:$OPS_TAG" ops
                aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "${OPS_REPO%%/*}"
                docker push "$OPS_REPO:$OPS_TAG"
              fi
            '''
          }
        }
    
        stage('Run in a transaction') {
          options { lock("quickcart-${params.ENV}-sql") }
          environment {
            MODE = "${params.MODE}"
            SQL = "${params.SQL}"
            EXPECTED_ROWS = "${params.EXPECTED_ROWS}"
            TICKET = "${params.TICKET}"
          }
          steps {
            sh "scripts/stack-outputs.sh ${params.ENV}"
            sh "scripts/run-sql-task.sh ${params.ENV} \"\$OPS_REPO:\$OPS_TAG\""
          }
        }
      }
    
      post {
        always { cleanWs() }
      }
    }
    PartWhat it does
    the five parametersenvironment, mode, the statement, the expected count and the ticket
    Check the requestrefuses to start without a ticket, or a commit without a number
    Second person approves → whena commit to prod waits for admin, who should not be the person who asked
    Build the ops toolbuilds and pushes quickcart-ops for this commit, once
    lock("quickcart-${params.ENV}-sql")one SQL change per database at a time
    environment { SQL = "${params.SQL}" } then "$SQL" in the scriptthe statement reaches the shell as an environment variable, never pasted into a command, so no quote in it can break out

    Using it, for the three stuck orders:

    RunMODESQLEXPECTED_ROWSResult
    1PREVIEWUPDATE orders SET status = 'cancelled' WHERE status = 'pending' AND created_at < '2026-09-01'emptythe statement touched 3 rows then ROLLED BACK: preview only
    2COMMITthe same3approval, then COMMITTED

    If rows were added between the two runs, run 2 touches 4, rolls back, and the build fails. That’s the point. The quickcart database from Project 4 starts empty, so to practise, create a table with a few rows through a first INSERT run.

  3. Log extraction for a time window

    “What happened between 14:00 and 14:30?” is the first question in every incident, and answering it shouldn’t need console access. Name quickcart-ops-logs, script path jenkins/ops-logs.Jenkinsfile.

    scripts/extract-logs.sh
    #!/usr/bin/env bash
    # Save every log line from one log group in a time window to logs.txt.
    # Usage: scripts/extract-logs.sh LOG_GROUP START END [FILTER]
    # START and END are UTC, like "2026-09-25 14:00".
    set -euo pipefail
    group="$1"
    start_ms=$(( $(date -u -d "$2" +%s) * 1000 ))
    end_ms=$(( $(date -u -d "$3" +%s) * 1000 ))
    filter="${4:-}"
    
    if [ "$end_ms" -le "$start_ms" ]; then
      echo "FAILED: END must be after START"
      exit 1
    fi
    if [ $(( end_ms - start_ms )) -gt $(( 24 * 3600 * 1000 )) ]; then
      echo "FAILED: the window is longer than 24 hours; ask for a smaller one"
      exit 1
    fi
    
    args=(--log-group-name "$group" --start-time "$start_ms" --end-time "$end_ms" --output json)
    if [ -n "$filter" ]; then
      args+=(--filter-pattern "$filter")
    fi
    aws logs filter-log-events "${args[@]}" \
      | jq -r '.events[] | "\(.timestamp / 1000 | floor | todate) \(.logStreamName) \(.message)"' > logs.txt
    echo "saved $(wc -l < logs.txt) lines from $group to logs.txt"
    PartWhat it does
    date -u -d "$2" +%s then * 1000turn 2026-09-25 14:00 into milliseconds since 1970, which CloudWatch expects
    the two checksrefuse a window that ends before it starts, or is longer than 24 hours
    args=(…) and args+=(--filter-pattern …)add the filter only if one was given
    aws logs filter-log-eventsfetch every matching line. The CLI fetches every page by itself
    the jq lineone line per event: time, which task, message
    jenkins/ops-logs.Jenkinsfile
    pipeline {
      agent { label 'aws' }
    
      options {
        timestamps()
        timeout(time: 15, unit: 'MINUTES')
      }
    
      parameters {
        choice(name: 'ENV', choices: ['prod', 'dev'], description: 'Which environment')
        choice(name: 'SOURCE', choices: ['orders', 'orders-canary', 'ops'], description: 'Which log group')
        string(name: 'START', defaultValue: '', description: 'UTC, like 2026-09-25 14:00')
        string(name: 'END', defaultValue: '', description: 'UTC, like 2026-09-25 14:30. At most 24 hours after START')
        string(name: 'FILTER', defaultValue: '', description: 'Optional CloudWatch filter pattern, like ERROR')
      }
    
      environment {
        AWS_REGION = 'us-east-1'
      }
    
      stages {
        stage('Extract') {
          environment {
            LOG_GROUP = "/ecs/quickcart-${params.ENV}-${params.SOURCE}"
            START = "${params.START}"
            END = "${params.END}"
            FILTER = "${params.FILTER}"
          }
          steps {
            sh 'scripts/extract-logs.sh "$LOG_GROUP" "$START" "$END" "$FILTER"'
            archiveArtifacts artifacts: 'logs.txt'
          }
        }
      }
    
      post {
        always { cleanWs() }
      }
    }
    PartWhat it does
    SOURCEthe log group: the stable service, the canary or the ops tasks
    the stage’s environmentthe parameters reach the script as environment variables, safely quoted
    archiveArtifacts artifacts: 'logs.txt'the file is attached to the build, to download or link in the incident notes
You should see

Checkpoint: a PREVIEW run of quickcart-ops-sql against dev ends with ROLLED BACK: preview only, nothing changed, and a quickcart-ops-logs run for the last hour attaches a logs.txt.

Write the operations runbook

A runbook is what the person on call reads at 3am. Keep it in the repository, as RUNBOOK.md, so it changes with the code. Runbooks explains how to write and test one. This is QuickCart’s.

  1. Release

    Whenany change merged to main
    Howmerge the pull request. Approve Approve production in the main build once dev’s smoke test has passed
    Checkthe build is green; prod’s /health reports the commit’s short ID
    If the canary abortsnothing to undo: traffic is already back on stable. Read the abort line in the log, then the canary’s logs with quickcart-ops-logs, SOURCE orders-canary
    Neverrelease by clicking in the ECS console. The next pipeline run would not know
  2. Roll back

    Whenprod misbehaves after a release that passed its canary
    Howquickcart-rollback, ENV prod, Build. About 5 minutes
    Check/health reports the previous version; the 5xx alarm returns to OK
    Thenrevert the bad commit in git, so the next merge doesn’t release it again
  3. Change data

    Whena ticket asks for a data fix
    Howquickcart-ops-sql in PREVIEW; check the count is what the ticket expects; run again in COMMIT with that count; a second person approves prod
    Checkthe log says COMMITTED
    NeverDROP, ALTER or TRUNCATE through this job; it refuses them. Schema changes go through a reviewed migration
  4. Investigate

    Whenan alarm fires, or someone reports a problem at a time
    Howquickcart-ops-logs with the environment, the source and a window around the time. Add FILTER ERROR to narrow it
    ThenWhere to look first
  5. When Jenkins itself is broken

    SymptomDo
    a build waits for aws foreverManage Jenkins > Nodes: reconnect aws-agent. If the instance is gone, terraform apply in ci/ rebuilds it, and the node reconnects
    a lock is stuck after a killed buildLockable Resources: Unlock it, after checking no release is running
    the controller is lostrestore the newest snapshot tagged Backup = jenkins-home as a volume and attach it to a new controller. You lose at most a day of build history. Everything that matters is in git
    an urgent rollback with Jenkins downrun scripts/stack-outputs.sh prod then scripts/rollback.sh prod from a laptop with admin access. The pipeline’s scripts work anywhere
You should see

Checkpoint: RUNBOOK.md is committed, and you’ve followed each procedure once, in dev, with a watch in your hand. Write each one’s time into it.

Prove it

The claim: a commit reaches production with nobody touching the console, and a bad one is stopped by itself.

  1. A pull request is checked, not released

    Make a branch, change the / page’s JSON in app/app.py (for example, add "team": "quickcart"), push it and open a pull request.

    You should see

    Within a couple of minutes, a PR-1 job in Jenkins: checks passed, two plans attached (No changes in each), no push, no deploy. GitHub shows the check on the pull request.

  2. A good merge reaches production

    Merge the pull request. Approve when asked. Time it from the merge to the last green stage.

    You should see

    Every stage green. Prod’s / shows "team":"quickcart", and nobody opened the AWS console.

  3. A bad merge is stopped by the canary

    In app/app.py, make /orders fail: change return jsonify(version=VERSION, count=len(ORDERS), orders=ORDERS) to return jsonify(error="broken"), 500. Merge it through a pull request, and approve.

    You should see

    Dev’s smoke test passes, because it only checks /health. The canary then fails its first check, because canary.sh sends its test traffic to /orders:

    The canary stage's log ends with lines like
    canary since 2026-09-25T14:02:07Z: 61 requests, 61 server errors
    ABORT: error rate above 1%
    traffic: stable 100%, canary 0%
    rolled back: all traffic is on the stable version, the canary is scaled to 0

    Your times and counts will differ. The build is red, and prod’s /orders still works: only a tenth of production traffic met the bad version, for five minutes.

    Revert the commit with another pull request. That’s the fix, and it goes through the same pipeline.

Fill in the evidence:

TestYour resultPass mark
Pull request: checks and plans, no releasegreen; nothing pushed
Merge to production, commit to last green stageyour minutesno console, one approval click
Broken canaryaborted at 10%; stable untouched
Rollback job, click to stableyour minutesunder 10 minutes
SQL preview then commitcounts match; commit only after approval
Access keys in Jenkins, the repository or the agentnone

Troubleshooting

Clean up

Production first: it costs the most.

  1. Destroy prod and dev

    Follow Project 4’s clean-up for prod, including turning its deletion protection off first, then for dev. Task definition revisions the pipeline registered are left behind as inactive entries; they cost nothing.

  2. Destroy Jenkins

    Terminal
    cd ci
    PartWhat it means
    cd cifrom the top of the repository, into the Jenkins folder
    Terminal
    terraform destroy
    PartWhat it means
    terraform destroydelete both instances, their roles, the SSH key parameter and the backup policy
    You should see

    Destroy complete!. Snapshots the backup policy already took are not deleted with it.

  3. Delete the snapshots and the token

    In EC2 > Snapshots, filter by the tag Backup and delete every jenkins-home snapshot. On GitHub, delete the quickcart-jenkins token: Settings > Developer settings > Personal access tokens.

  4. Destroy the shared layer

    In infra/shared, run terraform destroy. It deletes the quickcart-orders and quickcart-ops repositories in both regions, with every image.

  5. Clean-up step: Clean up

    Check both regions, US East (N. Virginia) and US West (Oregon):

    WhereShould show
    EC2 > Instancesnothing running from QuickCart
    EC2 > Snapshots and Volumesnothing tagged Project = quickcart
    ECS > Clusters, RDS > Databases, EC2 > Load Balancersnothing
    VPC > NAT gateways, EC2 > Elastic IPsnothing active
    ECR > Repositoriesnothing starting quickcart-
    Systems Manager > Parameter Storeno /quickcart/ parameters
    You should see

    All empty. The state bucket is the only thing left; empty and delete it as Terraform state shows if you’ve finished the course.

Built it, and cleaned up? Mark it done.