LLD — Deployment and CI/CD¶
GitHub Actions is the only pipeline. It authenticates to AWS through OIDC (no stored keys), deploys with AWS CDK (Python), and shifts Lambda traffic with CodeDeploy canaries that roll back automatically on alarms.
1. Pipeline¶
flowchart LR
pr["Pull request"] --> ci
subgraph ci["ci.yml (every push and PR)"]
lint["Lint and format<br/>ruff"]
types["Type check<br/>pyright strict"]
unit["Unit + property tests"]
integ["Integration tests<br/>testcontainers, LocalStack"]
sec["Security<br/>gitleaks, bandit, pip-audit"]
build["Build images<br/>buildx, arm64, cache"]
scan["Trivy scan"]
docs["Docs build<br/>mkdocs --strict"]
synth["cdk synth + cdk-nag"]
end
ci -->|"merge to main"| cd
subgraph cd["cd.yml (main)"]
oidc["Assume deploy role<br/>OIDC"]
push["Push images to ECR<br/>by digest"]
deploy["cdk deploy<br/>new Lambda versions"]
canary["CodeDeploy canary<br/>10% for 5 min"]
smoke["Smoke + e2e tests<br/>real Cognito tokens"]
done["100% traffic"]
end
oidc --> push --> deploy --> canary --> smoke --> done
canary -->|"alarm in ALARM"| rollback["Automatic rollback<br/>alias back to previous version"]
smoke -->|"failure"| rollback
Notice: images are built and scanned once in CI and deployed by digest, so what was tested is exactly what runs.
| Workflow | Trigger | Purpose |
|---|---|---|
ci.yml |
push, pull request | Quality gates; required for merge |
cd.yml |
push to main (after CI), manual dispatch |
Build, push, deploy, canary, e2e |
eval.yml |
nightly, manual | RAG evaluation report and badges |
load.yml |
manual | Locust against the deployed stack; SLO report |
docs.yml |
push to main touching docs/ |
Publish MkDocs site to GitHub Pages |
release-please.yml |
push to main |
Changelog and version tags from Conventional Commits |
Deploy steps are guarded by repository variable AWS_DEPLOY_ENABLED until the infrastructure phase (P6) lands.
2. OIDC trust¶
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::<project-account-id>:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:<owner>/ledgerlens-mcp:environment:production"
}
}
}
| Rule | Reason |
|---|---|
sub pinned to the production environment |
Only jobs that pass environment protection can deploy |
Role permissions limited to CDK bootstrap roles (cdk-*-deploy-role, cdk-*-file-publishing-role, cdk-*-image-publishing-role) |
CloudFormation does the actual provisioning with its execution role |
| Session duration 1 hour |
3. Environments¶
| Environment | Where | Data | Purpose |
|---|---|---|---|
local |
docker compose |
Seeded synthetic data | Development, integration tests, Grafana dashboards |
ci |
GitHub runners (containers) | Ephemeral, seeded per job | Automated tests |
production |
AWS eu-north-1 |
Seeded synthetic data | Public demo endpoint |
One AWS environment keeps cost down; canary + rollback provide the safety a staging stage would otherwise give.
4. Container image strategy¶
One parametrised docker/Dockerfile builds every Python deployable (--build-arg SERVICE=<name>). Stages:
flowchart LR
M["manifests<br/>pyproject.toml + uv.lock only"] --> B1["builder: uv sync<br/>third-party deps"]
B1 --> B2["builder: editable install<br/>of the service (.pth only)"]
B2 --> R1["runtime layer: /app/.venv<br/>~200 MB, rarely changes"]
S["service source"] --> R2["runtime layer: /src<br/>KBs, precompiled .pyc"]
L["Lambda Web Adapter"] --> R0["runtime: /opt/extensions"]
Source edits only touch the last, tiny layer: a code-only rebuild takes ~7 s instead of ~155 s.
The virtualenv layer is reproducible bit for bit: uv's uv_cache.json (source timestamps used only for uv's own cache) is removed, so BuildKit's content-addressed cache reuses the layer across code changes.
| Practice | Effect |
|---|---|
Multi-stage; runtime stage has no uv, compilers or source tree |
Smaller image, smaller attack surface |
| Dependency layer separated from source layer; venv layer reproducible | Code changes rebuild in ~7 s |
--frozen from uv.lock |
Reproducible builds; lockfile drift fails the build |
BuildKit cache mounts + GitHub Actions cache (type=gha) |
Warm CI builds |
linux/arm64 (Graviton) |
~20% cheaper Lambda compute |
| Non-root user (uid 10001); code root-owned and read-only | Process cannot modify itself |
| Lambda Web Adapter as extension | Same image serves HTTP locally and on Lambda; readiness via /readyz |
| Base images pinned by tag and digest, scanned by Trivy (fails on fixable HIGH/CRITICAL) | Reproducible, known CVE baseline |
Exact base and adapter versions are pinned in the Dockerfile and bumped by Dependabot. Target uncompressed size: < 250 MB per image.
5. Lambda versions, aliases and canary¶
| Element | Configuration |
|---|---|
| Versions | Every deploy publishes a new immutable version |
| Alias | live; API Gateway integrates with the alias, never $LATEST |
| Deployment config | LambdaCanary10Percent5Minutes |
| Rollback alarms | Alias Errors > 1% over 1 min, p95 Duration > SLO × 1.5, gateway 5xx rate > 2% |
| Hooks | Pre-traffic hook Lambda calls /healthz and one read tool with a client-credentials token |
| Database migrations | Run by a one-off migration task before traffic shift; migrations are backward compatible (expand → migrate → contract across releases) |
sequenceDiagram
autonumber
participant CF as CloudFormation
participant CD as CodeDeploy
participant L as Lambda alias live
participant CW as CloudWatch alarms
CF->>L: Publish version N+1
CF->>CD: Start deployment (N -> N+1)
CD->>CD: Run pre-traffic hook
CD->>L: Route 10% to N+1
loop 5 minutes
CD->>CW: Check alarms
end
alt alarms OK
CD->>L: Route 100% to N+1
else alarm fired
CD->>L: Route 100% back to N
CD-->>CF: Deployment failed (stack update rolls back)
end
Notice: a bad release only ever reaches 10% of traffic for at most 5 minutes before an alarm returns all traffic to the previous version.
6. Rollback and recovery¶
| Failure | Recovery |
|---|---|
| Canary alarm | Automatic; alias back to previous version |
| Post-deploy e2e failure | cd.yml triggers CodeDeploy stop with rollback |
| Bad migration | Forward-fix only; migrations are additive, so the previous code version still runs |
| Corrupted data in demo | poe seed --reset reloads deterministic data |