Deploy a container to Amazon ECS on Fargate from CI with no stored AWS keys, a role that reaches one service, and gates that stop an unscanned image. GitHub Actions end to end, with the same deploy on GitLab CI and on AWS CodePipeline with CodeBuild as tested examples.
Lab. Harbor Goods and all data here are fictional. Each repository in this portfolio is a separate engagement with Harbor Goods, a fictional mid-size retailer. Account IDs are AWS documentation examples.
- No long-lived AWS keys in CI. The deploy job trades a short-lived OIDC token for one-hour role credentials.
The trust policy accepts one subject and one audience, with
StringEqualsonly. - A role that reaches one service. It can push to one ECR repository, update one ECS service and pass one
execution role, and nothing else. Offline tests assert this, and
make test-liveasks the IAM policy simulator. - The deployed image is the scanned image. It is built once, gated by Trivy, attested, pushed with its digest
preserved and deployed as
image@sha256. A post-deploy check fails the run if the circuit breaker rolled back. - Pull requests cannot deploy. The deploy role refuses the
pull_requestsubject, and no pull request workflow in this repository can request anid-token. - The same pattern on GitLab CI.
examples/gitlab-ci/deploys the same image to the same service through GitLab's OIDC tokens, with its own tests. Jenkins is covered as a documented pattern. - The same deploy with AWS-native tools.
examples/codepipeline/runs CodeBuild (tests, Trivy gate, push by digest), a manual approval and the ECS deploy action in CodePipeline, with one scoped role per principal and encrypted artifacts that expire. Offline tests assert the stage order and every role's actions.
| Artifact | What to look at |
|---|---|
| deploy.yml | Build once, Trivy gate, attestations, OIDC, push by digest, deploy, verify |
| trust-policy.json.tftpl | The single trusted subject and audience |
| deploy-policy.json.tftpl | One repository, one service, one role to pass |
| iam.tftest.hcl | Offline assertions on both policies, wildcards refused |
| verify-deployment.sh | The post-deploy check that catches a circuit-breaker rollback |
| examples/gitlab-ci | The GitLab CI pipeline, its role and tests |
| examples/codepipeline | CodePipeline, CodeBuild buildspecs, three roles and tests |
| codepipeline.tftest.hcl | Approval before deploy, encryption, no wildcard actions |
| threat-notes.md | Each token subject and whether STS accepts it |
| deploy-runbook.md | Deploying the lab to a sandbox account, cost and teardown |
Harbor Goods, a fictional mid-size retailer, deploys a small storefront service to ECS on Fargate in its production
account (111122223333). Today a CI secret
holds an IAM user's access key with broad permissions, and a pull request from any branch can run a job that uses
it. The team uses GitHub for the storefront and GitLab for two internal tools, and wants one pattern for both.
The work is done when:
- No workflow or pipeline stores an AWS access key;
tests/test_gitlab_example.pyand a repository search find none. - The deploy role trusts exactly
repo:OWNER/REPO:environment:production(GitHub) or the protectedmainbranch subject (GitLab), with audiencests.amazonaws.com;terraform testasserts both. - The role's actions are named in full and scoped to one repository, service and execution role;
terraform testasserts it offline andmake test-livechecks it with the IAM policy simulator. - A pull request cannot obtain AWS credentials;
tests/test_workflows.pyfails if any pull request workflow in.github/workflows/asks for anid-token. - The deployed digest equals the scanned digest, and a rollback fails the run.
make verifypasses offline in under a minute.- The same deploy through AWS CodePipeline keeps the same gates: tests and Trivy before the push, a manual approval
before the ECS deploy, deploy by digest, and roles with every action named;
terraform testasserts it offline.
The engineer merges to main and approves the production deploy. The CI job asks its own OIDC issuer for a signed
token, and AWS STS exchanges it for one-hour credentials after checking the deploy role's trust policy. With those
credentials the job pushes the scanned image to Amazon ECR by digest and updates the Amazon ECS service, then verifies
the rollout. GitHub Actions is the implemented path; GitLab CI (dashed) is the tested example.
The deployment view shows the seven steps of deploy.yml, the approval gate and the pull request path that STS
refuses: docs/diagrams/deploy-flow.png. Diagram sources are the .drawio files next
to the images.
For clients whose delivery runs inside AWS, examples/codepipeline/ deploys the same image to the same service with
AWS CodePipeline. The source is a GitHub repository through AWS CodeConnections (or a zip in the artifact bucket).
A CodeBuild project runs the tests, builds the image, stops on fixable HIGH or CRITICAL Trivy findings and pushes it,
then exports the image as repository@sha256:<digest>. A manual approval shows that digest, the ECS deploy action
rolls it out, and a second CodeBuild project runs the same verify-deployment.sh. The pipeline, build and verify
roles are separate: only the pipeline can deploy, only the build can push. Artifacts and build logs are encrypted
with a pipeline KMS key, and the artifact bucket expires them after 30 days. When to choose each tool is in
ADR 0010; setup is in
examples/codepipeline/README.md.
Prerequisites, with the versions CI uses: Python 3.13 with uv, Terraform 1.14.5 (from
infra/terraform/.terraform-version; 1.11 or later works), tflint 0.61.0, shellharden 4.3.2, hadolint 2.15.1, and
shellcheck (CI uses the runner's; 0.11.0 locally). No AWS account or credentials.
make verifyExpected output ends with:
Success! 7 passed, 0 failed.
Success! 7 passed, 0 failed.
Success! 7 passed, 0 failed.
Success! 3 passed, 0 failed.
...
No findings to report. Good job! (7 suppressed)
verify: all checks passed
It takes about 30 seconds once tools and providers are cached. It runs pytest (66 tests), ruff, terraform fmt,
validate, mocked terraform test and tflint on the four Terraform roots (the fourth is the live-test network),
Checkov, shellcheck, shellharden, hadolint, actionlint and zizmor. make image adds the Trivy image gate (needs
Docker) and make semgrep the Semgrep rulesets.
make test-live is optional and manual. It applies the GitHub and GitLab Terraform roots to a sandbox account, checks
the roles with the IAM policy simulator and always destroys what it created; see
deploy-runbook.md. make test-live-codepipeline does the same for the
CodePipeline path and also runs the pipeline end to end, approval included. Both run private-only: a dedicated VPC
with no internet path, no public IPs, and a pre-flight that refuses any plan with an internet-facing resource before
it is applied (docs/live-test.md). To deploy the lab end to end from your own fork, follow the
same runbook.
app/ Python standard-library HTTP service, its tests, Dockerfile pinned by digest
infra/terraform/ OIDC provider, deploy role, optional read-only plan role, ECR, ECS on Fargate
infra/terraform/policies/ trust and permission policies as JSON templates
infra/terraform/tests/ terraform test with a mocked provider
examples/gitlab-ci/ .gitlab-ci.yml and a Terraform root for the GitLab OIDC role, with tests
examples/codepipeline/ buildspecs and a Terraform root for CodePipeline, CodeBuild and their roles, with tests
examples/workflows/ plan.yml: a read-only terraform plan on pull requests, for a client to install
tests/ pytest: workflow hardening rules, GitLab pipeline and buildspec properties, live-test guards
tests/live/ private network root and deploy-target settings for the live tests, with terraform test
scripts/ verify-deployment.sh (every pipeline), test-live.sh and test-live-codepipeline.sh
(manual, real AWS), check_private_plan.py (their pre-flight)
.github/workflows/ ci (make verify + shared checks), security (SARIF gates), deploy, scorecard,
update-pre-commit-hooks
docs/adr/ architecture decision records
docs/diagrams/ context, deployment and CodePipeline diagrams (.drawio source, .png export)
docs/ threat notes, deploy runbook, live tests, Jenkins pattern
Architecture decision records follow the Fundamentals of Software Architecture (2nd ed.) format.
| Number | Title | Status |
|---|---|---|
| 0001 | Match OIDC claims with StringEquals, never StringLike | Accepted |
| 0002 | Trust only the environment subject, not the branch subject | Accepted |
| 0003 | No ECS task role; the execution role only pulls and logs | Accepted |
| 0004 | One OIDC role per deploy target | Accepted |
| 0005 | Build once, scan what ships, deploy by digest, verify after | Accepted |
| 0006 | Semgrep, Trivy and Checkov as required checks, with SARIF in code scanning | Accepted |
| 0007 | A separate, optional, read-only role for terraform plan on pull requests | Accepted |
| 0008 | Test IAM policies offline with terraform test and a mocked provider | Accepted |
| 0009 | Show the GitLab CI equivalent as a tested example, bound to the protected branch | Accepted |
| 0010 | GitHub Actions vs CodePipeline: when to use each | Accepted |
| 0011 | Ship the pull request plan workflow as a client-installed example | Accepted |
| 0012 | Live tests run private-only | Accepted |
| Gate | Where | Why |
|---|---|---|
make verify |
ci.yml (verify) |
Same command as locally: tests, Terraform tests, linters, Checkov, workflow scanners |
| markdownlint, lychee, Vale | ci.yml, shared lint-docs |
Docs stay readable and links stay alive |
| actionlint, zizmor | ci.yml, shared lint-actions |
Workflow syntax and known-bad patterns |
| gitleaks | ci.yml, shared secrets |
No credentials in history |
| hadolint, image build and Trivy | ci.yml, shared container |
Dockerfile rules and image vulnerabilities |
| Trivy on the repository | ci.yml, shared security |
Vulnerabilities, secrets and IaC misconfigurations |
| Semgrep, Trivy image, Checkov | security.yml |
Required gates with SARIF in code scanning (ADR 0006) |
| OpenSSF Scorecard | scorecard.yml |
The repository's own supply-chain practices |
Every workflow starts from permissions: {} and grants per job; actions and the shared reusable workflows are pinned
to full commit SHAs, and there is no pull_request_target. Pre-commit runs the same hygiene locally, plus
detect-secrets and conventional commit messages.
Branch protection on main requires these checks; the command that applies them is in the
deploy runbook:
| Check | Workflow |
|---|---|
verify |
ci.yml (make verify) |
lint-docs / markdownlint, lint-docs / links, lint-docs / vale |
ci.yml (shared lint-docs) |
lint-actions / actionlint, lint-actions / zizmor |
ci.yml (shared lint-actions) |
secrets / gitleaks |
ci.yml (shared secrets) |
container / hadolint, container / build-scan |
ci.yml (shared container) |
security / trivy |
ci.yml (shared security, Trivy on the repository) |
Semgrep (code), Trivy (image), Checkov (infra) |
security.yml (SARIF gates) |
- CI checks never touch AWS; only the deploy job does. The policies are tested offline with a mocked provider
(ADR 0008); that proves what the JSON says.
make test-livechecks how IAM evaluates it, but runs only by hand. - The GitLab pipeline is not run here. Its tests prove what the files say. GitLab's subject names the branch,
not the environment, and on the free tier
when: manualis a click, not an approval (ADR 0009). - The CodePipeline path runs only in
make test-live-codepipeline. It has no provenance or SBOM attestation, its build project runs Docker in privileged mode, and the GitHub connection needs one console handshake (ADR 0010). - Jenkins is covered by a documented pattern (docs/jenkins-pattern.md), with no pipeline code or tests.
- Some controls live in GitHub settings: the
productionenvironment's reviewer and branch policy, and branch protection with the required checks. The repository documents them; it cannot enforce them. - The permission policy limits where the job deploys, not what. Anyone who can merge to
mainand approveproductioncan ship any image that passes the gates. - ECR keeps the ten most recent images. More than ten builds without a successful deploy would expire the image the service runs, and a rollback or scale-out could not pull it. A production setup tags released digests and excludes them from the lifecycle rule.
- Attestations are verified by the pipeline, not by ECS. Someone with console access could register a task definition with an unverified image.
- One environment, one account. A real engagement adds staging in its own account with its own role and subject, a load balancer, and VPC endpoints for ECR, S3 and CloudWatch Logs so task egress no longer needs the internet.
- A pull request plan is a client-installed example.
examples/workflows/plan.ymland the optional plan role give same-repository pull requests read access to the stack and its state once a client installs them. That is acceptable for this stack and would not be for a stack whose state holds secrets (ADR 0007, ADR 0011).
- CI/CD pipeline to AWS on Upwork. This lab is the pattern that service delivers on a client's own repository and account.
- AWS DevOps portfolio: the index of every lab and sample deliverable.


