Graceful Go Shutdown on ECS Fargate
Build a Go service that drains requests safely on Amazon ECS Fargate.
Introduction
30 Second Summary
Some online tasks take several seconds to finish. A restart at the wrong moment can erase that work before the user gets a result.
In this project, you will build ship, a Go HTTP service that lets a ten-second request finish during a graceful shutdown. Your private release proves the same OCI image stops correctly in local Docker plus Amazon ECS on AWS Fargate.
What You'll Build
Picture ship receiving a stop signal during a ten-second request while done still arrives before its JSON logs record draining, work, and stopped.
By the end of this project, you'll have:
- A local stop comparison that preserves done under 15 seconds of grace but loses it under two seconds with exit code 137.
- A measured image report that compares ship:v1 with ship:single before confirming the final image passes its vulnerability gate.
- A private release record that connects reviewed design evidence with Amazon ECS shutdown logs through a merged GitHub pull request and the v1.0 tag.
- Secret Mission: Prove a rebalanced timeout contract with DRAIN_MS=14000 and GRACE=18 on a new private branch.
Are there any prerequisites?
You need prior container experience plus Git experience. You also need active GitHub and AWS accounts.
Your AWS account needs default subnets in us-east-1 plus permission to create IAM, Amazon ECR, Amazon ECS, and Amazon CloudWatch Logs resources.
Before We Start
A graceful shutdown test is only trustworthy when the toolchain and cloud identity are known before the timed build begins. A stale image cache or the wrong AWS account can corrupt the evidence you collect later.
Interrupted work can discard a user's result after they have waited for it. A bounded shutdown gives operators a predictable window for finishing that work before the process stops.
In this step, get ready to:
- Verify the local development toolchain and authenticated accounts.
- Confirm the AWS identity and network prerequisites for the deployment.
- Warm the Linux images and Trivy vulnerability database.
Verify the local toolchain
The project depends on exact tool versions because version drift can change commands or generated files. You will check each tool before creating the project workspace.
- Press the Windows key to open Windows search.
- Type Cursor into the search field.
- Press Enter to open Cursor.
You should see Cursor open to its welcome screen or your most recent editor window.
- Confirm Git by running this version check in the Cursor terminal:
git --version
What does this check do?
This reads the installed Git version. The result must include 2.56.0.
- Confirm that Git has an effective author email by running:
git config user.email
Why Check the Git Email?
Git attaches this email to the commits you create. You should see your configured email without changing it during this project.
- Check the authenticated GitHub CLI account by running:
gh auth status
What Does Authentication Prove?
This displays the active GitHub account and its authentication state. The private repository steps depend on this session.
- Confirm that the Docker Desktop client can reach its engine by running:
docker version
What Does Docker Report?
This reports details for the Docker client and engine. Seeing both sections proves Docker Desktop is running.
- Check the installed Go release by running:
go version
Why Check Go?
This project uses Go 1.27 to compile the service. The output must report Go 1.27 or a later patch release in that series.
- Check the installed OpenTofu release by running:
tofu version
Why Pin OpenTofu?
The infrastructure configuration requires OpenTofu 1.13.2. An exact version keeps initialization and resource planning reproducible.
- Check the installed AWS CLI release by running:
aws --version
What Does the AWS Version Show?
This prints the installed AWS CLI release. The result must identify major version 2.
- Check the installed Trivy release by running:
trivy --version
Why Check Trivy?
Trivy scans the final container image for high-severity vulnerabilities. The result must report 0.76.1 or later.
Choose the tab that matches the results from all eight checks.
✔️ Every check meets the requirement
Good work. Your editor and command-line toolchain are ready for the timed build.
- Confirm that Git reports 2.56.0.
- Confirm that Go reports 1.27.
- Confirm that OpenTofu reports 1.13.2.
- Confirm that AWS CLI reports major version 2.
- Confirm that Trivy reports 0.76.1 or later.
ⓧ I see an older version
An older pinned tool can produce different behavior during the build. Update only the tools whose checks fall below the project requirement.
- Update Git from the official Git installation page.
- Update Go from the official Go downloads page.
- Update OpenTofu from the official OpenTofu installation page.
- Update AWS CLI from the official AWS CLI page.
- Update Trivy from the official Trivy installation page.
- Close the Cursor terminal after each update.
- Open a new Cursor terminal after each update.
- Repeat the affected version check in the new terminal.
ⓧ A command is not found
A missing command means the tool is absent or unavailable in the new terminal. Install each missing tool from its official page.
- Install Git from the official Git installation page.
- Install GitHub CLI from the official GitHub CLI page.
- Install Docker Desktop from the official Docker installation page.
- Install Go from the official Go downloads page.
- Install OpenTofu from the official OpenTofu installation page.
- Install AWS CLI from the official AWS CLI page.
- Install Trivy from the official Trivy installation page.
- Open a new Cursor terminal after the installations finish.
- Repeat every tool check in the new terminal.
Still Missing a Tool?
- Start Docker Desktop through Windows search if the Docker check shows only client information.
- Complete GitHub authentication from the official GitHub CLI guidance if the account status check fails.
- Restore Cursor from your existing setup if Windows search cannot find it.
Use this prompt for help with a tool that still fails: Help me diagnose a missing or outdated Day Zero tool on Windows.
Confirm the AWS prerequisites
The next commands configure a default AWS Region and read existing account details. They create no billable resources.
- Set the default AWS Region to us-east-1 by running:
aws configure set region us-east-1
Why Set the Region Now?
This saves us-east-1 as the default Region for later AWS CLI commands. It reduces the risk of creating resources in the wrong Region.
- Read the active AWS identity by running:
aws sts get-caller-identity
What Does the Identity Check Prove?
This returns the active user or role identity. It confirms which AWS account receives the deployment later.
Account identifiers are private operational details. Never paste this command's output into the project files or a public message.
- Confirm that the returned identity is the AWS identity you intend to use.
- Check your AWS access agreement for permission to create IAM resources.
- Check your AWS access agreement for permission to create Amazon ECR resources.
- Check your AWS access agreement for permission to create Amazon ECS resources.
- Check your AWS access agreement for permission to create Amazon CloudWatch Logs resources.
- Confirm that default subnets exist in us-east-1 by running:
aws ec2 describe-subnets --filters "Name=default-for-az,Values=true" --query 'Subnets[].SubnetId' --region us-east-1
Why Check Default Subnets?
The Fargate service later selects subnets that are default for their Availability Zones. This check should return one or more subnet IDs.
You should see a list containing at least one subnet ID. An empty list means the required default network is unavailable in us-east-1.
No Default Subnets Returned?
Stop here if the subnet list is empty. The deployment requires default subnets in the selected Region.
Use this prompt to review the account prerequisite: Help me understand why my default subnet check returned an empty list.
Warm the images and vulnerability database
A cached container Image keeps network downloads outside the timed build. Both base images must target Linux on the x86-64 architecture used by the Fargate task.
The first pull can take a few minutes while Docker downloads image layers. A quiet terminal during part of that download does not mean the command has failed.
- Warm the Go builder image for Linux x86 by running:
docker pull --platform linux/amd64 golang:1.27.2-alpine
What Does This Pull Prepare?
This downloads the exact Go 1.27.2 Alpine builder image for the deployment architecture. Docker reuses its cached layers during the image build.
You should see the pull finish for linux/amd64.
- Warm the nonroot distroless runtime image for Linux x86 by running:
docker pull --platform linux/amd64 gcr.io/distroless/static-debian13:nonroot
What Does the Runtime Image Provide?
The distroless runtime contains the files needed to run the compiled service without a shell or package manager. The nonroot variant runs the service without root privileges.
You should see the second pull finish for linux/amd64.
Image Pull Failed?
- Confirm that Docker Desktop is still running.
- Check that your network permits access to both image registries.
- Repeat only the pull command that failed.
Use this prompt for environment-specific help: Help me troubleshoot a Linux amd64 base image pull on Docker Desktop.
Before you inspect the images, what kind of identifier do you expect a repository digest to contain?
- Inspect the Go builder image's repository digest by running:
docker image inspect --format '{{index .RepoDigests 0}}' golang:1.27.2-alpine
What Is a Repository Digest?
An OCI digest identifies immutable image content. A tag can move to newer content while a digest continues to identify the same content.
You should see a repository reference containing @sha256:.
- Record the full builder repository reference here: golang:1.27.2-alpine@sha256:your-digest.
- Inspect the distroless runtime image's repository digest by running:
docker image inspect --format '{{index .RepoDigests 0}}' gcr.io/distroless/static-debian13:nonroot
Why Record the Runtime Digest?
The runtime image later uses a digest pin in the multi-stage Dockerfile. Recording the local result connects the warmed image to that immutable reference.
You should see a second repository reference containing @sha256:.
- Record the full runtime repository reference here: gcr.io/distroless/static-debian13@sha256:your-digest.
- Download the Trivy vulnerability database by running:
trivy image --download-db-only
Why Warm the Vulnerability Database?
Trivy uses this database to match image packages against known vulnerabilities. Downloading it now keeps that network work outside the image scan later.
You should see Trivy report that the vulnerability database was downloaded.
Why Bound the Shutdown?
A graceful stop begins with SIGTERM. The service uses that notice to reject new work while an existing request finishes.
The grace period places a firm limit on that drain. Work that exceeds the limit can receive SIGKILL and lose its result.
Your Day Zero environment is ready. Next, you will create a clean workspace and connect it to a private GitHub repository.
Create the Workspace and Private Repository
A stale workspace can mix old files into the shutdown build. That makes later evidence hard to trust.
A wrong AWS CLI identity can place resources in the wrong account. A public GitHub remote can expose work meant to stay private.
Cursor opens one clean local folder as your editor workspace. Git records the project history from that same path.
In this step, get ready to:
- Prepare a clean ~/stop-clean workspace in Cursor.
- Confirm the AWS identity for us-east-1.
- Connect the initialized Git workspace to a private GitHub repository.
Create the clean workspace
Every later command depends on the same folder path. Removing an older copy prevents stale files from changing your results.
Deleting the old folder is deliberate. The command targets only ~/stop-clean in your home directory.
- Press the Windows key to open system search.
- Type PowerShell or Bash.
- Press Enter to open your chosen shell.
Bash
- Move to your home directory by running this command:
cd ~
Why Start From Home?
This moves the shell to your home directory. The fixed starting point places stop-clean at the expected path.
- Remove any previous workspace by running this command:
rm -rf ~/stop-clean
What Does This Command Do?
This removes only the existing ~/stop-clean folder. The shell returns to the prompt without an error when the path is clear.
- Create the clean workspace by running this command:
mkdir stop-clean
What Did You Create?
This creates an empty stop-clean folder under your home directory. The folder becomes the boundary for every project file.
- Enter the new workspace by running this command:
cd ~/stop-clean
Why Enter the Folder?
This makes ~/stop-clean the active directory. Every following project command now targets the clean workspace.
PowerShell
- Move to your home directory by running this command:
cd ~
Why Start From Home?
This moves the shell to your home directory. The fixed starting point places stop-clean at the expected path.
- Remove any previous workspace by running this command:
Remove-Item -Recurse -Force ~/stop-clean -ErrorAction SilentlyContinue
What Does This Command Do?
This removes only the existing ~/stop-clean folder. The command also handles an already-missing folder without interrupting your setup.
- Create the clean workspace by running this command:
mkdir stop-clean
What Did You Create?
This creates an empty stop-clean folder under your home directory. The folder becomes the boundary for every project file.
- Enter the new workspace by running this command:
cd ~/stop-clean
Why Enter the Folder?
This makes ~/stop-clean the active directory. Every following project command now targets the clean workspace.
- Press the Windows key to open system search.
- Type Cursor.
- Press Enter to launch Cursor.
- Locate the stop-clean folder in your home directory.
- Drag the stop-clean folder onto the Cursor window.
You will see stop-clean as the current folder in Cursor. Your clean workspace is now ready for the project history.
Keep Every Command in Scope
A new terminal may start in your home directory. Return to ~/stop-clean before running another project command.
Workspace Not Opening?
Confirm that the folder exists directly under your home directory. Check that its name is exactly stop-clean.
Ask for help with the workspace commands.
Confirm the AWS identity
The AWS region controls where later cloud resources are created. The identity check catches a wrong account before any resource exists.
- Set the AWS CLI default region by running this command from ~/stop-clean:
aws configure set region us-east-1
What Does This Setting Control?
This sets us-east-1 as the default AWS CLI region. Later commands use that region unless a command provides another one.
The next check reveals account details so you can catch a wrong identity before deployment. Those details stay outside your project files.
- Display the active AWS identity by running this command:
aws sts get-caller-identity
What Should You Check?
The response contains UserId, Account, and Arn. These fields identify the credentials that later AWS commands use.
- Confirm that the response identifies the AWS account you intend to use.
- Keep the returned account details out of every project file.
- Avoid including the identity response in screenshots.
That account check is complete. Your later infrastructure commands now have a confirmed identity in the required region.
Identity Check Not Returning?
Confirm that your network connection is active. Repeat the Day Zero AWS CLI authentication check if the current credentials are unavailable.
Ask for help diagnosing the AWS identity check.
Create the private repository
The local Git repository records your design history from a known branch. The private GitHub repository gives that history a controlled remote destination.
- Initialize the workspace on the main branch by running this command:
git init -b main
What Did Git Initialize?
This creates Git metadata inside ~/stop-clean. It also sets main as the initial branch.
You will see confirmation that an empty Git repository was initialized. The clean workspace can now record the design baseline in the next step.
Repository privacy is the delivery boundary for this project. The next command creates stop-clean as a private remote from your current local repository.
- Create the private GitHub repository by running this command:
gh repo create stop-clean --private --source=.
What Does This Command Create?
This creates the GitHub repository named stop-clean with private visibility. It connects the current local repository to the new remote.
Before you run the final check, predict which repository name and visibility value should appear.
- Verify the repository name and visibility by running this command:
gh repo view --json name,visibility
What Does the Check Prove?
This requests only the repository name and visibility fields. It proves that the local workspace points to the intended private delivery target.
You will see the name stop-clean and the visibility PRIVATE. That is the privacy boundary confirmed before any design files are published.
Private Repository Not Confirmed?
Confirm that GitHub CLI still uses the account authenticated during Day Zero. Check the repository page in your own GitHub account if the command cannot find the remote.
Ask for help with the private repository connection.
- Capture only the terminal area that shows the final repository check.
- Keep the earlier AWS identity response outside the screenshot.
Your clean workspace now has a confirmed cloud identity and a private remote. Next up, you will publish the five design documents before writing the service.
Design and Publish the Stop Contract
The private stop-clean workspace is open in Cursor. Its remote points to a private GitHub repository.
Publishing reviewed design evidence before code separates the intended stop contract from implementation accidents. This step defines measurable behavior for Amazon ECS on AWS Fargate before you write the Go service.
In this step, get ready to:
- Define the timeout budget with its acceptance criteria.
- Document the architecture with its implementation plan.
- Publish the reviewed design to the private main branch.
Define the shutdown contract
A stop contract gives each shutdown phase a fixed deadline. Here, a ten-second request gets a two-second application margin before the 15-second outer grace expires.
- In Cursor's file explorer, create the docs folder inside the open stop-clean workspace.
- Confirm the file explorer lists the new docs folder.
- Create build-spec.md inside the docs folder.
- Add the goal with its constraints by pasting this first Markdown block into docs/build-spec.md:
# Build Specification: Drain Before Kill
## Goal
Build one Go service that drains a ten-second request locally and on ECS, then ship the evidence through a private GitHub pull request.
## Constraints
- Core time: 45 minutes, including setup.
- Build time: 23 minutes.
- Day Zero happens the day before.
- Runtime variables: PORT, WORK_MS, DRAIN_MS.
- Selected values: WORK_MS=10000, DRAIN_MS=12000, GRACE=15.
- Ordering: GRACE seconds > DRAIN_MS > WORK_MS.
- Region: us-east-1.
- Runtime: Linux x86 Fargate, 0.25 vCPU, 0.5 GB.
- Repository: private GitHub repository named stop-clean.
- Excluded: load balancer, rolling deployment test, image signing, CI.
- If a check differs, fix its source step and rerun it.
What does this contract define?
- The goal connects local shutdown behavior to the later cloud proof.
- The values WORK_MS=10000, DRAIN_MS=12000, plus GRACE=15 define the timeout budget.
- The ordering leaves two seconds inside the application deadline. It leaves three more seconds inside the outer grace.
- Save docs/build-spec.md.
- View docs/build-spec.md with Cursor's Markdown preview.
You should see the project goal followed by the complete constraints list. The selected timeout values should appear in one constraint.
Seeing broken Markdown?
Check that each constraint begins on its own line with one hyphen. Confirm that the headings begin with the same number of hash characters shown above.
Help me compare my build specification with the required Markdown structure.
- In docs/build-spec.md, find the final constraint that ends with rerun it..
- Add the acceptance criteria with their sources immediately after that line by pasting this block:
## Acceptance Criteria
- AC-1: Missing PORT produces JSON error and exit 2.
- AC-2: /readyz returns ready.
- AC-3: A 15-second stop lets /work print done; exit 0; logs contain draining, work, stopped; docker diff is empty.
- AC-4: ship:v1 is at least 60 percent smaller than ship:single.
- AC-5: Trivy HIGH and CRITICAL gate exits 0.
- AC-6: ECS scale-to-zero stops the captured task with exit 0; CloudWatch contains draining and stopped.
- AC-7: A two-second local stop loses done and exits 137.
- AC-8: The private PR merges, v1.0 is pushed, seven resources are destroyed, no ship cluster remains, and local project images are removed.
## Sources
- https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html
- https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-lifecycle-explanation.html
- https://docs.github.com/en/github-cli/github-cli/quickstart
- https://docs.docker.com/reference/cli/docker/container/stop/
- https://trivy.dev/docs/latest/getting-started/installation/
What do the acceptance criteria prove?
The eight checks cover configuration failure, readiness, graceful completion, forced termination, cloud behavior, delivery, plus cleanup. Each claim now has an observable result.
The source list anchors the stop behavior to official documentation. Later results must match these criteria before the project can ship.
- Save docs/build-spec.md.
- Return to the Markdown preview for docs/build-spec.md.
You should see acceptance criteria from AC-1 through AC-8. You should also see five source links beneath them.
Missing an acceptance criterion?
Compare the criterion labels in your preview with the sequence from AC-1 through AC-8. A skipped number means one line was omitted during the paste.
Help me find the missing or duplicated acceptance criterion.
✔️ Awesome, I've got everything!
Great. Your build specification now fixes the project scope with its measurable stop contract.
ⓧ I'd like to double check the full code
# Build Specification: Drain Before Kill
## Goal
Build one Go service that drains a ten-second request locally and on ECS, then ship the evidence through a private GitHub pull request.
## Constraints
- Core time: 45 minutes, including setup.
- Build time: 23 minutes.
- Day Zero happens the day before.
- Runtime variables: PORT, WORK_MS, DRAIN_MS.
- Selected values: WORK_MS=10000, DRAIN_MS=12000, GRACE=15.
- Ordering: GRACE seconds > DRAIN_MS > WORK_MS.
- Region: us-east-1.
- Runtime: Linux x86 Fargate, 0.25 vCPU, 0.5 GB.
- Repository: private GitHub repository named stop-clean.
- Excluded: load balancer, rolling deployment test, image signing, CI.
- If a check differs, fix its source step and rerun it.
## Acceptance Criteria
- AC-1: Missing PORT produces JSON error and exit 2.
- AC-2: /readyz returns ready.
- AC-3: A 15-second stop lets /work print done; exit 0; logs contain draining, work, stopped; docker diff is empty.
- AC-4: ship:v1 is at least 60 percent smaller than ship:single.
- AC-5: Trivy HIGH and CRITICAL gate exits 0.
- AC-6: ECS scale-to-zero stops the captured task with exit 0; CloudWatch contains draining and stopped.
- AC-7: A two-second local stop loses done and exits 137.
- AC-8: The private PR merges, v1.0 is pushed, seven resources are destroyed, no ship cluster remains, and local project images are removed.
## Sources
- https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html
- https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-lifecycle-explanation.html
- https://docs.github.com/en/github-cli/github-cli/quickstart
- https://docs.docker.com/reference/cli/docker/container/stop/
- https://trivy.dev/docs/latest/getting-started/installation/
The build specification says what must work. Decision records preserve why each major choice exists.
- Create decisions.md inside the existing docs folder.
- Document the three decision records by pasting this block into docs/decisions.md:
# Decision Records
## DR-1: Timeout Budget
Choice: WORK_MS=10000, DRAIN_MS=12000, GRACE=15.
Alternative: ECS default 30-second stop timeout.
Reason: Two seconds for app margin and three seconds for outer margin.
Cost: A hung task may remain for 15 seconds.
Reverse when: Measured work exceeds 12 seconds or the outer margin proves too small.
## DR-2: Runtime Image
Choice: Multi-stage Go build and digest-pinned distroless nonroot runtime.
Alternative: Single-stage Go runtime.
Reason: Remove compiler, source, package manager, and shell from the final image.
Cost: Shell-based production debugging is unavailable.
Reverse when: Approved runtime libraries or tools are required.
## DR-3: Delivery and Runtime
Choice: Private GitHub PR, one ECR image, one Fargate task, no load balancer.
Alternative: Direct push to main, EC2, or an Application Load Balancer.
Reason: Keep review evidence while isolating the graceful-stop lesson.
Cost: No production ingress, redundancy, or rolling deployment proof.
Reverse when: The next project requires user traffic or multi-task availability.
How do decision records help?
Each record captures a choice with its alternative. It then states the reason, cost, plus reversal condition.
The reversal condition keeps each choice open to measured evidence. Future changes can point to a threshold instead of personal preference.
- Save docs/decisions.md.
- View docs/decisions.md with Cursor's Markdown preview.
You should see three decision records. Each record should contain a choice, alternative, reason, cost, plus reversal condition.
Does a decision look incomplete?
Check each record for all five labels. Confirm that the timeout choice contains the same three selected values as docs/build-spec.md.
Help me compare the three decision records for missing fields.
✔️ Awesome, I've got everything!
Your three decisions now explain the timeout budget, runtime image, plus delivery path.
ⓧ I'd like to double check the full code
# Decision Records
## DR-1: Timeout Budget
Choice: WORK_MS=10000, DRAIN_MS=12000, GRACE=15.
Alternative: ECS default 30-second stop timeout.
Reason: Two seconds for app margin and three seconds for outer margin.
Cost: A hung task may remain for 15 seconds.
Reverse when: Measured work exceeds 12 seconds or the outer margin proves too small.
## DR-2: Runtime Image
Choice: Multi-stage Go build and digest-pinned distroless nonroot runtime.
Alternative: Single-stage Go runtime.
Reason: Remove compiler, source, package manager, and shell from the final image.
Cost: Shell-based production debugging is unavailable.
Reverse when: Approved runtime libraries or tools are required.
## DR-3: Delivery and Runtime
Choice: Private GitHub PR, one ECR image, one Fargate task, no load balancer.
Alternative: Direct push to main, EC2, or an Application Load Balancer.
Reason: Keep review evidence while isolating the graceful-stop lesson.
Cost: No production ingress, redundancy, or rolling deployment proof.
Reverse when: The next project requires user traffic or multi-task availability.
Map the delivery path
A topology shows how source, images, tests, cloud runtime, plus review evidence connect. The Mermaid diagram makes that flow visible before implementation begins.
- Create topology.md inside the existing docs folder.
- Add the delivery topology by pasting this block into docs/topology.md:
# Topology
```mermaid
flowchart LR
A["Private GitHub main"] --> B["Private build branch"]
B --> C["main.go"]
C --> D["ship:single"]
C --> E["ship:v1"]
E --> F["Local stop tests"]
E --> G["Trivy scan"]
E --> H["Amazon ECR"]
I["infra/main.tf"] --> H
I --> J["Amazon ECS on AWS Fargate"]
H --> J
J --> K["CloudWatch Logs ship"]
J --> L["SIGTERM then drain"]
B --> M["Private pull request"]
M --> N["Merged main and v1.0"]
```
What does the topology show?
The graph traces the service from the private main branch into build work. It follows the optimized image through local checks, the cloud runtime, plus the pull request.
Quoted labels keep spaces with their intended node text. The left-to-right layout preserves the delivery sequence.
- Save docs/topology.md.
- Review the raw Mermaid block in docs/topology.md.
You should see every node label wrapped in double quotes. The graph should run from private main through the merged v1.0 result.
Does the diagram fail to render?
Check that the opening fence names Mermaid. Confirm that the final fence contains exactly three backticks.
Help me find the syntax problem in my Mermaid topology.
✔️ Awesome, I've got everything!
The topology now connects the private delivery path to the graceful-stop proof.
ⓧ I'd like to double check the full code
# Topology
```mermaid
flowchart LR
A["Private GitHub main"] --> B["Private build branch"]
B --> C["main.go"]
C --> D["ship:single"]
C --> E["ship:v1"]
E --> F["Local stop tests"]
E --> G["Trivy scan"]
E --> H["Amazon ECR"]
I["infra/main.tf"] --> H
I --> J["Amazon ECS on AWS Fargate"]
H --> J
J --> K["CloudWatch Logs ship"]
J --> L["SIGTERM then drain"]
B --> M["Private pull request"]
M --> N["Merged main and v1.0"]
```
The topology establishes the route. The implementation plan turns that route into five ordered build items with a FinOps estimate.
- Create implementation-plan.md inside the existing docs folder.
- Add the five build items with their cost estimate by pasting this block into docs/implementation-plan.md:
# Implementation Plan
## 1. Configure and publish the design
Create the five design files, commit them on main, push the private repository, and branch to build.
## 2. Build the configuration gate
Require PORT, WORK_MS, and DRAIN_MS; log JSON; exit 2 when config is missing.
## 3. Add graceful draining and images
Add readiness, work, signal handling, bounded shutdown, lifecycle logs, and both image forms.
## 4. Validate and deploy
Run local success and must-fail checks, scan the image, create seven resources, push v1, and run one task.
## 5. Stop, ship, and clean up
Capture the running task, scale to zero, prove exit 0 and logs, merge the PR, tag v1.0, destroy seven resources, and remove images.
## FinOps Estimate
| Item | Basis | Estimated USD |
| --- | ---: | ---: |
| Fargate compute | 0.5 hours | 0.0062 |
| Public IPv4 | 0.5 hours | 0.0025 |
| ECR storage | Conservative allowance | 0.0001 |
| Other stated allowances | ECS and low log volume | 0.0000 |
| Total | Before credits and taxes | 0.0088 |
How is the plan ordered?
The five items move from design through configuration, draining, deployment, plus cleanup. That order keeps every later claim tied to an earlier requirement.
The final table estimates the short cloud run before credits or taxes. It makes the cost visible before deployment.
- Save docs/implementation-plan.md.
- Count the numbered build items in the Markdown preview.
- Confirm the FinOps table follows the fifth build item.
You should see exactly five numbered build items. The table should end with a total of 0.0088.
Is the plan count wrong?
Check that every numbered heading starts with two hash characters. Confirm that the FinOps estimate appears after item five.
Help me compare my implementation plan with the required five-item sequence.
✔️ Awesome, I've got everything!
The plan now fixes the build order with its cost estimate.
ⓧ I'd like to double check the full code
# Implementation Plan
## 1. Configure and publish the design
Create the five design files, commit them on main, push the private repository, and branch to build.
## 2. Build the configuration gate
Require PORT, WORK_MS, and DRAIN_MS; log JSON; exit 2 when config is missing.
## 3. Add graceful draining and images
Add readiness, work, signal handling, bounded shutdown, lifecycle logs, and both image forms.
## 4. Validate and deploy
Run local success and must-fail checks, scan the image, create seven resources, push v1, and run one task.
## 5. Stop, ship, and clean up
Capture the running task, scale to zero, prove exit 0 and logs, merge the PR, tag v1.0, destroy seven resources, and remove images.
## FinOps Estimate
| Item | Basis | Estimated USD |
| --- | ---: | ---: |
| Fargate compute | 0.5 hours | 0.0062 |
| Public IPv4 | 0.5 hours | 0.0025 |
| ECR storage | Conservative allowance | 0.0001 |
| Other stated allowances | ECS and low log volume | 0.0000 |
| Total | Before credits and taxes | 0.0088 |
A test plan converts each acceptance criterion into a repeatable check. It records the result that must appear before the project can pass.
- Create test-plan.md inside the existing docs folder.
- Add the eight checks with their evidence rule by pasting this block into docs/test-plan.md:
# Test Plan
| AC | Check | Must-see result |
| --- | --- | --- |
| AC-1 | Run ship:v1 with no env vars. | PORT error and exit 2. |
| AC-2 | Run with all env vars and call /readyz. | ready. |
| AC-3 | Call /work and stop with 15 seconds. | done, exit 0, draining/work/stopped, empty diff. |
| AC-4 | Inspect both image sizes. | v1 at least 60 percent smaller. |
| AC-5 | Run Trivy HIGH and CRITICAL gate. | exit 0. |
| AC-6 | Capture running task, scale service to zero, wait, describe task, tail logs. | exit 0, draining, stopped. |
| AC-7 | Call /work and stop with 2 seconds. | no done, exit 137. This case must fail. |
| AC-8 | Merge, tag, destroy, list clusters, remove images. | private v1.0 exists, 7 destroyed, no ship cluster, images removed. |
Append observed output to check-out.txt. Bash uses tee -a. PowerShell uses Tee-Object -Append. Preserve the exit code before piping commands whose exit code matters.
How does the test plan work?
Each row pairs one acceptance criterion with a check. The last column defines the evidence that proves the check passed.
The final note protects exit-code evidence when output is appended to check-out.txt. That file becomes the later validation record.
- Save docs/test-plan.md.
- View docs/test-plan.md with Cursor's Markdown preview.
You should see eight rows from AC-1 through AC-8. The final row should cover delivery, teardown, plus image removal.
Does the test table look uneven?
Check that every row starts with a vertical bar. Confirm that each row contains the acceptance criterion, check, plus must-see result.
Help me find the malformed row in my test-plan table.
✔️ Awesome, I've got everything!
Your test plan now maps every acceptance criterion to observable evidence.
ⓧ I'd like to double check the full code
# Test Plan
| AC | Check | Must-see result |
| --- | --- | --- |
| AC-1 | Run ship:v1 with no env vars. | PORT error and exit 2. |
| AC-2 | Run with all env vars and call /readyz. | ready. |
| AC-3 | Call /work and stop with 15 seconds. | done, exit 0, draining/work/stopped, empty diff. |
| AC-4 | Inspect both image sizes. | v1 at least 60 percent smaller. |
| AC-5 | Run Trivy HIGH and CRITICAL gate. | exit 0. |
| AC-6 | Capture running task, scale service to zero, wait, describe task, tail logs. | exit 0, draining, stopped. |
| AC-7 | Call /work and stop with 2 seconds. | no done, exit 137. This case must fail. |
| AC-8 | Merge, tag, destroy, list clusters, remove images. | private v1.0 exists, 7 destroyed, no ship cluster, images removed. |
Append observed output to check-out.txt. Bash uses tee -a. PowerShell uses Tee-Object -Append. Preserve the exit code before piping commands whose exit code matters.
Publish the design baseline
A design baseline gains value when Git records it before implementation begins. The private main branch becomes the reviewed source for every later build decision.
- Stage all five design documents by running this command in the existing terminal:
git add -A
What does this command do?
This command stages every new design file in the workspace. The next commit records that exact staged snapshot.
The terminal should return to the prompt without an error. Your five design documents are now staged.
- Record the design baseline by running this command:
git commit -m "Add the design docs"
What does this command do?
This command creates one commit from the staged design files. Its message identifies the snapshot as the design baseline.
You should see a new commit summary. The summary should report the five design files.
Did the commit fail?
A message about missing identity means your Git name or email is unavailable in this workspace. A message about no staged changes means the files were not saved before staging.
Help me diagnose why the design commit did not succeed.
- Publish the design commit to private remote branch main by running this command:
git push -u origin main
What does this command do?
This command pushes the local main branch to the private remote. It also records that remote branch as the default destination for later pushes.
You should see confirmation that the private remote accepted the main branch update. The design commit is now preserved outside your local workspace.
Did the push fail?
Confirm that GitHub CLI remains authenticated. Check that the current workspace still points to the private stop-clean repository.
Help me diagnose why the private main branch did not receive my design commit.
- Create the implementation branch by running this command:
git switch -c build
What does this command do?
This command creates the local build branch from the published design commit. It switches the workspace to that branch immediately.
You should see confirmation that Git changed branches. Future implementation commits now stay separate from the design baseline.
Before you run the final check, which branch do you expect Git to report?
- Verify the current branch by running this command:
git branch --show-current
What does this command check?
This command prints the name of the branch currently checked out in the workspace. It confirms that implementation starts from the intended branch.
The command prints build. That result proves the private main branch holds the design baseline while your local workspace is ready for implementation.
Seeing a different branch?
Confirm that the branch-creation command completed without an error. Run that command again only if build does not already exist.
Help me reach the build branch without losing my design commit.
That is the design boundary secured. Next, you will build the configuration gate against the contract you just published.
Build the Configuration Gate
Your reviewed stop contract is now published on main. Your build branch is ready to turn that contract into executable behavior.
The first runnable Go version deliberately rejects missing deploy-time configuration before it accepts work. Its JSON error makes the startup failure visible to operators.
In this step, get ready to:
- Define the ship module with Go 1.27.
- Add a configuration gate for the three required runtime values.
- Prove the planned AC-1 failure.
Define the Go module
A Go module gives the source code one identity. The go.mod file records that identity with the language version used by this project.
- In Cursor's file sidebar, select the new-file control.
- Name the file go.mod.
- Paste the module declaration below:
module ship
go 1.27
What does this module file do?
- The module ship declaration gives the codebase its module path.
- The go 1.27 declaration records the Go language version expected by this module.
- Save go.mod.
- Confirm go.mod appears in Cursor's file sidebar.
Does the module file look wrong?
Check that the filename is exactly go.mod. Remove any extra file extension added to the name.
Compare both lines with the reference tab below. Help me check why my go.mod file is not being recognized.
✔️ Awesome, I've got everything!
Your module file now identifies this project as ship.
ⓧ I'd like to double check the full code
module ship
go 1.27
Build the configuration reader
The service receives its runtime settings through environment variables. The configuration reader checks each required value before startup can continue.
- In Cursor's file sidebar, select the new-file control.
- Name the file main.go.
- Paste the first section into main.go:
package main
import (
"errors"
"log/slog"
"os"
)
type config struct {
port string
}
func loadConfig() (config, error) {
port := os.Getenv("PORT")
if port == "" {
return config{}, errors.New("missing required env var PORT")
}
workText := os.Getenv("WORK_MS")
if workText == "" {
return config{}, errors.New("missing required env var WORK_MS")
}
drainText := os.Getenv("DRAIN_MS")
if drainText == "" {
return config{}, errors.New("missing required env var DRAIN_MS")
}
return config{port: port}, nil
}
How does the configuration reader work?
- The config type holds the validated port value.
- The loadConfig() function reads the required runtime values from the process environment.
- The checks follow the stop contract's required order. A missing PORT value produces the first error.
- The errors.New() calls give each missing value a specific failure reason.
- Place your cursor after the closing brace of loadConfig().
- Complete startup by pasting this code:
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
_, err := loadConfig()
if err != nil {
logger.Error("configuration error", "error", err)
os.Exit(2)
}
}
How does startup enforce the gate?
- The slog.NewJSONHandler call sends structured log records to standard output.
- The loadConfig() call runs before the service can start accepting work.
- The logger.Error() call records the configuration failure as JSON.
- The os.Exit(2) call gives configuration failures a distinct process status.
- Save main.go.
- Confirm go.mod and main.go appear in Cursor's file sidebar.
Seeing source warnings?
Check that the second section sits below the closing brace of loadConfig(). Confirm that every opening brace has a matching closing brace.
Use the full-file reference below to find missing imports or misplaced lines. Help me compare my main.go file with the required configuration gate.
✔️ Awesome, I've got everything!
Your source file now checks configuration before startup can continue.
ⓧ I'd like to double check the full code
package main
import (
"errors"
"log/slog"
"os"
)
type config struct {
port string
}
func loadConfig() (config, error) {
port := os.Getenv("PORT")
if port == "" {
return config{}, errors.New("missing required env var PORT")
}
workText := os.Getenv("WORK_MS")
if workText == "" {
return config{}, errors.New("missing required env var WORK_MS")
}
drainText := os.Getenv("DRAIN_MS")
if drainText == "" {
return config{}, errors.New("missing required env var DRAIN_MS")
}
return config{port: port}, nil
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
_, err := loadConfig()
if err != nil {
logger.Error("configuration error", "error", err)
os.Exit(2)
}
}
Prove the gate fails safely
AC-1 treats a controlled startup rejection as a successful test. The check proves the service refuses an incomplete runtime environment.
- Check the Go source by running this command in the Cursor terminal:
go vet ./...
What does this check prove?
The go vet command examines the module for suspicious Go code. A silent result means this source passes the check.
Did vet report a problem?
Compare main.go with the full-file reference. Look for a missing quote or brace near the reported line.
Confirm that go.mod and main.go are both saved. Help me diagnose my go vet result.
Before you run the service, do you think it starts or rejects the current environment?
- Test the configuration gate without runtime values by running:
go run .
Why does this failure pass AC-1?
You should see a JSON record containing missing required env var PORT. The command also reports that the program exited with status 2.
The gate stops startup at the first missing requirement. That gives operators one clear configuration problem to fix.
- Confirm the saved process status in PowerShell by running:
$LASTEXITCODE
What should the status show?
PowerShell should print 2. This status distinguishes the configuration failure from a successful exit.
Did the gate behave differently?
Remove any PORT value from the current terminal session before rerunning the test. A populated port moves the gate to the next required value.
Run the status check immediately after the program so another command cannot replace the saved status. Help me troubleshoot why the configuration gate did not produce the AC-1 result.
- Stage the configuration gate by running:
git add -A
What does this command record?
The git add -A command stages go.mod and main.go for the next commit.
- Commit the tested gate by running:
git commit -m "Add a build step"
What should the commit show?
You should see a new commit summary on the build branch. The summary should report the two new Go files.
That is the first acceptance check locked in. Next, you will add graceful draining and build the two container images that carry this gate into the runtime.
Add Draining and Build the Images
Your Go configuration gate now rejects missing runtime values through structured JSON logs. The service still needs to accept requests and finish active work during a stop.
A single-stage Docker image also carries the compiler and source into production. You will add the drain path before comparing that baseline with a smaller nonroot runtime image.
In this step, get ready to:
- Complete the HTTP service with readiness and bounded shutdown behavior.
- Record the timeout contract and build the single-stage baseline image.
- Build the digest-pinned multi-stage image and pass its vulnerability gate.
Finish the Go service
A graceful stop begins when the process receives SIGTERM. The service first turns off readiness before giving active requests a bounded window to finish.
- In main.go, find the current import group and config type shown below:
import (
"errors"
"log/slog"
"os"
)
type config struct {
port string
}
What are you replacing?
The current imports support only the configuration error path. The current config value stores only the listening port.
- Replace that import group and type with the expanded version below:
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"sync/atomic"
"syscall"
"time"
)
type config struct {
port string
work time.Duration
drain time.Duration
}
What does this change prepare?
- The HTTP packages provide the server and its bounded shutdown context.
- The signal packages let the process react to operating system stop requests.
- The atomic readiness value can change safely while the server handles requests.
- The duration fields hold the work delay and drain deadline.
- Find the current loadConfig() function shown below:
func loadConfig() (config, error) {
port := os.Getenv("PORT")
if port == "" {
return config{}, errors.New("missing required env var PORT")
}
workText := os.Getenv("WORK_MS")
if workText == "" {
return config{}, errors.New("missing required env var WORK_MS")
}
drainText := os.Getenv("DRAIN_MS")
if drainText == "" {
return config{}, errors.New("missing required env var DRAIN_MS")
}
return config{port: port}, nil
}
Where does validation stop?
The current function confirms that all three values exist. It returns only the port because it does not parse the two duration values yet.
- Replace the current function with this complete duration validation:
func loadConfig() (config, error) {
port := os.Getenv("PORT")
if port == "" {
return config{}, errors.New("missing required env var PORT")
}
workText := os.Getenv("WORK_MS")
if workText == "" {
return config{}, errors.New("missing required env var WORK_MS")
}
drainText := os.Getenv("DRAIN_MS")
if drainText == "" {
return config{}, errors.New("missing required env var DRAIN_MS")
}
workMS, err := strconv.Atoi(workText)
if err != nil || workMS <= 0 {
return config{}, fmt.Errorf("invalid WORK_MS %q", workText)
}
drainMS, err := strconv.Atoi(drainText)
if err != nil || drainMS <= 0 {
return config{}, fmt.Errorf("invalid DRAIN_MS %q", drainText)
}
return config{port: port, work: time.Duration(workMS) * time.Millisecond, drain: time.Duration(drainMS) * time.Millisecond}, nil
}
How does the configuration gate work?
- The parser converts each duration from text into an integer.
- The positive-value checks reject zero values and negative values.
- The return value converts milliseconds into durations used by the work handler and shutdown deadline.
- Save main.go.
- Check the expanded configuration gate by running this command in the terminal from earlier:
go vet ./...
What does this check prove?
A silent result confirms that the expanded imports and duration validation pass the Go vet checks. Your command prompt returns without a diagnostic.
Seeing a Go diagnostic?
Compare the import names with the replacement block. An omitted package or an extra unused package causes this check to report the affected line.
Confirm that both duration fields use time.Duration and both parsed values use strconv.Atoi.
Help me fix the Go vet diagnostic in my expanded configuration gate.
The configuration gate is still intact. Next, the service needs a health signal that changes as soon as shutdown begins.
- In main.go, insert this readiness setup after the configuration error block inside main():
var ready atomic.Bool
ready.Store(true)
mux := http.NewServeMux()
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
if !ready.Load() {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "ready")
})
What does readiness control?
- The atomic value begins as true while the process can accept work.
- The /readyz handler prints ready while that value remains true.
- The unavailable response stops new traffic from treating a draining process as ready.
- Save main.go.
- Check the readiness handler by running this command:
go vet ./...
What should happen?
Go vet stays silent and returns control to you. That confirms the readiness state and handler fit the program.
- Inside main(), find this configuration line:
_, err := loadConfig()
Why does this line change?
The underscore discards the validated configuration. The work handler needs those duration values.
- Replace that line with this version:
cfg, err := loadConfig()
What does cfg hold?
The cfg value now holds the port and both validated durations. The next handler uses the work duration directly.
- Insert the work handler below the /readyz handler:
mux.HandleFunc("GET /work", func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(cfg.work)
fmt.Fprintln(w, "done")
logger.Info("work", "duration_ms", cfg.work.Milliseconds())
})
What does the work handler prove?
Each request stays active for the configured work duration before it prints done. The final log records how long the completed request waited.
- Save main.go.
- Check both handlers by running this command:
go vet ./...
What does silence confirm?
A silent result confirms that both handlers use the validated configuration correctly. The server lifecycle is the final part of the service.
- Insert the server and signal setup below the work handler:
srv := &http.Server{Addr: ":" + cfg.port, Handler: mux}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(stop)
serverErrors := make(chan error, 1)
go func() {
logger.Info("started", "port", cfg.port)
serverErrors <- srv.ListenAndServe()
}()
How does the server report state?
- The HTTP server listens on the configured port with the two registered handlers.
- The signal channel receives the process stop request.
- The error channel records how the server exits.
- The goroutine lets the main function wait for either a signal or a server result.
- Insert the shutdown selection directly below the server goroutine:
select {
case sig := <-stop:
ready.Store(false)
logger.Info("draining", "signal", sig.String(), "drain_ms", cfg.drain.Milliseconds())
ctx, cancel := context.WithTimeout(context.Background(), cfg.drain)
shutdownErr := srv.Shutdown(ctx)
cancel()
if shutdownErr != nil {
logger.Error("shutdown failed", "error", shutdownErr)
os.Exit(1)
}
serveErr := <-serverErrors
if !errors.Is(serveErr, http.ErrServerClosed) {
logger.Error("server failed", "error", serveErr)
os.Exit(1)
}
logger.Info("stopped")
case serveErr := <-serverErrors:
if !errors.Is(serveErr, http.ErrServerClosed) {
logger.Error("server failed", "error", serveErr)
os.Exit(1)
}
}
How does the drain finish?
- A stop signal changes readiness to false before shutdown starts.
- The timeout context limits how long active requests may continue.
- A normal closed-server result leads to the final stopped log.
- An unexpected shutdown or server error exits with a failure code.
- Save main.go.
- Check the complete service by running this command:
go vet ./...
What should you see?
The command returns silently. Your complete service now passes the Go vet checks.
Still seeing a Go diagnostic?
Confirm that both new blocks remain inside main(). A misplaced closing brace can move the server setup outside the function.
Check that the select block reads from stop and serverErrors with the same capitalization shown above.
Help me locate the mismatch in my complete main.go file.
✔️ Awesome, I've got everything!
Great. Save main.go before you create the image files.
ⓧ I'd like to double check the full code
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"sync/atomic"
"syscall"
"time"
)
type config struct {
port string
work time.Duration
drain time.Duration
}
func loadConfig() (config, error) {
port := os.Getenv("PORT")
if port == "" {
return config{}, errors.New("missing required env var PORT")
}
workText := os.Getenv("WORK_MS")
if workText == "" {
return config{}, errors.New("missing required env var WORK_MS")
}
drainText := os.Getenv("DRAIN_MS")
if drainText == "" {
return config{}, errors.New("missing required env var DRAIN_MS")
}
workMS, err := strconv.Atoi(workText)
if err != nil || workMS <= 0 {
return config{}, fmt.Errorf("invalid WORK_MS %q", workText)
}
drainMS, err := strconv.Atoi(drainText)
if err != nil || drainMS <= 0 {
return config{}, fmt.Errorf("invalid DRAIN_MS %q", drainText)
}
return config{port: port, work: time.Duration(workMS) * time.Millisecond, drain: time.Duration(drainMS) * time.Millisecond}, nil
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := loadConfig()
if err != nil {
logger.Error("configuration error", "error", err)
os.Exit(2)
}
var ready atomic.Bool
ready.Store(true)
mux := http.NewServeMux()
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
if !ready.Load() {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "ready")
})
mux.HandleFunc("GET /work", func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(cfg.work)
fmt.Fprintln(w, "done")
logger.Info("work", "duration_ms", cfg.work.Milliseconds())
})
srv := &http.Server{Addr: ":" + cfg.port, Handler: mux}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(stop)
serverErrors := make(chan error, 1)
go func() {
logger.Info("started", "port", cfg.port)
serverErrors <- srv.ListenAndServe()
}()
select {
case sig := <-stop:
ready.Store(false)
logger.Info("draining", "signal", sig.String(), "drain_ms", cfg.drain.Milliseconds())
ctx, cancel := context.WithTimeout(context.Background(), cfg.drain)
shutdownErr := srv.Shutdown(ctx)
cancel()
if shutdownErr != nil {
logger.Error("shutdown failed", "error", shutdownErr)
os.Exit(1)
}
serveErr := <-serverErrors
if !errors.Is(serveErr, http.ErrServerClosed) {
logger.Error("server failed", "error", serveErr)
os.Exit(1)
}
logger.Info("stopped")
case serveErr := <-serverErrors:
if !errors.Is(serveErr, http.ErrServerClosed) {
logger.Error("server failed", "error", serveErr)
os.Exit(1)
}
}
}
Record the contract and build the baseline
The shutdown path depends on an ordered timeout contract. The request must finish before the application drain deadline, while the outer grace period must remain longer than both.
- In the ~/stop-clean folder open in Cursor, create TASKS.md with this timeout contract:
# Timeout Contract
WORK_MS: 10000 milliseconds
DRAIN_MS: 12000 milliseconds
GRACE: 15 seconds
ECS default stopTimeout: 30 seconds
Delta: selected grace is 15 seconds below the default
Reason: The ten-second request fits inside the 12-second application deadline. The 15-second outer grace leaves three more seconds before SIGKILL.
Reverse this decision if observed work regularly exceeds 12 seconds or the three-second outer margin proves too small.
How is the margin divided?
The ten-second request has two seconds to finish before the application deadline. The outer grace adds three more seconds before the runtime may force the process to stop.
This ordering makes the shutdown budget reviewable before deployment. The decision also states the observation that should trigger a change.
- Save TASKS.md.
✔️ Awesome, I've got everything!
Your timeout contract now records the selected values and their margins.
ⓧ I'd like to double check the full code
# Timeout Contract
WORK_MS: 10000 milliseconds
DRAIN_MS: 12000 milliseconds
GRACE: 15 seconds
ECS default stopTimeout: 30 seconds
Delta: selected grace is 15 seconds below the default
Reason: The ten-second request fits inside the 12-second application deadline. The 15-second outer grace leaves three more seconds before SIGKILL.
Reverse this decision if observed work regularly exceeds 12 seconds or the three-second outer margin proves too small.
The baseline image keeps the Go builder as its runtime. Measuring this version gives the later multi-stage image a concrete comparison point.
- In the ~/stop-clean folder, create Dockerfile.single with this single-stage build:
FROM golang:1.27.2-alpine
WORKDIR /src
COPY go.mod main.go ./
RUN go build -o /ship .
ENTRYPOINT ["/ship"]
What does the baseline contain?
- The base image supplies the Go toolchain used to compile the service.
- The source files enter the image before the build creates the service binary.
- The same builder image remains as the final runtime.
- Save Dockerfile.single.
- Build the Linux x86 baseline image by running this command:
docker build --platform linux/amd64 -f Dockerfile.single -t ship:single .
What does this build create?
Docker compiles the service inside the Go builder image. The completed local image receives the ship:single tag for the later comparison.
You should see the build steps complete and the command return to your prompt. The ship:single image now exists locally for Linux x86.
Did the baseline build fail?
Confirm that Dockerfile.single, go.mod, and main.go all sit directly inside ~/stop-clean.
Confirm that Docker Desktop from Day Zero is still running before you retry the build.
Help me troubleshoot the failed single-stage Docker build.
✔️ Awesome, I've got everything!
Your baseline image is built with the ship:single tag.
ⓧ I'd like to double check the full code
FROM golang:1.27.2-alpine
WORKDIR /src
COPY go.mod main.go ./
RUN go build -o /ship .
ENTRYPOINT ["/ship"]
Build and scan the runtime image
A multi-stage image uses one stage to compile the binary and a second stage to run it. The final stage carries the service binary without the builder toolchain.
Why use a multi-stage image?
The single-stage image is useful as a measurable baseline. The multi-stage approach removes the compiler and source from the runtime image.
The final stage also uses a nonroot runtime pinned by an OCI digest. That pin identifies the exact runtime content selected during Day Zero.
- In the ~/stop-clean folder, create Dockerfile with this multi-stage build:
FROM golang:1.27.2-alpine AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -o /ship .
FROM gcr.io/distroless/static-debian13:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /ship /ship
ENTRYPOINT ["/ship"]
What does each stage do?
- The build stage compiles a static service binary with CGO disabled.
- The runtime stage starts from the digest-pinned nonroot image.
- The cross-stage copy moves only the completed binary into the runtime.
- The entry point starts that binary when the container runs.
- Save Dockerfile.
- Build the Linux x86 runtime image by running this command:
docker build --platform linux/amd64 -t ship:v1 .
What does this build create?
Docker compiles the service in the first stage and assembles the runtime in the second stage. The completed local image receives the ship:v1 tag.
You should see both stages finish before the command returns to your prompt. The ship:v1 image now exists locally for Linux x86.
Did the multi-stage build fail?
Check the runtime digest character by character. A missing digit prevents Docker from resolving the pinned image.
Confirm that the build stage is named build because the copy instruction reads from that exact stage name.
Help me troubleshoot the failed multi-stage Docker build.
✔️ Awesome, I've got everything!
Your digest-pinned runtime image is built with the ship:v1 tag.
ⓧ I'd like to double check the full code
FROM golang:1.27.2-alpine AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -o /ship .
FROM gcr.io/distroless/static-debian13:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /ship /ship
ENTRYPOINT ["/ship"]
The final gate uses Trivy to scan the runtime image for high and critical findings. This is an empirical check, so the result must come from the image you just built.
Before you run the gate, do you expect the scan to return control without failing?
- Scan the runtime image by running this command:
trivy image --severity HIGH,CRITICAL --exit-code 1 ship:v1
What does the gate prove?
The command checks the ship:v1 runtime image against the warmed vulnerability database. The gate returns control without failing when the scan exits with code 0.
You should see the scan finish and return to your prompt without a failure. That result completes the image gate for this build.
Did the image gate fail?
Confirm that the scan targets ship:v1 and that this tag completed its build. A missing local tag prevents the scan from starting.
If the scan reports findings, stop here and inspect the reported package and runtime layer. The acceptance target requires this command to exit successfully.
Help me interpret why the Trivy image gate failed.
That is the runtime boundary complete. Your service passes vet, both Linux x86 images exist, and the scanned runtime image passes its gate.
- Stage the completed service and image files by running this command:
git add -A
What does staging include?
Git stages the completed service, both Dockerfiles, and the timeout contract. The existing design documents remain unchanged.
- Commit this build step by running this command:
git commit -m "Add a build step"
What should you see?
Git reports a new commit on the build branch. That commit preserves the completed drain logic and both image definitions.
Did Git skip the commit?
Confirm that you saved main.go, TASKS.md, Dockerfile.single, and Dockerfile before staging.
Confirm that Cursor still shows the local build branch from the previous step.
Help me diagnose why Git did not create the build commit.
Your service and both images are ready. Next, you will deploy the exact runtime image to Fargate, prove its drain behavior, and remove every paid resource.
Deploy, Stop, Ship, and Tear Down
The service now has a tested shutdown contract. Its two Linux x86 container images are ready for deployment.
The final claim needs proof from Amazon ECS on AWS Fargate. You will preserve that evidence in your private GitHub repository before removing every paid runtime resource.
In this step, get ready to:
- Deploy the scanned image with exactly seven AWS resources.
- Prove that one captured Fargate task drains with exit code 0.
- Ship the evidence through a private pull request before tearing down the runtime.
Deploy the image with seven AWS resources
The OpenTofu configuration defines the image repository plus the execution role and runtime resources. The deployment starts with zero tasks so you can push the image before compute begins.
Keep the deployment short
One task plus its public IPv4 address costs about $0.0088 before credits for half an hour in us-east-1. The end of this step destroys all seven resources.
- Create an infra folder from the Cursor Explorer sidebar inside the open ~/stop-clean workspace.
- Create main.tf inside the new infra folder.
- Add the pinned tool configuration to infra/main.tf by pasting this code:
terraform {
required_version = "= 1.13.2"
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.70.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
What does this configuration do?
- The version constraints pin OpenTofu to 1.13.2 plus the AWS provider to 6.70.0.
- The provider sends resource requests to us-east-1.
- Save infra/main.tf.
- Add the deployment inputs plus default-subnet lookup below the provider block:
variable "grace_seconds" {
type = number
}
variable "drain_ms" {
type = string
}
variable "tasks" {
type = number
}
data "aws_subnets" "d" {
filter {
name = "default-for-az"
values = ["true"]
}
}
What do these inputs control?
- The variables carry the outer grace period plus the application drain deadline and desired task count.
- The data source finds the default subnets available to the Fargate service.
- Save infra/main.tf.
- Return to the Cursor terminal from earlier.
- Initialize the partial configuration by running this command:
tofu -chdir=infra init
What does this command prove?
OpenTofu loads the configuration from infra. A successful initialization confirms that the pinned provider and input syntax are valid.
The first initialization can take a little time while OpenTofu prepares the provider. You will see a successful initialization message when it finishes.
Initialization failed?
Confirm that the terminal is inside ~/stop-clean. Check the braces in both sections of infra/main.tf.
Use the terminal output with this request: Help me diagnose this OpenTofu initialization failure.
The first two resources store the image plus its lifecycle logs. Amazon ECR supplies the image to Fargate while Amazon CloudWatch Logs preserves the stop evidence.
- Add the repository plus log group below the subnet data source:
resource "aws_ecr_repository" "r" {
name = "ship"
force_delete = true
}
resource "aws_cloudwatch_log_group" "l" {
name = "ship"
}
What do these resources provide?
- The ship repository stores the deployed v1 image.
- The ship log group records the draining plus stopped lifecycle events.
- Save infra/main.tf.
- Add the execution role plus policy attachment and cluster below the log group:
resource "aws_iam_role" "x" {
name = "ship-execution"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "x" {
role = aws_iam_role.x.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_ecs_cluster" "c" {
name = "ship"
}
What does this control plane do?
- The execution role lets the Fargate runtime pull the image plus publish logs.
- The policy attachment supplies the managed execution permissions.
- The ship cluster holds the project service.
- Save infra/main.tf.
- Check the expanded configuration by running:
tofu -chdir=infra init
What should you see?
OpenTofu reports a successful initialization after parsing the repository plus log group and execution resources.
Role configuration not loading?
Check the capitalization of the trust policy keys. Confirm that the attachment references aws_iam_role.x.name.
Use this request without sharing account details: Help me debug this ECS execution role configuration.
The task definition carries the timeout contract into Fargate. Its runtime platform must match the Linux x86 image you built.
- Add the task definition base below the cluster resource:
resource "aws_ecs_task_definition" "t" {
family = "ship"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.x.arn
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "X86_64"
}
}
What does the task base set?
- The task uses 0.25 vCPU plus 0.5 GB of memory.
- The runtime requests Linux x86 compute that matches linux/amd64.
- Save infra/main.tf.
- Find this ending in the aws_ecs_task_definition.t block:
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "X86_64"
}
}
What are you locating?
This brace currently closes the task definition. The container definition belongs directly before it.
- Replace that ending with this completed task definition ending:
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "X86_64"
}
container_definitions = jsonencode([{
name = "ship"
image = "${aws_ecr_repository.r.repository_url}:v1"
essential = true
stopTimeout = var.grace_seconds
environment = [
{ name = "PORT", value = "8080" },
{ name = "WORK_MS", value = "10000" },
{ name = "DRAIN_MS", value = var.drain_ms }
]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.l.name
awslogs-region = "us-east-1"
awslogs-stream-prefix = "ship"
}
}
}])
}
How does this preserve the contract?
- The image uses the v1 tag in the ECR repository.
- The environment supplies port 8080 plus ten-second work and the selected drain deadline.
- The stop timeout gives the process its outer grace period.
- The log configuration sends lifecycle records to the ship log group.
- Save infra/main.tf.
- Check the completed task definition by running:
tofu -chdir=infra init
What should you see?
OpenTofu reports a successful initialization after loading the nested container definition.
Container definition not loading?
Check the square brackets around the environment list. Confirm that }]) closes the JSON expression.
Use this request for a focused comparison: Help me find the bracket mismatch in this ECS container definition.
The service connects the task definition to the default subnets. Its desired count comes from each apply command.
- Add the service plus repository outputs below the task definition:
resource "aws_ecs_service" "s" {
name = "ship"
cluster = aws_ecs_cluster.c.id
task_definition = aws_ecs_task_definition.t.arn
desired_count = var.tasks
launch_type = "FARGATE"
network_configuration {
subnets = data.aws_subnets.d.ids
assign_public_ip = true
}
depends_on = [aws_iam_role_policy_attachment.x]
}
output "repo" {
value = aws_ecr_repository.r.repository_url
}
output "registry" {
value = split("/", aws_ecr_repository.r.repository_url)[0]
}
What completes the configuration?
- The service runs the requested task count in the default subnets.
- The outputs expose the account-specific repository URL plus registry host.
- Save infra/main.tf.
- Confirm the completed configuration initializes by running:
tofu -chdir=infra init
What should you see?
OpenTofu reports successful initialization. The seven-resource configuration is ready to apply.
Final initialization failed?
Compare the resource references with the full file below. Pay close attention to block order plus closing braces.
Use this request while preserving the pins: Help me diagnose the final OpenTofu initialization failure.
✔️ Awesome, I've got everything!
Your complete infra/main.tf initializes successfully. Save it before the first apply.
ⓧ I'd like to double check the full code
terraform {
required_version = "= 1.13.2"
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.70.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
variable "grace_seconds" {
type = number
}
variable "drain_ms" {
type = string
}
variable "tasks" {
type = number
}
data "aws_subnets" "d" {
filter {
name = "default-for-az"
values = ["true"]
}
}
resource "aws_ecr_repository" "r" {
name = "ship"
force_delete = true
}
resource "aws_cloudwatch_log_group" "l" {
name = "ship"
}
resource "aws_iam_role" "x" {
name = "ship-execution"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "x" {
role = aws_iam_role.x.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_ecs_cluster" "c" {
name = "ship"
}
resource "aws_ecs_task_definition" "t" {
family = "ship"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.x.arn
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "X86_64"
}
container_definitions = jsonencode([{
name = "ship"
image = "${aws_ecr_repository.r.repository_url}:v1"
essential = true
stopTimeout = var.grace_seconds
environment = [
{ name = "PORT", value = "8080" },
{ name = "WORK_MS", value = "10000" },
{ name = "DRAIN_MS", value = var.drain_ms }
]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.l.name
awslogs-region = "us-east-1"
awslogs-stream-prefix = "ship"
}
}
}])
}
resource "aws_ecs_service" "s" {
name = "ship"
cluster = aws_ecs_cluster.c.id
task_definition = aws_ecs_task_definition.t.arn
desired_count = var.tasks
launch_type = "FARGATE"
network_configuration {
subnets = data.aws_subnets.d.ids
assign_public_ip = true
}
depends_on = [aws_iam_role_policy_attachment.x]
}
output "repo" {
value = aws_ecr_repository.r.repository_url
}
output "registry" {
value = split("/", aws_ecr_repository.r.repository_url)[0]
}
This first apply creates paid cloud resources. The desired task count stays at zero until the image reaches ECR.
- Create the seven AWS resources with zero running tasks by running:
tofu -chdir=infra apply -auto-approve -var grace_seconds=15 -var drain_ms=12000 -var tasks=0
What should you see?
OpenTofu creates exactly seven resources. The ECS service finishes with a desired count of zero.
Apply did not create seven resources?
Confirm that ship plus ship-execution are available in the selected account. Confirm that default subnets exist in us-east-1.
Use redacted output with this request: Help me diagnose this seven-resource OpenTofu apply failure.
The repository URL plus registry host are specific to your AWS account. Keep both values in the same terminal session for the image commands.
macOS
- Store the repository URL in the current Bash session by running:
REPO="$(tofu -chdir=infra output -raw repo)"
What does this store?
The REPO shell value holds the full ECR repository URL.
- Store the registry host in the current Bash session by running:
REGISTRY="$(tofu -chdir=infra output -raw registry)"
What does this store?
The REGISTRY shell value holds the host used for Docker authentication.
Windows
- Store the repository URL in the current PowerShell session by running:
$REPO = tofu -chdir=infra output -raw repo
What does this store?
The REPO PowerShell value holds the full ECR repository URL.
- Store the registry host in the current PowerShell session by running:
$REGISTRY = tofu -chdir=infra output -raw registry
What does this store?
The REGISTRY PowerShell value holds the host used for Docker authentication.
macOS
- Authenticate Docker to the project registry by running:
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin "$REGISTRY"
What does this command do?
AWS supplies a temporary registry password through standard input. Docker stores the authenticated registry session without printing that password.
- Tag the scanned local image for ECR by running:
docker tag ship:v1 "$REPO:v1"
What changes here?
The local image gains a repository-qualified v1 reference. Its contents remain unchanged.
- Push the tagged image to ECR by running:
docker push "$REPO:v1"
What should you see?
Docker uploads the layers plus prints the pushed digest. ECR can now supply the image to Fargate.
Windows
- Authenticate Docker to the project registry by running:
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin $REGISTRY
What does this command do?
AWS supplies a temporary registry password through standard input. Docker stores the authenticated registry session without printing that password.
- Tag the scanned local image for ECR by running:
docker tag ship:v1 "${REPO}:v1"
What changes here?
The local image gains a repository-qualified v1 reference. Its contents remain unchanged.
- Push the tagged image to ECR by running:
docker push "${REPO}:v1"
What should you see?
Docker uploads the layers plus prints the pushed digest. ECR can now supply the image to Fargate.
Image push failed?
Confirm that the registry login succeeded in the same terminal. A new terminal does not retain the repository or registry shell values.
Use this request without sharing credentials: Help me diagnose why this ECR image push failed.
The image is now available to the service. Increasing the desired count to one starts the billable task that proves the runtime contract.
- Deploy one task with the selected grace plus drain values by running:
tofu -chdir=infra apply -auto-approve -var grace_seconds=15 -var drain_ms=12000 -var tasks=1
What should you see?
OpenTofu updates the service desired count to one. Fargate starts a Linux x86 task from the ECR image.
Task startup can take a few minutes while Fargate places the task plus pulls the image. A quiet wait here is normal.
- Read the first task logs by running:
aws logs tail ship --since 10m
What should you see?
The log window contains the JSON started event. This proves that the deployed process reached its server loop.
No task logs yet?
Allow Fargate placement to finish before running the log command again. Confirm that the apply completed with one desired task.
Use this request with redacted output: Help me diagnose why the task has no CloudWatch logs.
Prove the captured task drains
The local acceptance checks prove both sides of the shutdown contract. A valid stop preserves the ten-second response while the two-second stop demonstrates the expected forced-termination path.
- Return to docs/test-plan.md from the design baseline.
- Run AC-1 to capture the missing PORT JSON error plus exit code 2.
- Run AC-2 to capture the ready response from /readyz.
- Run AC-3 to capture done plus exit code 0 and the lifecycle logs.
- Run AC-4 to record the measured size comparison between ship:single plus ship:v1.
- Run AC-5 to record the clean Trivy severity gate.
- Run AC-7 to capture exit code 137 plus the missing done response from the forced two-second stop.
The successful local stop protects the response. The forced stop proves that work is lost when the outer grace period is too short.
The cloud check must follow one exact task. Capturing its Amazon Resource Name before scale-down prevents an older stopped task from being mistaken for this run.
macOS
- Capture the running service task ARN in Bash by running:
TASK_ARN="$(aws ecs list-tasks --cluster ship --service-name ship --desired-status RUNNING --query 'taskArns[0]' --output text)"
Why capture the ARN now?
The shell value identifies the exact running task before scale-down. Every later wait plus exit-code query follows this identifier.
Windows
- Capture the running service task ARN in PowerShell by running:
$TASK_ARN = aws ecs list-tasks --cluster ship --service-name ship --desired-status RUNNING --query 'taskArns[0]' --output text
Why capture the ARN now?
The PowerShell value identifies the exact running task before scale-down. Every later wait plus exit-code query follows this identifier.
Task ARN is empty?
The service has not reached a running task. Allow Fargate placement to finish before repeating the capture command.
Use this request to inspect the service state: Help me diagnose why the service has no running task ARN.
Before you trigger the stop, do you expect the captured task to finish inside the configured grace period?
- Start the graceful stop by running:
aws ecs update-service --cluster ship --service ship --desired-count 0
What happens now?
ECS changes the desired count to zero. The running task receives its stop signal plus enters the configured grace period.
- Wait for the service to reach its zero-task target by running:
aws ecs wait services-stable --cluster ship --services ship
What does this waiter prove?
The command returns when the service running count matches its desired count.
This waiter can stay quiet while ECS reconciles the service. Let it return before checking the captured task.
macOS
- Wait for the exact captured task to stop by running:
aws ecs wait tasks-stopped --cluster ship --tasks "$TASK_ARN"
What does this waiter prove?
The waiter follows the captured ARN until that exact task reaches the stopped state.
Windows
- Wait for the exact captured task to stop by running:
aws ecs wait tasks-stopped --cluster ship --tasks $TASK_ARN
What does this waiter prove?
The waiter follows the captured ARN until that exact task reaches the stopped state.
Before you query the stopped task, what exit code do you expect from a successful drain?
macOS
- Read the captured container exit code by running:
aws ecs describe-tasks --cluster ship --tasks "$TASK_ARN" --query 'tasks[0].containers[0].exitCode'
What should you see?
The query prints 0. The process completed its shutdown path before the outer grace period ended.
Windows
- Read the captured container exit code by running:
aws ecs describe-tasks --cluster ship --tasks $TASK_ARN --query 'tasks[0].containers[0].exitCode'
What should you see?
The query prints 0. The process completed its shutdown path before the outer grace period ended.
- Read the shutdown lifecycle logs by running:
aws logs tail ship --since 15m
What should you see?
The log window contains draining plus stopped from the stop window. Lines from multiple streams can appear in a different order.
Exit code or logs differ?
Confirm that the query uses the captured ARN. Check that the task definition received grace 15 plus drain 12000.
Use redacted output with this request: Help me diagnose why the captured task did not exit cleanly.
That is the cloud proof complete. Your exact Fargate task accepted the stop signal plus drained before forced termination.
Ship the evidence and remove the runtime
The repository needs self-contained evidence that another engineer can review. The handoff records the operating procedure plus the measured result and product impact.
- Create check-out.txt in ~/stop-clean from the Cursor Explorer sidebar.
- Paste this evidence summary into check-out.txt:
AC-1 through AC-8 observed command output was appended during validation. It records the missing PORT JSON error and exit 2, readiness response, graceful local drain with done and exit 0, image-size comparison, Trivy HIGH and CRITICAL exit 0, ECS captured-task exit 0 with draining and stopped logs, forced two-second stop exit 137 without done, private PR merge, v1.0 tag, seven-resource destroy, no ship cluster, and local image removal.
What does this evidence cover?
The summary connects the acceptance criteria to their observed outcomes. It also records release plus teardown evidence.
- Save check-out.txt.
- Create docs/runbook.md inside the existing docs folder.
- Paste this operating procedure into docs/runbook.md:
# Runbook
Use the committed timeout contract and test plan to build ship:v1, validate local graceful and forced shutdown behavior, deploy the ECR image through infra/main.tf, capture the running ECS task ARN before scaling the service to zero, and inspect the task exit code and CloudWatch logs. Destroy infrastructure with tasks=0 when validation completes.
What does the runbook preserve?
The runbook records the safe operational order. Its captured-task rule prevents historical stopped tasks from weakening the evidence.
- Save docs/runbook.md.
- Create docs/after-action-review.md inside the existing docs folder.
- Paste this review into docs/after-action-review.md:
# After-Action Review
The service rejected missing runtime configuration, completed ten-second work within the twelve-second application drain deadline and fifteen-second ECS grace period, and exited cleanly after ECS scale-to-zero. The two-second stop demonstrated the expected SIGKILL failure path. The deployment was destroyed after evidence capture.
What does the review establish?
The review records the successful contract plus its intentional failure case. It closes with the required cloud teardown.
- Save docs/after-action-review.md.
- Create docs/pm-readout.md inside the existing docs folder.
- Paste this project readout into docs/pm-readout.md:
# PM Readout
A private, versioned Go service proved graceful request draining locally and on Amazon ECS Fargate. The image passed the configured Trivy severity gate. The private pull request merged and tag v1.0 was pushed. Seven AWS resources and both local project image tags were removed after validation.
What does the readout communicate?
The readout states the delivered capability plus the security gate and release outcome. It also confirms the runtime cleanup.
- Save docs/pm-readout.md.
- Stage the infrastructure plus evidence files by running:
git add -A
What does this stage?
Git stages the infrastructure plus validation and handoff files on build.
- Record the final build commit by running:
git commit -m "Add a build step"
What does this commit preserve?
The commit binds the implementation plus its evidence into one reviewable history.
- Push the private build branch by running:
git push -u origin build
What should you see?
Git pushes build to the private remote. The local branch begins tracking its remote counterpart.
Push rejected?
Confirm that GitHub CLI remains authenticated. Confirm that the existing remote points to the private stop-clean repository.
Use this request without sharing credentials: Help me diagnose why the private build branch push was rejected.
- Create the private pull request by running:
gh pr create --fill
What does this create?
GitHub CLI creates the pull request from the pushed build branch using its commit information.
The merge command can request confirmation before updating the private repository. Review the current pull request before confirming it.
- Merge the current pull request with a merge commit by running:
gh pr merge --merge
What should you see?
GitHub reports that the pull request merged. The private remote main branch now contains the complete project history.
- Switch the local repository to main by running:
git switch main
What changes locally?
The local checkout moves from build to main.
- Synchronize local main by running:
git pull
What should you see?
Git pulls the merged project into local main.
- Create the release tag by running:
git tag v1.0
What does this tag identify?
The v1.0 tag identifies the merged release commit.
- Publish the release tag by running:
git push origin v1.0
What should you see?
Git reports that the new v1.0 tag reached the private remote.
Deleting the cloud deployment is the intended close-out. Your private repository plus v1.0 remain as the shipped artifacts.
- Destroy the seven AWS resources by running:
tofu -chdir=infra destroy -auto-approve -var grace_seconds=15 -var drain_ms=12000 -var tasks=0
What should you see?
OpenTofu reports Resources: 7 destroyed. This removes the ECR plus CloudWatch Logs and IAM and ECS resources.
Destroy did not complete?
Confirm that the destroy command uses tasks=0 plus the same timeout values as the deployment.
Use redacted output with this request: Help me finish this seven-resource OpenTofu destroy.
Before the final cloud query, do you expect any cluster ARN containing ship to remain?
- Verify that no project cluster remains by running:
aws ecs list-clusters --query "clusterArns[?contains(@, 'ship')]"
What should you see?
The query prints an empty list. No ECS cluster containing ship remains in the configured Region.
The cloud runtime is gone. The last cleanup removes the two local image tags used by this project.
- Remove both local project image tags by running:
docker image rm ship:v1 ship:single
What should you see?
Docker reports that both local tag references were removed. The ECR repository and its remote image disappeared during the OpenTofu destroy.
Image removal blocked?
A local project container can still reference one of the images. Identify that container before repeating the image-removal command.
Use the Docker output with this request: Help me identify what still references the ship images.
You shipped a private v1.0 release with local plus Fargate shutdown evidence. The seven AWS resources plus both local project image tags are now gone.
Secret mission
Rebalance the Timeout Contract
Your first timeout contract protected ten-second work with a twelve-second drain deadline plus a fifteen-second outer grace. In this mission, you will test a wider margin from v1.0, preserve the new evidence, and open a private pull request for review.
Clean Up Your Resources
Clean Up Your Resources
Your core cleanup is complete. No AWS resources or local Docker images remain, so this project has no ongoing costs beyond the completed run of about $0.01 before credits.
Resources you used:
- Cloud deployment: All seven OpenTofu-managed resources are destroyed.
- Cluster state: No ship Amazon ECS cluster remains.
- Local images: The ship:v1 plus ship:single tags are removed.
Your private GitHub repository remains as the shipped artifact. The v1.0 tag plus the open Secret Mission pull request remain available for review.
Keep everything running
No action is needed. Choose this if you are still reviewing the Secret Mission pull request.
- Keep the stop-clean repository private.
- Preserve v1.0 as the released baseline.
- Continue reviewing the open Secret Mission pull request.
Pause - I'll come back to this later
No processes or paid resources remain to pause. Choose this if you want to return later.
- Leave the Secret Mission pull request open.
- Preserve the private rebalanced-timeout branch.
- Return to TASKS.md when you resume the review.
Delete - I don't want to use this again
The runtime deletion is already complete. This option confirms the saved teardown evidence.
- Review check-out.txt for the seven-resource destroy result.
- Review check-out.txt for the recorded absence of the ship cluster.
- Review check-out.txt for removal of ship:v1 plus ship:single.
- Retain the private stop-clean repository as the shipped artifact.
Nice Work!
Nice Work!
You did it! Your private stop-clean repository now proves graceful draining on Amazon ECS with AWS Fargate.
You've learned how to:
- Implemented a bounded shutdown contract in Go. The service rejects missing runtime configuration. It changes readiness during a stop. It lets a ten-second request finish before exit 0.
- Built a digest-pinned nonroot OCI image for Linux x86. Trivy proved the vulnerability gate. Amazon ECS stopped the captured task with exit 0. Amazon CloudWatch Logs preserved the draining plus stopped lifecycle evidence.
- Published the design history plus build evidence through a private GitHub pull request. You merged main. You pushed v1.0. You destroyed seven AWS resources. You removed both local image tags.
- Secret Mission: Rebalanced the timeout contract on a new private branch. DRAIN_MS=14000 gives the request more time to drain. GRACE=18 leaves a four-second outer margin. Repeated checks returned ready. Graceful work printed done with exit code 0.
Ready to quiz yourself?