# Same tag, no deploy: why `cdk deploy` with an unchanged image does nothing, and the 300-line script we wrote around it

Two stories about the same deploy pipeline, told together because the second exists because of the first.

The first is a bug that isn't a bug: we rebuilt a container image, pushed it under the same tag, ran `cdk deploy`, watched it report *no changes*, and spent an hour understanding why production was still running the old code. The second is what happened when our CI was unavailable for a stretch, for billing reasons outside our control, and we needed a way to deploy from a laptop that was safer than the way [we'd once deleted three services from a laptop](/en/blog/cloudformation-deleted-our-app-runner-services).

## Part one: CloudFormation compares strings, not bytes

The App Runner service is a CloudFormation resource, and the image it runs is a string property: `ImageIdentifier: 123456.dkr.ecr.us-east-1.amazonaws.com/api:abc1234`. When you run `cdk deploy`, CloudFormation diffs the new template against the last one it applied. If the string is identical, there's no diff, no update, and App Runner keeps serving whatever it pulled last time.

That's correct behaviour, and it bites in exactly one situation: the tag is the same but the image behind it isn't. We hit it when we rebuilt the same commit with a changed build argument. Same git SHA, same tag `api:abc1234`, different bytes in ECR, and CloudFormation, correctly, saw nothing to do. The registry's tag now pointed at a new digest; the service was still running the old one; nothing anywhere was wrong except our expectation.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two rows. Top row, pin by tag: ECR tag api:abc1234 is moved from digest sha256:1111 to sha256:2222 by a rebuild; the template still says api:abc1234, CloudFormation sees no diff, App Runner keeps running sha256:1111. Bottom row, pin by digest: the deploy script resolves the tag to sha256:2222 and puts the digest in the template; CloudFormation sees a diff and App Runner rolls out sha256:2222.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="26" fill="#ff6b8a" font-size="14" font-weight="700">pin by tag</text>
<rect x="20" y="40" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="120" y="62" text-anchor="middle" fill="#f1f3ff">ECR tag api:abc1234</text><text x="120" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">sha256:1111 → rebuilt → sha256:2222</text>
<rect x="260" y="40" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="360" y="62" text-anchor="middle" fill="#f1f3ff">template</text><text x="360" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">ImageIdentifier: …/api:abc1234</text>
<rect x="500" y="40" width="170" height="56" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="585" y="62" text-anchor="middle" fill="#ff6b8a" font-weight="700">no diff</text><text x="585" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">same string as last time</text>
<rect x="710" y="40" width="175" height="56" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="797" y="62" text-anchor="middle" fill="#f1f3ff">App Runner</text><text x="797" y="82" text-anchor="middle" fill="#ff6b8a" font-size="11">still running sha256:1111</text>
<text x="20" y="136" fill="#4fffb0" font-size="14" font-weight="700">pin by digest</text>
<rect x="20" y="150" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="120" y="172" text-anchor="middle" fill="#f1f3ff">deploy script</text><text x="120" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">resolves tag → sha256:2222</text>
<rect x="260" y="150" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="360" y="172" text-anchor="middle" fill="#f1f3ff">template</text><text x="360" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">ImageIdentifier: …/api@sha256:2222</text>
<rect x="500" y="150" width="170" height="56" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="585" y="172" text-anchor="middle" fill="#4fffb0" font-weight="700">diff</text><text x="585" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">digest changed</text>
<rect x="710" y="150" width="175" height="56" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="797" y="172" text-anchor="middle" fill="#f1f3ff">App Runner</text><text x="797" y="192" text-anchor="middle" fill="#4fffb0" font-size="11">rolls out sha256:2222</text>
</g>
</svg>
</div>

There are two honest fixes. The first is a rule: every deploy moves the SHA. If you want a different image, commit something. It's simple and it's what we did for a while, and it fails the moment someone needs to rebuild an image for a reason that doesn't involve a code change, like a base image security patch.

The second is to pin by digest. After the image is pushed, the deploy script asks ECR what the tag resolves to and passes the digest, not the tag, into the template:

```bash
digest=$(aws ecr describe-images --repository-name api --image-ids imageTag="$TAG" \
  --query 'imageDetails[0].imageDigest' --output text)
npx cdk deploy platform-prod --context apiImage="$REPO/api@$digest"
```

```ts
// in the stack
imageRepository: { imageIdentifier: this.node.tryGetContext('apiImage'), imageRepositoryType: 'ECR' }
```

Now the template changes whenever the bytes change, and only then. A rebuild of the same commit with different contents is a real deploy. A re-run with identical contents is a genuine no-op. CloudFormation's behaviour didn't change; we just gave it the string that actually means "the image".

The digest shows up in the CloudFormation console and in `cdk diff` as an unreadable 64-character hash. We print `tag → digest` in the deploy output so a human can still tell what's going out.

## Part two: deploying from a laptop, safely

CI went away for a while. The platform still needed deploys. We'd already learned, expensively, that `cdk deploy` from a developer machine with the wrong context deletes production resources, so "just run it locally" was not an option we'd accept again. The alternative was a script whose job is to make a laptop deploy behave like a CI deploy: same inputs, same guardrails, no way to ship what isn't in git.

It grew to about 300 lines of bash. Here is what those lines do, in order.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Pipeline of the deploy script in eight steps: 1 preflight refuses a dirty tree, wrong branch or missing --yes; 2 a git worktree is checked out at origin/main; 3 git archive of that tree is uploaded to S3; 4 three CodeBuild jobs build the images in parallel from the archive; 5 tags are resolved to digests; 6 cdk diff is shown and must be confirmed; 7 cdk deploy runs with the digests as context; 8 post-deploy verification of health, release SHA and client bundle, then a message to the team channel.">
<defs><marker id="arrDs" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="11">
<rect x="15" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="115" y="52" text-anchor="middle" fill="#ff6b8a" font-weight="700">1 · preflight</text><text x="115" y="70" text-anchor="middle" fill="#9aa3c7">dirty tree? wrong branch? no --yes? → exit</text>
<line x1="217" y1="60" x2="240" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="243" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="343" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">2 · worktree @ origin/main</text><text x="343" y="70" text-anchor="middle" fill="#9aa3c7">never the working copy</text>
<line x1="445" y1="60" x2="468" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="471" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="571" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">3 · git archive → S3</text><text x="571" y="70" text-anchor="middle" fill="#9aa3c7">the exact bytes CodeBuild sees</text>
<line x1="673" y1="60" x2="696" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="699" y="30" width="185" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="791" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">4 · 3 × CodeBuild</text><text x="791" y="70" text-anchor="middle" fill="#9aa3c7">in parallel · ~3 min</text>
<path d="M791,92 L791,120 L115,120 L115,148" fill="none" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="15" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="115" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">5 · tags → digests</text><text x="115" y="190" text-anchor="middle" fill="#9aa3c7">printed for the human</text>
<line x1="217" y1="180" x2="240" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="243" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="343" y="172" text-anchor="middle" fill="#ffd166" font-weight="700">6 · cdk diff, confirm</text><text x="343" y="190" text-anchor="middle" fill="#9aa3c7">any delete → stop and read</text>
<line x1="445" y1="180" x2="468" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="471" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="571" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">7 · cdk deploy</text><text x="571" y="190" text-anchor="middle" fill="#9aa3c7">digests as context</text>
<line x1="673" y1="180" x2="696" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="699" y="150" width="185" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="791" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">8 · verify + notify</text><text x="791" y="190" text-anchor="middle" fill="#9aa3c7">health · SHA · bundle grep</text>
<text x="450" y="238" text-anchor="middle" fill="#9aa3c7">A laptop deploy and a CI deploy run the same steps on the same bytes. The laptop just has a person at step 6.</text>
</g>
</svg>
</div>

**Preflight.** The script refuses to run if the working tree has uncommitted changes, if `--env prod` is given without `--yes`, if the current branch isn't `main` for a prod deploy, or if the AWS identity it's about to use isn't the expected deploy role for that account. Each refusal prints one sentence saying what to do. About 40 lines, and the most valuable 40 in the file.

**Worktree.** It creates a detached `git worktree` at `origin/main` (after a `git fetch`) in a temporary directory and does everything from there. Whatever is in the developer's working copy, stashed, half-edited or experimental, cannot ship. The SHA of that worktree is the release identifier for everything that follows.

**Archive to S3.** `git archive` of the worktree, uploaded to a build bucket under the SHA. CodeBuild builds from that archive, not from a GitHub connection, so the build input is the same bytes whether triggered by CI or by the script.

**Three builds in parallel.** One CodeBuild project per application, started together, polled together. Building three Next.js images on a laptop's Docker would take eight minutes and heat the room; on three CodeBuild instances it takes three. The projects are the same ones CI uses.

**Digests.** As in part one. The script resolves every tag and prints a table of `app → tag → digest`.

**Diff and confirm.** `cdk diff` against the target environment, printed in full. If the diff contains any resource removal, the script highlights it and requires the operator to type the stack name to continue, not just `y`. This is the step that would have prevented the incident.

**Deploy.** `cdk deploy` with the digests as context and `--require-approval never`, because approval already happened in the previous step with better information than CDK's own prompt gives.

**Verify and notify.** Hit the health route until it reports the new SHA, [grep the client bundle for the same SHA](/en/blog/nextjs-build-time-vs-runtime-config-on-containers), and post a message to the team channel with the environment, the SHA, the operator's name and the diff summary. If verification fails, the script says so loudly and exits non-zero; it doesn't roll back automatically, because a failed verification usually means "look at it", not "undo it".

## What it looks like to use

```bash
./scripts/deploy.sh --env staging
./scripts/deploy.sh --env prod --yes
./scripts/deploy.sh --env prod --dry-run     # synth + diff, no build, no deploy
```

Three flags. Everything else is decided by the script from git and from the account. There is no way to pass a custom image, a custom branch or a custom context value, on purpose.

## Would we write it again?

Yes, and we'd write it on day one rather than after the incident and the CI outage. Not because laptop deploys are good, but because *the script is the pipeline*. When CI came back, its workflow became: check out, assume the OIDC role, run `./scripts/deploy.sh --env prod --yes`. One code path, exercised from both places, with the guardrails in the code rather than in a YAML file that only runs on someone else's machine.

The 300 lines are long for a shell script. They replaced a page of runbook that said "be careful", which is shorter and doesn't work.

If your deploy is a `cdk deploy` that someone runs from wherever they happen to be, [we can help make it boring](/contact).
