Skip to content

Commit e52f0e4

Browse files
authored
feat: deploy API + workers to AWS Lambda via Terraform (#54)
* feat: deploy api + workers to AWS Lambda via Terraform 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 * chore(tf): allow overwrite on api A record 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. * chore: stub sentiment scoring, drop nltk 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 * docs: fold AWS deploy guide into the main README 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. * docs: depersonalize the AWS deploy section 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. * feat(tf): add allowed_account_ids guard 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. * chore: bump pinned versions to current 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. * chore(tf): expose region as an output The bootstrap commands in the README reference `terraform output -raw region`, which didn't exist. Adding it so those commands work as written. * feat(tf): auto-push bootstrap image so first apply doesn't need a manual 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. * chore: review-pass cleanup before merge - 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. * chore(tf): drop the OVH migration note on api A record 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). * feat(ci): add infra workflow so tofu apply can run from GitHub Actions 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. * feat: load app secrets from Secrets Manager at runtime 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 * chore(tf): enable S3 backend now that the state bucket exists * chore(tf): set diswho_base_url to the public diswho instance * chore: remove DISCORD_SECRET from the deployment 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. * chore: switch region from eu-west-3 to eu-west-1 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 * chore(tf): point backend at dumpus-prod-tfstate (matches stack naming) * fix(tf): two apply-time errors 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.
1 parent a8f13b2 commit e52f0e4

44 files changed

Lines changed: 1573 additions & 213 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,13 @@ POSTGRES_PORT="6641"
44
POSTGRES_DB="dumpus"
55
POSTGRES_URL="postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASS}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}"
66

7-
RABBITMQ_USER="dumpus"
8-
RABBITMQ_PASS="CHANGE_ME"
9-
RABBITMQ_PORT="5672"
10-
RABBITMQ_URL="amqp://${RABBITMQ_USER}:${RABBITMQ_PASS}@localhost:${RABBITMQ_PORT}/"
11-
127
API_PORT="5000"
138
DL_ZIP_WHITELISTED_DOMAINS=""
149
DL_ZIP_TMP_PATH="./tmp"
1510

16-
CELERY_QUEUE="regular_process"
17-
CELERY_HOSTNAME="regular_process"
11+
# `sync` runs the worker inline (default for local dev).
12+
# `sqs` (production) sends to AWS SQS — also requires SQS_QUEUE_URL.
13+
QUEUE_BACKEND="sync"
1814

1915
DISWHO_JWT_SECRET=""
2016
DISWHO_BASE_URL=""

.github/workflows/deploy.yml

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
name: deploy
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths-ignore:
7+
- "docs/**"
8+
- "infra/**"
9+
- "**.md"
10+
workflow_dispatch:
11+
inputs:
12+
image_tag:
13+
description: "Override image tag (defaults to git sha)"
14+
required: false
15+
16+
permissions:
17+
id-token: write # GitHub OIDC
18+
contents: read
19+
20+
env:
21+
AWS_REGION: eu-west-1
22+
ECR_REPO: dumpus-prod-lambda
23+
API_FUNCTION: dumpus-prod-api
24+
WORKER_FUNCTION: dumpus-prod-worker
25+
26+
jobs:
27+
build-and-deploy:
28+
runs-on: ubuntu-latest
29+
30+
steps:
31+
- uses: actions/checkout@v6
32+
33+
- name: Resolve image tag
34+
id: meta
35+
run: |
36+
if [ -n "${{ inputs.image_tag }}" ]; then
37+
tag="${{ inputs.image_tag }}"
38+
else
39+
tag="${GITHUB_SHA::12}"
40+
fi
41+
echo "image_tag=$tag" >> "$GITHUB_OUTPUT"
42+
43+
- name: Configure AWS credentials
44+
uses: aws-actions/configure-aws-credentials@v6
45+
with:
46+
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
47+
aws-region: ${{ env.AWS_REGION }}
48+
49+
- name: Login to ECR
50+
id: ecr
51+
uses: aws-actions/amazon-ecr-login@v2
52+
53+
- name: Set up Buildx
54+
uses: docker/setup-buildx-action@v4
55+
56+
- name: Build & push Lambda image
57+
id: build
58+
uses: docker/build-push-action@v7
59+
with:
60+
context: .
61+
file: Dockerfile.lambda
62+
push: true
63+
# Lambda only runs amd64 unless the function is configured for arm64.
64+
# Keep amd64 unless we explicitly switch the function architecture.
65+
platforms: linux/amd64
66+
provenance: false
67+
tags: |
68+
${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPO }}:${{ steps.meta.outputs.image_tag }}
69+
${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPO }}:latest
70+
cache-from: type=gha,scope=lambda
71+
cache-to: type=gha,scope=lambda,mode=max
72+
73+
- name: Update Lambda functions
74+
env:
75+
IMAGE_URI: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPO }}:${{ steps.meta.outputs.image_tag }}
76+
run: |
77+
set -euo pipefail
78+
for fn in "${API_FUNCTION}" "${WORKER_FUNCTION}"; do
79+
echo "::group::Updating $fn"
80+
aws lambda update-function-code \
81+
--function-name "$fn" \
82+
--image-uri "$IMAGE_URI" \
83+
--publish \
84+
> /dev/null
85+
aws lambda wait function-updated --function-name "$fn"
86+
echo "::endgroup::"
87+
done
88+
89+
- name: Smoke test API
90+
run: |
91+
set -euo pipefail
92+
for i in 1 2 3 4 5; do
93+
code=$(curl -s -o /dev/null -w "%{http_code}" https://api.dumpus.app/health || echo "000")
94+
if [ "$code" = "200" ]; then
95+
echo "API healthy"
96+
exit 0
97+
fi
98+
echo "attempt $i: $code, retrying..."
99+
sleep 5
100+
done
101+
echo "API never returned 200" >&2
102+
exit 1

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ node_test
1111

1212
# Env
1313
.env
14+
.env.*
15+
!.env.example
1416

1517
# Data
1618
scripts/res.txt
1719
*.db
1820

1921
# JetBrains IDE
20-
.idea
22+
.idea
23+
24+
# Claude Code
25+
.claude/

Dockerfile.api

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,8 @@
1-
# Last stable Python version
2-
FROM python:3.10-slim-buster
1+
FROM python:3.10-slim-bookworm
32

43
# Create application directory and move there
54
WORKDIR /app
65

7-
# Install nltk
8-
RUN pip install --no-cache-dir --prefer-binary nltk
9-
10-
# Copy the script that downloads the vader lexicon module
11-
COPY scripts/download-ntk.py ./
12-
13-
# Execute that script
14-
RUN python download-ntk.py
15-
166
# Copy requirements file from the host to the container
177
COPY requirements.txt ./
188

Dockerfile.flower

Lines changed: 0 additions & 26 deletions
This file was deleted.

Dockerfile.lambda

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Single image used by both Lambda functions (api + worker). The active
2+
# handler is selected per-function via the Lambda ImageConfig.Command.
3+
FROM public.ecr.aws/lambda/python:3.10
4+
5+
WORKDIR ${LAMBDA_TASK_ROOT}
6+
7+
COPY requirements.txt ./
8+
RUN pip install --no-cache-dir -r requirements.txt
9+
10+
# Flat layout: src/* sits at the task root so existing top-level imports
11+
# (`from tasks import ...`) keep working.
12+
COPY src/ ${LAMBDA_TASK_ROOT}/
13+
14+
# Default to the API handler. Override per function in Terraform/Lambda config.
15+
CMD [ "lambda_handlers.api.handler" ]

Dockerfile.worker

Lines changed: 0 additions & 31 deletions
This file was deleted.

Makefile

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,12 @@ install:
1414
uv venv --python 3.10
1515
. .venv/bin/activate
1616
uv pip install -r requirements.txt
17-
uv run ./scripts/download-ntk.py
1817

1918
dev:
2019
. .venv/bin/activate
2120
bash -c " \
2221
trap 'docker compose down' EXIT; \
23-
docker compose up -d broker db && \
22+
docker compose up -d db && \
2423
cd src && \
25-
tee \
26-
>(docker compose logs -f) \
27-
>(celery --app tasks worker --loglevel=info --queues=${CELERY_QUEUE} --hostname=${CELERY_HOSTNAME}@%h --concurrency=1) \
28-
>(waitress-serve --port=${API_PORT} app:app) \
29-
"
24+
QUEUE_BACKEND=sync waitress-serve --port=${API_PORT} app:app \
25+
"

README.md

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ API to extract statistics from the Discord Data Packages (GDPR packages). This A
66

77
* [Architecture Documentation](#architecture-documentation)
88
* [Self-hosting](#self-hosting)
9+
* [Deploy to AWS](#deploy-to-aws)
910
* [API Documentation](#api-documentation)
1011
* [Troubleshooting](#troubleshooting)
1112

@@ -32,18 +33,59 @@ Thus:
3233

3334
Anyone can host their own Dumpus instance. The official Dumpus client can then be configured to use it.
3435

36+
The worker no longer runs as a separate Celery process — set `QUEUE_BACKEND=sync` and the API processes packages inline on the request thread (good enough at small volume), or set `QUEUE_BACKEND=sqs` to dispatch to AWS SQS (used by the Lambda deployment).
37+
3538
* clone <https://github.com/dumpus-app/dumpus-api>
36-
* you can use Docker (easy way)
37-
* or you can install everything by yourself:
39+
* easy way: `cp .env.example .env`, fill it in, then `make up`
40+
* manual:
3841
- install requirements with pip
39-
- start a RabbitMQ server (or redis)
4042
- start a PostgreSQL server
41-
- fill the .env file with your Redis and PostgreSQL creds
42-
- start the API using `waitress-serve --port=5000 app:app`
43-
- start one worker using `celery --app tasks worker --loglevel=info --queues=regular_process --hostname=regular_process@%h --concurrency=1`
43+
- fill the .env file with your PostgreSQL creds
44+
- start the API: `QUEUE_BACKEND=sync waitress-serve --port=5000 app:app`
4445

4546
By default, Dumpus API will only treat zip files sent from `https://discord.click`. You can specify a `DL_ZIP_WHITELISTED_DOMAINS` environment variable to add other allowed domains.
4647

48+
## Deploy to AWS
49+
50+
A Terraform stack under `infra/terraform/` provisions a serverless deployment of the API on AWS:
51+
52+
| Component | AWS service |
53+
| ---------------- | ------------------------ |
54+
| API | Lambda (container image) behind API Gateway HTTP API |
55+
| Worker | Lambda triggered by SQS |
56+
| Database | RDS Postgres in private subnets |
57+
| Outbound NAT | fck-nat instance (NAT Gateway replacement) |
58+
| Secrets | Secrets Manager + Lambda env |
59+
| TLS / DNS | ACM cert + Route53 alias to API Gateway |
60+
| CI | GitHub OIDC role; build → ECR → `update-function-code` |
61+
62+
### Bootstrap
63+
64+
1. Create a public Route53 hosted zone for your domain and point your registrar's nameservers at it.
65+
2. `cp infra/terraform/terraform.tfvars.example infra/terraform/terraform.tfvars` and fill in `discord_secret`, `domain_name`, `github_repository`, region, etc.
66+
3. `cd infra/terraform && terraform init && terraform apply`.
67+
This single apply does everything: a `null_resource` pushes a placeholder image (the public AWS Lambda Python base) into ECR with the `:bootstrap` tag, then the Lambda functions are created against that placeholder. Requires `docker` and `aws` CLI on the apply host.
68+
4. Set the GitHub repo secret `AWS_DEPLOY_ROLE_ARN` from `terraform output -raw github_deploy_role_arn`. From here on, every push to `main` builds the real image in CI and rolls both Lambdas — no more local builds needed.
69+
70+
### Day-to-day deploys
71+
72+
Push to `main``.github/workflows/deploy.yml` builds the container, pushes to ECR tagged with the git SHA, and rolls both Lambdas via OIDC. No long-lived AWS keys in GitHub.
73+
74+
### Operations
75+
76+
```bash
77+
aws logs tail /aws/lambda/<name-prefix>-<env>-api --follow
78+
aws logs tail /aws/lambda/<name-prefix>-<env>-worker --follow
79+
aws sqs receive-message --queue-url "$(terraform output -raw sqs_dlq_url)"
80+
```
81+
82+
Things to keep in mind:
83+
84+
- **API cold start** is a few seconds while pandas imports. Invisible on the async submit/poll flow; use provisioned concurrency if a sync endpoint must be sub-second.
85+
- **Worker `/tmp` cap** defaults to 5GB (max 10GB). Bump `worker_ephemeral_storage_mb` if users upload very large Discord exports.
86+
- **Worker timeout** is 15 min (Lambda hard cap). Failures land in the DLQ after one retry.
87+
- **fck-nat is a single instance.** Switch to a managed NAT Gateway if you need the extra availability — at the cost of a much higher fixed monthly bill.
88+
4789
## API Documentation
4890

4991
One header is required for all the requests except the `POST /process` one:
@@ -167,12 +209,5 @@ Current error message codes:
167209

168210
## Troubleshooting
169211

170-
* Server does not respond after `POST /process`. Try to remove this property from the celeryconfig.py file.
171-
```
172-
broker_use_ssl={
173-
'ssl_cert_reqs': None
174-
}
175-
```
176-
177-
* API server is crashing and say that Postgres is not supported.
212+
* API server is crashing and says that Postgres is not supported.
178213
Make sure that your PostgreSQL server URL starts with **postgresql://** and not **postgres://**, which is no longer supported by SQLAlchemy.

captain.flower

Lines changed: 0 additions & 4 deletions
This file was deleted.

0 commit comments

Comments
 (0)