Skip to content

feat: deploy API + workers to AWS Lambda via Terraform#54

Merged
Androz2091 merged 19 commits into
mainfrom
feat/aws-lambda-deploy
May 1, 2026
Merged

feat: deploy API + workers to AWS Lambda via Terraform#54
Androz2091 merged 19 commits into
mainfrom
feat/aws-lambda-deploy

Conversation

@Androz2091

@Androz2091 Androz2091 commented Apr 30, 2026

Copy link
Copy Markdown
Member

Summary

  • Move the stack from always-on docker-compose (API + Celery worker + RabbitMQ) to AWS Lambda + SQS + RDS, deployed via Terraform
  • API runs as a Lambda behind API Gateway HTTP API at api.dumpus.app; worker is an SQS-triggered Lambda that replaces the Celery worker
  • Drops Celery and RabbitMQ entirely. New enqueue.py picks SQS in prod / a synchronous inline path for local dev
  • CI builds a single container image, pushes to ECR, and rolls both Lambdas via GitHub OIDC (no long-lived AWS keys)

Architecture

api.dumpus.app → API Gateway HTTP API → API Lambda
                                          │ enqueue
                                          ▼
                                        SQS ──► Worker Lambda
                                          │       │
                                          ▼       ▼
                                        DLQ      RDS Postgres
  • VPC with private subnets for RDS + Lambda ENIs
  • fck-nat on t4g.nano replaces the managed NAT Gateway
  • Worker Lambda: 3008 MB RAM, 5 GB /tmp, 15-min timeout, reserved concurrency 1

What changed in app code

  • src/tasks.py: handle_package (Celery task) replaced by process_package (plain function). No Celery import, no celeryconfig.py
  • src/enqueue.py: QUEUE_BACKEND=sqs (prod) or sync (local dev, default)
  • src/lambda_handlers/api.py: Flask wrapped via apig-wsgi
  • src/lambda_handlers/worker.py: SQS handler with partial-batch-failure reporting
  • Dockerfile.lambda: single image used by both functions; handler selected via Lambda ImageConfig.Command
  • docker-compose.yml, Makefile, .env.example: simplified — no more broker/worker container
  • Sentiment scoring stubbed to a constant 0.5 with a TODO; nltk + vader_lexicon dropped from the image

Required to deploy

  1. Hosted zone for dumpus.app in Route 53 (delegated from registrar)
  2. cp infra/terraform/terraform.tfvars.example terraform.tfvars, fill discord_secret and github_repository
  3. terraform init && terraform apply
  4. Push the bootstrap image (commands in the README)
  5. Set AWS_DEPLOY_ROLE_ARN GitHub secret from terraform output github_deploy_role_arn

Test plan

  • terraform validate and terraform plan succeed against a fresh AWS account
  • First terraform apply creates VPC / RDS / SQS / Lambdas / API GW / ACM cert without manual intervention
  • Bootstrap image push succeeds; both Lambdas reach Active state
  • curl https://api.dumpus.app/health returns 200
  • Submitting a real Discord package via POST /process enqueues to SQS and the worker Lambda processes it end-to-end (status flips to PROCESSED)
  • Failed packages land in the DLQ with a single retry
  • CI workflow on push to main builds, pushes, redeploys both Lambdas, and the smoke-test passes
  • Local make dev still works in QUEUE_BACKEND=sync mode

Move from docker-compose to a fully-managed AWS deployment:
- API as a Lambda behind API Gateway HTTP API at api.dumpus.app
- Worker as an SQS-triggered Lambda (replaces the always-on Celery worker)
- RDS Postgres 17 + fck-nat for ~$22/mo total
- Drops Celery + RabbitMQ entirely; QUEUE_BACKEND=sync runs the worker
  inline for local dev, QUEUE_BACKEND=sqs in production
- GitHub Actions deploy via OIDC: build container -> ECR -> update both Lambdas
- Terraform stack under infra/terraform with full bootstrap docs
During the OVH→Route53 cutover the api record is pre-populated with
the existing origin IP to avoid downtime while nameservers propagate.
allow_overwrite = true lets Terraform take ownership and swap it for
the API Gateway alias on apply.
Sentiment analysis pulled in nltk + a baked-in vader_lexicon (~50MB),
which inflated the Lambda image and added cold-start cost. Stub
count_sentiments() to a constant 0.5 for now. The TODO in tasks.py
calls out how to revive it.

- remove nltk from requirements
- drop the vader_lexicon download from both Dockerfiles
- drop scripts/download-ntk.py and the install-step Makefile reference
Move the contents of infra/terraform/README.md into a new "Deploy to AWS"
section in the project README, so contributors discover it from one place.
Also refresh the self-hosting steps for the post-Celery world (sync mode,
no broker) and drop the now-stale celeryconfig.py troubleshooting tip.
The README is shipped with the project; cost figures, traffic estimates,
and credit accounting only make sense for one operator and confuse
everyone else who self-hosts. Drop those, generalize the log-group paths,
and tighten the language.
@Androz2091 Androz2091 force-pushed the feat/aws-lambda-deploy branch from 57e4e3f to f097593 Compare April 30, 2026 11:13
Prevents an apply from touching the wrong AWS account when AWS_PROFILE
or env credentials accidentally point somewhere else (e.g. a company
account on the same workstation). When set in terraform.tfvars, the AWS
provider hard-fails before planning if the resolved account isn't in
the allow-list.
Latest releases checked against the upgrade guides; only one breaking
change in our resource set (aws_ami stricter validation).

- AWS provider 5.70 -> 6.0 (latest 6.43)
- Add explicit owner-id filter to aws_ami.fck_nat for v6's stricter
  most_recent validation
- apig-wsgi 2.18.0 -> 2.20.0
- actions/checkout v4 -> v6
- aws-actions/configure-aws-credentials v4 -> v6
- docker/setup-buildx-action v3 -> v4
- docker/build-push-action v6 -> v7

Python 3.10 is deprecated by Lambda on Oct 31 2026. Bumping the runtime
needs numpy/pandas/Flask version bumps and is left for a follow-up PR.
The bootstrap commands in the README reference `terraform output -raw region`,
which didn't exist. Adding it so those commands work as written.
…ual step

CreateFunction validates that the image_uri exists, so the very first apply
used to fail unless you'd manually pushed a :bootstrap image to ECR first.

Add a null_resource that runs once after the ECR repo is created: pulls
public.ecr.aws/lambda/python:3.10, retags it to :bootstrap, pushes to our
ECR. Lambdas depend on it, so apply ordering becomes ECR -> placeholder
image -> Lambdas. Idempotent: re-applies skip if the tag already exists.

After first apply, CI takes over. The placeholder image gets overwritten by
the first push to main, and Terraform never touches image_uri again
(lifecycle.ignore_changes already in place).

Requires docker + aws CLI on the apply host.
- secrets.tf: drop special chars from RDS master password. !#$%&*()-_=+[]{}<>:?
  included `:`, `?`, `&`, `=` which would break SQLAlchemy URL parsing
  any time the random password happened to contain one. 32 alnum chars give
  ~190 bits of entropy anyway. (real bug)
- Dockerfile.api: bookworm replaces buster (Debian 10 EOL'd Jun 2024).
- bootstrap.tf: pin the placeholder image to python:3.13 instead of 3.10
  (3.10 is being deprecated by Lambda). The placeholder is replaced on
  first deploy, so this is purely about which tag we pull from public ECR.
- main.tf: drop unused common_tags local and aws_caller_identity data.
- variables.tf: rephrase worker_reserved_concurrency description to avoid
  project-specific framing.
- terraform.tfvars.example: rewrite the image_tag comment so it matches
  the actual lifecycle (Terraform owns the first apply, CI owns updates,
  ignore_changes prevents drift).
- .gitignore: stop ignoring .terraform.lock.hcl — provider lock should be
  committed so applies are reproducible. Adds the lock at v6.43.0.
The migration is one-time. Once it's done, the comment is archaeology;
allow_overwrite is meaningful on its own (lets Terraform take ownership
of an existing record).
Lets users with limited local bandwidth (provider download is ~600MB)
run plan/apply from CI instead. The workflow uses static AWS keys
stored as GH secrets for the bootstrap, and remote state in S3 (set up
out-of-band via 'aws s3 mb' before the first run). After the OIDC role
is created on first apply, deploy.yml takes over for code deploys.

terraform.ci.tfvars carries the non-sensitive vars (region, account ID,
domain). Sensitive vars (discord_secret, diswho_jwt_secret) come in via
TF_VAR_* env from GH secrets and never land in the repo.
Lambda env no longer carries the actual secret values — only a JSON map
of ARNs (SECRETS_ARN_MAP). On cold start, secrets_loader.py reads the
real values from Secrets Manager via boto3 and hydrates os.environ, so
the rest of the app keeps using os.getenv() unchanged.

What this buys us:
- secret values aren't visible to anyone with lambda:GetFunction
- rotate via 'aws secretsmanager put-secret-value' without redeploying
  the Lambda (versioned secret_string + ignore_changes prevents tofu
  apply from reverting the rotation)
- every secret read shows up in CloudTrail

Other changes:
- add WH_URL secret + variable so internal Discord webhook notifications
  work in prod (was missing entirely before)
- discord_secret defaults to '' since it's only used as a fallback when
  diswho_base_url is unset
- gitignore .env.* (except .env.example) so .env.bootstrap can't be
  accidentally committed
- workflow plumbs TF_VAR_wh_url alongside the other secret vars
The fallback path in util.py.fetch_diswho_user that uses DISCORD_SECRET
only runs when DISWHO_BASE_URL is unset. Our deployment has it set, so
the secret is dead config. Drop:

- the discord_secret TF variable
- the Secrets Manager entry + version
- the IAM read permission
- the Lambda env entry in SECRETS_ARN_MAP
- the TF_VAR_discord_secret plumbing in infra.yml
- the matching GH repo secret

util.py keeps the fallback so self-hosters without a diswho instance
can still configure DISCORD_SECRET via env.
Better service availability and lower cost. Latency from FR is ~30ms
worse but still acceptable for this app's UX. Stack-wide change:

- variables.tf, terraform.ci.tfvars, terraform.tfvars.example: var.region default
- backend.tf: state bucket region (the bucket itself is being recreated
  in eu-west-1 out-of-band)
- infra.yml + deploy.yml: aws-region used by the workflows
1. New AWS accounts have a 10-execution Lambda concurrency ceiling and
   require >=10 unreserved at all times, so reserved_concurrent_executions=1
   was rejected with InvalidParameterValueException. Make the variable
   optional (0 = no reservation), default to 0.

2. EIP association raced with EC2 instance creation — the instance was
   still in pending-instance-creation state when the EIP tried to attach
   to its ENI. Add depends_on so the EIP attaches first, then the
   instance picks up the already-EIP-bound ENI.
@Androz2091 Androz2091 merged commit e52f0e4 into main May 1, 2026
1 check passed
@Androz2091 Androz2091 deleted the feat/aws-lambda-deploy branch May 1, 2026 02:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant