Continuous delivery
Approval gates, deploys, smoke tests and one-click rollback.
Part 1: The problem
QuickCart’s pipeline had three deploy jobs, one per environment. Each one checked out the code, built a fresh image and deployed it.
On Tuesday the new menu search passed every test in dev and stg. On Wednesday Kofi ran the prod job. It built the image again — and in the hours between, a library the app used had released a new minor version. The prod build picked it up. Search broke for every customer, with code that had never run anywhere before.
Rolling back meant running the prod job with Monday’s commit, which built yet another image. That took twenty minutes, and nobody could be sure it matched what Monday’s prod had really run.
QuickCart needs three things: one image that travels unchanged from dev to prod, a person’s approval before prod, and a rollback that takes seconds, not a rebuild.
Part 2: The idea
Continuous delivery means every change that passes the pipeline is ready to go to production, and getting it there is one routine, repeatable step. Whether a human presses the final button is a choice; the rest is automatic.
Build once, promote many times
The pipeline builds the image once, pushes it to ECR, and from then on refers to it only by its digest — the sha256:… fingerprint of its contents. Each environment is then told “run this digest”.
| Step | What happens | Why |
|---|---|---|
| Build | One image, pushed to ECR once | Everything after this uses the same bytes |
| Deploy to dev | dev runs the digest | Fast feedback from a real environment |
| Smoke test dev | A few quick checks that the release really works there | Catch “it deployed but doesn’t start” |
| Deploy to stg, smoke test | The same digest again | stg looks like prod, so this is the dress rehearsal |
| Approval | A person agrees to go to prod, with a time limit | Someone accountable decides, and nothing waits forever |
| Deploy to prod, smoke test | The same digest again | Prod runs exactly what stg tested |
Approval gates
In a Jenkinsfile, an approval is the input step. The build pauses and shows a button. Always wrap it in a timeout: an unanswered approval should end the build, not wait for days.
| Setting | What it does |
|---|---|
input message: '…', ok: '…' | Pause and ask. The build continues only if someone presses the button |
timeout around the stage | If nobody answers in time, the build is aborted and prod is untouched |
submitter: 'release-managers' | Only people in that group may approve. Without it, anyone who can see the job can |
While it waits, input holds on to the agent it’s running on. Pipelines that wait a long time put agent none on the approval stage so no agent sits idle — the Jenkinsfile lesson covers per-stage agents.
Rollback is a deploy, not a rebuild
Because every environment only ever ran digests, “the previous version” is just the previous digest. A rollback job looks it up and deploys it. Nothing is compiled, nothing is fetched, and it was already proven in prod. That makes it the fastest safe move in an incident: get back to good first, fix calmly afterwards.
Credentials: roles, not keys
The pipeline needs AWS permissions to deploy. Never store an access key in Jenkins for this.
| Where Jenkins runs | How it gets AWS credentials |
|---|---|
| An agent on EC2 | The agent’s instance profile gives it a role. The AWS CLI picks up short-lived credentials automatically |
| One role per environment | The Pipeline: AWS Steps plugin’s withAWS(role: 'quickcart-deploy-prod') assumes a role just for that stage, so a dev build can’t touch prod |
| On your computer, for learning | Your own SSO login from Set up your computer: short-lived, and it expires on its own |
Timeouts, retries and idempotency
| Idea | In practice |
|---|---|
| Timeout every stage that talks to the network | A hung call ends the build instead of blocking the next release |
| Retry only steps that are safe to repeat | retry(3) around a deploy call rides out a brief API error |
| Idempotent deploys | “Set prod to digest X” gives the same result however many times it runs. “Add one more server” does not — never retry that |
Part 3: See it
Below, a new version of the order service moves through QuickCart’s environments. First keep The same image selected: build, promote to dev and stg, approve prod, then roll prod back. Then choose A fresh build, where every environment rebuilds, and watch what the registry fills up with.
Registry
every image ever built
dev
running
stg
running
prod
running
What to notice: with one build, every environment shows the same digest, and rollback is instant because the old digest is still there. With fresh builds, the digests stop matching — prod runs an image that no earlier environment ever tested.
Part 4: Do it
Pennies — two small images in ECR, a fraction of a cent a day. Parameter Store standard parameters are free.
You’ll build a delivery pipeline on the Jenkins you set up in The Jenkinsfile. It finds one image in ECR by its digest, promotes it through dev, stg and prod with an approval before prod, and a second job rolls prod back. About 45 minutes.
To keep this lab cheap, each environment is a record of which digest it runs: a parameter in AWS Systems Manager Parameter Store, such as /quickcart/prod/image. Deploying means updating that record. The pipeline logic — resolve once, promote, approve, check, roll back — is exactly what Project 5 wires to real ECS services.
Put two versions in ECR
You need the
quickcart-ordersrepository from Storing images: ECR, with Tag immutability on. If you deleted it, create it again the same way: ECR → Private registry → Repositories → Create repository, namequickcart-orders, immutable tags.For this lab, any two different images will do as “version 1” and “version 2”. Pull two small public ones:
Terminaldocker pull public.ecr.aws/docker/library/busybox:1.36Part What it means docker pulldownload an image to your computer public.ecr.aws/docker/library/busybox:1.36a tiny public image, version 1.36 Terminaldocker pull public.ecr.aws/docker/library/busybox:1.37Part What it means docker pulldownload an image to your computer public.ecr.aws/docker/library/busybox:1.37the next version of the same image Log Docker in to your registry. This is the one pipe in the lab:
Terminalaws ecr get-login-password --region us-east-1 --profile quickcart | docker login --username AWS --password-stdin 111122223333.dkr.ecr.us-east-1.amazonaws.comPart What it means aws ecr get-login-passwordask ECR for a password that’s valid for 12 hours --region us-east-1 --profile quickcartin your region, using your SSO login |pass that password straight to the next command, without showing it on screen docker login --username AWSlog Docker in. The user name for ECR is always AWS--password-stdinread the password from the pipe, not from the command line 111122223333.dkr.ecr.us-east-1.amazonaws.comyour registry. Replace the account number with yours You should seeLogin SucceededTag the two images as
v1andv2ofquickcart-orders:Terminaldocker tag public.ecr.aws/docker/library/busybox:1.36 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1Part What it means docker taggive an image a second name public.ecr.aws/docker/library/busybox:1.36the image to name 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1its new name: your repository, tag v1Terminaldocker tag public.ecr.aws/docker/library/busybox:1.37 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2Part What it means docker taggive an image a second name public.ecr.aws/docker/library/busybox:1.37the second image 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2tag v2in your repositoryPush both:
Terminaldocker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1Part What it means docker pushupload an image to its registry 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1the image, by its full name Terminaldocker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2Part What it means docker pushupload an image to its registry 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2the second image Each push ends with a line like this, with its own digest:
You should seev2: digest: sha256:3f5a1e0c9b7d4a2e8f6c1b0d9e7a5c3b1f2e4d6a8c0b9e7f5d3a1c2b4e6f8a0d size: 527You should seeECR → quickcart-orders lists two images,
v1andv2, each with a different digest.Give Jenkins your AWS login
Jenkins on your computer runs as you, so it can use your SSO login. Nothing secret is stored in Jenkins, and the login expires on its own.
Stop Jenkins in its terminal with Ctrl+C. Then sign in:
Terminalaws sso login --profile quickcartPart What it means aws sso loginopen the browser to sign in, and cache short-lived credentials --profile quickcartthe profile you set up in Set up your computer Start Jenkins again, telling it which profile to use:
TerminalAWS_PROFILE=quickcart jenkins-lts --httpListenAddress=127.0.0.1 --httpPort=8080Part What it means AWS_PROFILE=quickcartset this variable for Jenkins only. Every build step Jenkins runs inherits it, so the AWS CLI uses your profile jenkins-ltsstart Jenkins, as before --httpListenAddress=127.0.0.1 --httpPort=8080only reachable from this computer, on port 8080 You should seeJenkins is fully up and runningCreate the delivery pipeline
In Jenkins, choose New Item:
Field Enter Enter an item name quickcart-deliverType Pipeline Choose OK. Under Pipeline, leave Definition as Pipeline script, and paste this into Script. Replace the account number in
REPOwith yours:Jenkinsfilepipeline { agent any parameters { string(name: 'TAG', defaultValue: 'v1', description: 'Which image tag to deliver') } options { timeout(time: 30, unit: 'MINUTES') disableConcurrentBuilds() timestamps() } environment { AWS_REGION = 'us-east-1' AWS_PAGER = '' REPO = '111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders' } stages { stage('Find the image') { steps { script { env.DIGEST = sh( script: 'aws ecr describe-images --repository-name quickcart-orders --image-ids imageTag="$TAG" --query "imageDetails[0].imageDigest" --output text', returnStdout: true ).trim() env.IMAGE = "${env.REPO}@${env.DIGEST}" } echo "Delivering ${params.TAG} as ${env.IMAGE}" } } stage('Deploy to dev') { steps { script { deploy('dev') } } } stage('Smoke test dev') { steps { script { smokeTest('dev') } } } stage('Deploy to stg') { steps { script { deploy('stg') } } } stage('Smoke test stg') { steps { script { smokeTest('stg') } } } stage('Approve prod') { options { timeout(time: 15, unit: 'MINUTES') } steps { input message: "Deliver ${params.TAG} to prod?", ok: 'Deploy to prod' } } stage('Deploy to prod') { steps { script { deploy('prod') } } } stage('Smoke test prod') { steps { script { smokeTest('prod') } } } } post { success { echo "${params.TAG} is running in dev, stg and prod as ${env.IMAGE}" } aborted { echo 'Stopped before prod. Prod is unchanged.' } failure { echo 'A step failed. Environments that were already updated keep the new version.' } } } def deploy(String target) { retry(3) { sh "aws ssm put-parameter --name /quickcart/${target}/image --type String --value \"\$IMAGE\" --overwrite" } } def smokeTest(String target) { def running = sh( script: "aws ssm get-parameter --name /quickcart/${target}/image --query Parameter.Value --output text", returnStdout: true ).trim() if (running != env.IMAGE) { error("${target} runs ${running}, but this build delivered ${env.IMAGE}") } sh 'aws ecr describe-images --repository-name quickcart-orders --image-ids imageDigest="$DIGEST" --query "imageDetails[0].imageTags" --output text' }Line What it does If you leave it out parameters→string(name: 'TAG', …)Asks which tag to deliver when you start the build. Jenkins also sets it as the environment variable TAGYou’d edit the pipeline for every release timeout(time: 30, unit: 'MINUTES')Ends the whole build if it runs longer than 30 minutes A hung call could block releases indefinitely disableConcurrentBuilds()Only one delivery at a time Two releases could race to set the same environment AWS_PAGER = ''Stops the AWS CLI opening a pager, which would hang a build waiting for a key press Some commands hang in Jenkins REPOYour repository’s address, used to build the full image name Required sh(script: …, returnStdout: true).trim()Runs a command and keeps what it prints, without the final newline You couldn’t use the digest later imageTag="$TAG"in single quotesThe shell reads $TAG, not Groovy. A tag with odd characters can’t break out into other commandsWith double quotes, Groovy would paste the value straight into the command env.IMAGE = "${env.REPO}@${env.DIGEST}"The image by digest: …/quickcart-orders@sha256:…. Every later stage uses only thisStages might resolve the tag again and get a different image deploy('dev')insidescriptCalls the deployhelper defined at the bottom of the fileDeclarative stages only allow plain steps outside a scriptblockoptions { timeout(time: 15 …) }on the approval stageIf nobody approves within 15 minutes, the build is aborted It would wait until someone noticed input message: …, ok: 'Deploy to prod'Pauses and shows two buttons: Deploy to prod and Abort Nothing would stop before prod post→success,aborted,failureA clear last line for each outcome You’d have to read the whole log to see what happened def deploy(String target)Sets the environment’s record to the digest. --overwritemakes it idempotent: running it twice gives the same result— retry(3)Tries the deploy up to three times if the AWS call fails briefly One network hiccup fails the release def smokeTest(String target)Reads the record back and fails the build unless it holds the digest this build delivered, then confirms the image really exists in ECR A deploy that silently didn’t happen would look like success Choose Save.
Deliver v1 everywhere
Choose Build with Parameters (or Build Now the first time), with TAG
v1. Open the build, then Console Output. It stops at the approval:The console shows, near the endDeliver v1 to prod? Deploy to prod or AbortChoose Deploy to prod. The build finishes:
The end of the console outputv1 is running in dev, stg and prod as 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders@sha256:8d2f6a4b1c0e9f7d5a3b2c1e0f9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d Finished: SUCCESSYour account number and digest will differ. The digest is the same one ECR shows for
v1.You should seeSystems Manager → Parameter Store lists
/quickcart/dev/image,/quickcart/stg/imageand/quickcart/prod/image, all with the same value.Deliver v2, and try not answering
Build again with TAG
v2. At the approval, this time don’t answer. After 15 minutes the stage’s timeout ends it:The end of the console outputCancelling nested steps due to timeout Timeout has been exceeded Stopped before prod. Prod is unchanged. Finished: ABORTEDYou should seeIn Parameter Store,
/quickcart/dev/imageand/quickcart/stg/imagenow hold thev2digest./quickcart/prod/imagestill holdsv1. The unanswered approval changed nothing in prod.If you don’t want to wait 15 minutes, choose Abort at the approval instead. The result in prod is the same.
Deliver v2 to prod
Build once more with TAG
v2, and this time choose Deploy to prod.The end of the console outputv2 is running in dev, stg and prod as 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders@sha256:3f5a1e0c9b7d4a2e8f6c1b0d9e7a5c3b1f2e4d6a8c0b9e7f5d3a1c2b4e6f8a0d Finished: SUCCESSOpen
/quickcart/prod/imagein Parameter Store, then its History tab. Parameter Store keeps every value it has held:v1’s digest as version 1,v2’s as version 2. The rollback job uses that history.Create the one-click rollback
Choose New Item, name
quickcart-rollback, type Pipeline, and paste:Jenkinsfilepipeline { agent any parameters { choice(name: 'TARGET', choices: ['prod', 'stg', 'dev'], description: 'Which environment to roll back') } options { timeout(time: 10, unit: 'MINUTES') disableConcurrentBuilds() } environment { AWS_REGION = 'us-east-1' AWS_PAGER = '' } stages { stage('Find the previous digest') { steps { script { def name = "/quickcart/${params.TARGET}/image" def version = sh( script: "aws ssm get-parameter --name ${name} --query Parameter.Version --output text", returnStdout: true ).trim() as Integer def current = sh( script: "aws ssm get-parameter --name ${name} --query Parameter.Value --output text", returnStdout: true ).trim() def previous = '' for (int v = version - 1; v >= 1 && previous == ''; v--) { def value = sh( script: "aws ssm get-parameter --name ${name}:${v} --query Parameter.Value --output text", returnStdout: true ).trim() if (value != current) { previous = value } } if (previous == '') { error("${params.TARGET} has never run a different digest, so there is nothing to roll back to") } env.PREVIOUS = previous echo "${params.TARGET} runs ${current}" echo "Rolling back to ${previous}" } } } stage('Redeploy it') { steps { sh "aws ssm put-parameter --name /quickcart/${params.TARGET}/image --type String --value \"\$PREVIOUS\" --overwrite" } } } post { success { echo "${params.TARGET} is back on ${env.PREVIOUS}" } } }Line What it does If you leave it out choice(name: 'TARGET', …)A drop-down of environments, with prodfirst because that’s the usual emergencySomeone would have to type the name under pressure get-parameter … --query Parameter.VersionHow many values the record has held — get-parameter --name ${name}:${v}Reads the value the record held at version v. The:numbersuffix selects a past versionYou could only ever read the current value The forloopWalks back through history until it finds a digest different from the current one Rolling back twice would bounce between the good and bad versions, because a rollback is itself a new version error(…)when nothing is foundStops with a clear message instead of “deploying” the same digest A rollback that did nothing would look like success Redeploy itThe same idempotent update as a normal deploy. Nothing is built — Choose Save, then Build Now once so Jenkins learns the
TARGETparameter. That first run rolls prod back.The end of the console outputprod is back on 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders@sha256:8d2f6a4b1c0e9f7d5a3b2c1e0f9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d Finished: SUCCESSYou should see/quickcart/prod/imageholdsv1’s digest again, while dev and stg keepv2. The rollback took seconds, and nothing was rebuilt.Clean-up step: Clean up
Delete the three parameters:
Terminalaws ssm delete-parameters --names /quickcart/dev/image /quickcart/stg/image /quickcart/prod/image --profile quickcartPart What it means aws ssm delete-parametersdelete several Parameter Store parameters at once --names /quickcart/dev/image /quickcart/stg/image /quickcart/prod/imagethe three environment records --profile quickcartusing your SSO login You should see{ "DeletedParameters": [ "/quickcart/dev/image", "/quickcart/stg/image", "/quickcart/prod/image" ], "InvalidParameters": [] }The order of the names may differ. Then delete the lab images:
In the AWS console: ECRPrivate registryRepositoriesquickcart-orders
Tick
v1andv2, choose Delete, and confirm. If you don’t need the repository any more, go back to Repositories, tickquickcart-ordersand choose Delete.In Jenkins, open each of
quickcart-deliverandquickcart-rollbackand choose Delete Pipeline. Keep Jenkins itself: the next lesson uses it.You should seeParameter Store has no
/quickcart/…parameters, and ECR has no lab images. Nothing from this lab is left costing money.
Part 5: Check yourself
Why does a delivery pipeline deploy an image by its digest, not by a tag like v2?
The team rebuilds the image separately for dev, stg and prod "to be safe". What's wrong with that?
The prod approval step has a 15-minute timeout and nobody answers. What happens?
Prod is misbehaving after a release. What is the fastest safe rollback?
How should a Jenkins agent on AWS get permission to deploy?
Part 6: Cheat sheet
| Idea or code | In one line |
|---|---|
| Build once | One image per change. Every environment gets that same image |
| Deploy by digest | repo@sha256:… never changes. A tag can |
| Promotion | dev → smoke test → stg → smoke test → approval → prod → smoke test |
input message: '…', ok: '…' | Pause for a person. Add submitter to limit who can approve |
timeout around input | No answer means aborted, never approved |
| Smoke test | A few quick checks that the release really runs where it was deployed |
| Rollback | Deploy the previous digest. No rebuild |
retry(3) | Only around steps that are safe to repeat |
| Idempotent | “Set to X” can run twice safely. “Add one more” can’t |
| AWS credentials | An instance profile, or withAWS(role: …) per environment. Never stored keys |