Deployment¶
Infrastructure is AWS CDK (TypeScript) under .aws/iac; the application ships as a Docker image built by CodeBuild and run on EC2-backed ECS. A merge to main builds the image, pushes it to ECR, registers a new task definition, and rolls the ECS service. See ADR-0002 for why CDK + EC2 ECS + Aurora Serverless v2.
Environments¶
| Environment | URL | Trigger |
|---|---|---|
| Dev (AWS) | https://dev-cslewis.fueled.engineering |
Push to main touching src/**, .aws/**, or pyproject.toml |
| Production | https://app.cslewis.com (pre-prod / staging until launch) |
Push of a v*.*.* tag |
Both environments are live over HTTPS. Dev and prod each run all four stacks with the real Django app; /-/health/ returns {"healthy": true} (Aurora + Redis wired) and HTTP 301-redirects to HTTPS. Prod (app.cslewis.com, Cloudflare zone cslewis.com) went live 2026-08-25 at tag v0.1.0 and is used as a content-prep / pre-prod environment until public launch.
AWS account¶
| Value | |
|---|---|
| Account ID | 987292390939 |
| Region | us-east-2 |
| CLI profile | cslewis (all just recipes default to it) |
The account resolves automatically from the active profile — CDK reads CDK_DEFAULT_ACCOUNT, so nothing hardcodes the ID in the stacks. Set export AWS_PROFILE=cslewis (or pass profile=<name> to just) before running anything below.
Prerequisites (One-Time Setup)¶
Done once per AWS account. Run CDK commands from .aws/iac (npm install first). The just recipes wrap the CDK CLI and default to the cslewis AWS profile.
- Bootstrap CDK (once per account/region):
- Deploy the shared stack (ECR repo + GitHub OIDC provider/role — account-wide, no env):
- Push the seed image so the first compute deploy comes up healthy before the real image exists (the ECS service points at
:seed, an nginx placeholder that returns 200 on/-/alive/): - Request an ACM public certificate (us-east-2) for the env hostname, DNS-validated in the Cloudflare zone. Add the ACM validation CNAME as DNS-only (grey cloud) — if it's proxied, validation never completes. Once the cert is
Issued, put its ARN in.aws/iac/config/<env>/compute.yaml→acm_ssl_certand redeploycomputeto add the :443 listener + an 80→443 redirect. Whileacm_ssl_certis empty the ALB is HTTP-only so infra can be validated first. - Cloudflare:
- Create a proxied (orange-cloud) DNS record for the app hostname → the ALB DNS name.
- Set SSL/TLS mode to Full (strict), but scope it to the hostname with a Configuration Rule (Rules → Configuration Rules: Hostname equals
<host>→ SSL → Full (strict)), not the zone default. The encryption mode is zone-wide by default, so flippingfueled.engineeringitself to strict can break other subdomains whose origins don't serve a valid public cert (Cloudflare 525/526). Conversely, leaving the origin on the zone's Flexible default makes Cloudflare talk HTTP to the ALB, hit the 80→443 redirect, and loop → 503. The per-hostname rule avoids both. - The ALB security group already restricts ingress to Cloudflare's published IP ranges (
restrict_alb_to_cloudflare: true; opened on both 80 and 443). - For prod, add Authenticated Origin Pulls (mTLS) for stronger origin lockdown.
- GitHub → AWS: set the repository variable
AWS_ACCOUNT_IDto987292390939(used by the workflows to assumegithub-actions-cs-lewis-backendvia OIDC). CreatedevandprodGitHub Environments. - Connect CodeBuild to GitHub (CodeConnections) —
aws-codebuild-run-buildoverrides the build source to the GitHub repo, so the account needs a GitHub source credential (this step is separate from the OIDC role above):The# a. create the connection (returns an ARN; status PENDING) aws codeconnections create-connection --provider-type GitHub --connection-name cslewis-github # b. complete the handshake in the console: Developer Tools → Settings → Connections → # the pending connection → "Update pending connection" → authorize "AWS Connector for # GitHub" and install it on the Fueled org WITH access to the cs-lewis-backend repo # c. register it as CodeBuild's account/region-wide GitHub credential aws codebuild import-source-credentials --server-type GITHUB \ --auth-type CODECONNECTIONS --token <connectionArn>computestack grants the CodeBuild rolecodeconnections:UseConnection. If the AWS Connector GitHub App lacks repo access, the build fails at DOWNLOAD_SOURCE with "repository not found" — fix in GitHub → org Settings → GitHub Apps → AWS Connector for GitHub → Repository access (org owner). A missing credential instead fails with "authentication required". - Populate secrets (see Configuration & Secrets).
Infrastructure Deployment¶
Stacks deploy in dependency order: shared → network → compute → storage. Deploying an env with no stack argument does all three env stacks in order.
just deploy dev network # VPC
just deploy dev compute # ECS cluster, ALB, ASG, CodeBuild, IAM, SSM params
just deploy dev storage # Aurora Serverless v2, Redis, S3 uploads bucket
# or all at once:
just deploy dev
Inspect changes before applying with just diff dev <stack> or render templates with just synth dev <stack>.
After first compute + storage deploy, wire the runtime secret (below) so the real app can boot, then push code to trigger the app deploy.
Application Deployment (CI/CD)¶
Flow: GitHub Actions → OIDC role → CodeBuild → ECR → ECS.
- GitHub Actions (
.github/workflows/dev-aws-backend.yml) assumes the OIDC role and starts the CodeBuild projectcslewis-app-<env>withbuildspec-<env>.yaml. - CodeBuild builds
.aws/docker/Dockerfile-aws, tags the image with the commit SHA (andlatest), and pushes to ECR. - It renders
.aws/codebuild/td-cs-lewis-backend.jsonwithenvsubst(viarender-task-defs.sh), registers a new task-definition revision, and callsecs update-service --force-new-deployment. - ECS rolls the service (circuit breaker on — a failed rollout auto-rolls-back).
Dev deploys on every qualifying push to main; prod deploys on a v*.*.* tag.
Task-def ownership split (gotcha). CDK creates the initial task definition, but CodeBuild overrides it on every deploy from
td-cs-lewis-backend.json. Environment-variable and secret changes must go in that JSON template (and, for new secrets, the CDK secrets map + the render script whitelist) — editing only the CDK stack will not affect running tasks.
Post-deploy data seeding¶
The pipeline runs migrate automatically before each rollout, but content data is not seeded by the pipeline — a freshly deployed environment has an empty CMS. Populate it by running the management commands as one-off ECS tasks (the same run-task mechanism as the migrate step). All are idempotent.
The container runs from WORKDIR /app/src, so pass absolute content paths (/app/content/...); a repo-relative path such as content/ingestion/... resolves against /app/src and fails with "directory not found".
sync_themes— the controlled Theme vocabulary (run first; book imports reference it). Real data; run in every environment.seed_tags— sample/bootstrap Tag data. Do not run in prod: the Tag vocabulary is editorial and built by editors in the CMS. Treat it likeseed(dev-only sample data).import_book …— a processed book's Book + draft Passages. See the Book Processing guide for the exactrun-taskinvocation.import_articles --posts /app/content/ingestion/substack/posts— the Substack editorial snapshot as draft Articles (only the 28 client-licensed posts, percontent/ingestion/substack/licensed-slugs.txt; the snapshot is committed undercontent/ingestion/, so it's in the image). Samerun-taskmechanism.
seed (sample dev users) is a local-dev convenience — its users are DEBUG-gated, so it is effectively a no-op in AWS and isn't run there. Likewise, seed_tags produces sample tags and is not run in prod (see above).
No sample data in prod. Only real content goes into the prod CMS: the controlled Themes, licensed Articles, and processed Books. Placeholder/sample seeders (
seed,seed_tags,seed_writings,seed_related) are dev-only.
Configuration & Secrets¶
- Non-secret runtime config is set as plain env vars in
.aws/codebuild/td-cs-lewis-backend.json(DJANGO_SETTINGS_MODULE,SITE_DOMAIN,DJANGO_DEBUG,DJANGO_LOG_LEVEL,SENTRY_ENVIRONMENT,AWS_S3_UPLOADS_BUCKET,FIREBASE_PROJECT_ID,FIREBASE_WEB_API_KEY,DEV_AUTH_ENABLED). The Firebase values are public identifiers (they ship in the mobile app binary);DEV_AUTH_ENABLEDisTrueon dev,Falseon prod — it gates the dev-login endpoint that mints bearer tokens without Firebase. - Secrets live in one AWS Secrets Manager secret per env,
cslewis-app-<env>, as a JSON blob. The CDK compute stack creates it with empty placeholders on first deploy; populate the real values afterward:aws secretsmanager put-secret-value --secret-id cslewis-app-dev --secret-string '{ "DATABASE_URL": "postgres://USER:PASS@HOST:5432/cslewisdb", "REDIS_URL": "redis://HOST:6379/0", "DJANGO_SECRET_KEY": "...", "SENTRY_DSN": "...", "ANTHROPIC_API_KEY": "...", "ELEVENLABS_API_KEY": "..." }' --profile cslewisDATABASE_URL/REDIS_URLcome from thestoragestack (Aurora credentials secretrds-credentials-<env>+ the Aurora/Redis endpoints; internal hostnames are also published in the private Route53 zone). The task definition injects each key as an env var viavalueFrom. - SSM parameters under
/cslewis/<env>/(aws_account_id,secret_arn,task_role_arn,execution_role_arn,cluster_name, …) are written by the compute stack and read by the buildspec — no manual setup.
Rollback¶
- Fast path: the ECS deployment circuit breaker auto-rolls-back a rollout whose tasks never become healthy.
- Manual: re-point the service at a previous task-definition revision: Or re-run the deploy for a known-good commit SHA. Images are retained in ECR (last 10).
- Infrastructure:
just diffbefore any re-deploy; CDK/CloudFormation rolls a failed stack update back automatically. Data stores useRemovalPolicy.RETAIN(prod Aurora also has deletion protection) so a stack rollback never drops data. - Failed
computecreate (first bring-up): if the ECS rollout circuit breaker trips during the initial create, the stack can land inROLLBACK_FAILED(the ASG capacity provider is "in use"). To reset:aws cloudformation delete-stack, then — because the EC2 host keeps ECS managed scale-in protection and stalls the ASG teardown — clear it withaws autoscaling set-instance-protection --auto-scaling-group-name cslewis-app-<env>-asg --instance-ids <id> --no-protected-from-scale-in. After delete, remove the twoRETAINed, fixed-name leftovers or the redeploy hits "already exists": the log groupcslewis-app-<env>and the bucketcslewis-app-alb-logs-<env>-<account>.
Post-Deploy Verification¶
# Liveness (no DB) and readiness (DB + cache) — through Cloudflare:
curl -fsS https://dev-cslewis.fueled.engineering/-/alive/ # -> ok
curl -fsS https://dev-cslewis.fueled.engineering/-/health/ # -> {"healthy": true}
Also confirm: the ECS service shows the new task-def revision and a steady running count; the ALB target group is healthy; CloudWatch logs (cslewis-app-<env>) show granian serving without boot errors; the Wagtail and Django admin login pages load at their configured prefixes (WAGTAIL_ADMIN_URL / DJANGO_ADMIN_URL; defaults studio/ and backstage/, not /cms/ and /admin/).