Overview
How HyperSaaS runs in production on AWS, why, and what it costs, plus the local setup and settings.
HyperSaaS deploys to AWS: the backend and frontend run as containers on ECS Fargate, behind one load balancer, with PostgreSQL (RDS), Valkey (ElastiCache) and a private S3 bucket. Terraform defines all of it, and GitHub Actions deploys every merge to main.
- AWS: first deployment takes you from an empty AWS account to a working staging site, in 10 steps.
- AWS: running it covers deploys, rollbacks, logs, keys, backups, costs and going to production.
The repository also has docker-compose.production.yml, which runs everything on one server behind Traefik. It builds the same images, but these docs cover AWS.
The AWS layout
flowchart TB
internet(["Internet"])
subgraph vpc["VPC 10.20.0.0/16, two availability zones"]
igw["Internet gateway"]
subgraph public["Public subnets"]
alb["Load balancer<br/>accepts 80 and 443 from anywhere"]
tasks["ECS tasks: web, worker, beat, frontend<br/>public IP, but accept traffic<br/>only from the load balancer"]
end
subgraph private["Private subnets (no internet route)"]
rds[("RDS PostgreSQL<br/>port 5432, from the tasks only")]
cache[("Valkey<br/>port 6379, from the tasks only")]
end
end
aws_services(["S3, ECR, Secrets Manager,<br/>CloudWatch, AI providers, Stripe"])
internet --> igw --> alb
alb -- "5000 (web), 3000 (frontend)" --> tasks
tasks --> rds
tasks --> cache
tasks -- "outbound" --> igw --> aws_services
The tasks sit in public subnets with public IP addresses. That lets them reach ECR, Secrets Manager and the AI providers without a NAT gateway. Their security group accepts connections only from the load balancer, so nothing on the internet can reach them directly. The database and the cache sit in private subnets with no route to the internet, and accept connections only from the tasks.
Who can do what (IAM)
No long-lived AWS keys exist anywhere: not in GitHub, and not in the containers.
flowchart LR
subgraph github["GitHub Actions"]
release["Release workflow<br/>(main branch)"]
deploy["Deploy workflow<br/>(staging environment, main only)"]
end
oidc["GitHub OIDC provider<br/>(in IAM)"]
publisher["image publisher role"]
deployrole["staging deploy role"]
ecr[("ECR")]
ecs["ECS: task definitions,<br/>services, one-off tasks"]
subgraph task["Each running task"]
execution["execution role<br/>pull the image, read the secrets,<br/>write logs"]
taskrole["task role<br/>S3 bucket, ECS Exec"]
end
release -- "short-lived token" --> oidc --> publisher -- "push images" --> ecr
deploy -- "short-lived token" --> oidc --> deployrole -- "register, run, update" --> ecs
ecs -. "starts tasks with" .-> task
-
The image publisher role (from the bootstrap stack) can push to the image repositories. Only workflows on the
mainbranch of your two repositories can use it. -
Each environment's deploy role can, in that environment's cluster only:
- register task definitions
- run the migration task
- update the services
- read the migration logs
Only jobs running in the matching GitHub environment can use it, and that environment only accepts the
mainbranch. -
The task role gives the backend its S3 access. Django uses it automatically, with no AWS keys set.
-
Repositories are named by number. GitHub's tokens name repositories by their numeric IDs (
owner@<id>/repo@<id>), and the roles trust those IDs. A renamed account, or a new repository with an old name, can't take a role over.
Infrastructure
| Piece | Setting in staging | Why |
|---|---|---|
| ECS on Fargate | web 0.5 vCPU / 1 GB, worker 2 vCPU / 8 GB, beat 0.25 vCPU / 0.5 GB, frontend 0.5 vCPU / 1 GB | Containers without servers to patch, sized per process. Rolling deploys, health checks and automatic rollback are built in. |
| Fargate Spot | On in staging | Up to 70% cheaper. AWS can stop a task with two minutes' notice, and ECS starts another. Use regular Fargate in production. |
| Application Load Balancer | TLS 1.3 policy, idle timeout 300 s | One entry point for both hosts. The long timeout keeps streamed replies open. |
| ACM | One certificate for both hosts | Free, and renews itself while its DNS records stay in place. |
| RDS PostgreSQL 17 | db.t4g.micro, 20 GB gp3 (grows to 200 GB), one zone, 7 days of backups; production: larger, with a standby in a second zone (db_multi_az) | Managed backups, patches and storage growth, with pgvector available. |
| ElastiCache Valkey 8 | cache.t4g.micro, one node, TLS; production: a replica that takes over (cache_replicas) | Redis-compatible and open source; AWS prices it below Redis on ElastiCache. |
| S3 | Private, encrypted, CORS for the app host | |
| Secrets Manager | Two secrets per environment | See the two secrets. |
| ECR | Three image repositories, plus a build cache | Image tags are commit SHAs and can't be overwritten, so a tag always means one build. The last 50 images are kept for rollbacks. |
| CloudWatch | Logs (kept 14 days) and alarms, emailed through SNS | Four alarms, listed below. |
| AWS Budgets | One monthly budget for the account | Emails at 80% of the forecast and at 100% of actual spend. |
| Terraform | 1.10 or newer | Everything above is code, reviewed with terraform plan before it changes. State lives in S3 with S3's own locking, so no DynamoDB table is needed. |
The CloudWatch alarms fire on:
- more than 10 server errors in 5 minutes
- an unhealthy web or frontend task for 3 minutes
- database CPU above 80% for 15 minutes
- less than 2 GB of free database storage
Why ECS on Fargate, compared with the alternatives:
| Option | Verdict |
|---|---|
| ECS on Fargate | Chosen. Runs the web, worker, beat and frontend containers as they are, each sized separately, with no servers to maintain. |
| Elastic Beanstalk | Its worker tier is built on SQS, not Celery, and keeping exactly one beat while scaling is awkward. |
| App Runner | Runs web services only, so there's nowhere for the worker or beat. |
| EKS (Kubernetes) | A cluster to run, at about $73 a month before any workload, for a handful of containers. |
| One server with Docker Compose | The cheapest and simplest, and docker-compose.production.yml still does it. But you run the operating system, the backups and failover yourself. |
Why there's no NAT gateway: a NAT gateway costs about $35 a month per zone, plus data charges, only so that private tasks can reach the internet. Public IP addresses cost about $3.65 a month each, and the security groups still keep the tasks unreachable from outside.
Why there are two backend images: Docling, PyTorch and their models add about 3 GB, and only the worker needs them. Fargate pulls the whole image every time a task starts. Without them, the web image (about 1 GB) starts faster and uses less memory.
CI/CD
| Workflow | Repository | When | Does |
|---|---|---|---|
| CI | both | Every push and pull request (the backend skips changes to docs alone) | Backend: the migrations check and the tests, against PostgreSQL with pgvector. Frontend: lint, tests, typecheck, build, image build. |
| Release | both | After CI passes on main, or by hand | Builds the images, pushes them to ECR tagged with the commit SHA, then runs Deploy for staging. |
| Deploy | both | Called by Release, or run by hand | New task definition revisions with the commit's images, migrations (backend), service updates. It waits for each rollout and fails if ECS rolls one back. |
| Images | backend | Pull requests that touch the Dockerfile or requirements; Mondays without the cache | Proves both images still build, before a deploy needs them. |
| Terraform | backend | Changes under infra/terraform | terraform fmt and validate. |
| Dependabot | both | Weekly | Update pull requests for dependencies, GitHub Actions and Docker base images. |
flowchart LR
merge(["Merge to main"]) --> ci["CI<br/>tests"]
ci -- "passed" --> images["Release<br/>build images"]
images -- "push, tagged with the commit" --> ecr[("ECR")]
images --> register["Deploy<br/>new task definition revisions"]
register --> migrate["Run migrations<br/>(one-off task)"]
migrate -- "succeeded" --> update["Update the services"]
update --> wait{"Rollout<br/>completed?"}
wait -- "yes" --> done(["Live on staging"])
wait -- "no: ECS rolled back" --> failed(["Job fails;<br/>the old version keeps running"])
migrate -- "failed" --> stopped(["Job fails;<br/>nothing changed"])
Two rules of the pipeline:
- Terraform owns the settings, and Deploy owns the image. Deploy copies the latest task definition revision of each process and changes only the image. A setting changed in Terraform (an environment variable, CPU, memory) reaches the running services at the next deploy.
- Migrations run while the old version still serves traffic. Each migration must work with the code before it. Add a column in one release and use it in the next; drop a column only once no running code reads it.
Local development
Locally, Docker Compose runs everything (docker-compose.local.yml; see Docker):
| Service | Image | Port | Purpose |
|---|---|---|---|
| Django | Custom (Python 3.12) | 8000 | API server (runserver_plus) |
| PostgreSQL | pgvector/pgvector:pg17 | 5432 | Database with vector search |
| Redis | redis:6 | 6379 | Cache + Celery broker |
| Celery Worker | Same as Django | — | Async task processing |
| Celery Beat | Same as Django | — | Scheduled tasks |
| Flower | Same as Django | 5555 | Celery monitoring |
| Mailpit | axllent/mailpit | 8025 | Email testing UI |
Settings
config/settings/
├── base.py # Shared settings (apps, middleware, DRF, sign-in and tokens, Celery)
├── local.py # DEBUG=True, Mailpit, LocMemCache, eager Celery
├── production.py # gunicorn, S3 for files, WhiteNoise for static files, Sentry, secure cookies, logging
└── test.py # Fast password hashing, in-memory emailSelect the settings module via DJANGO_SETTINGS_MODULE:
# Local
DJANGO_SETTINGS_MODULE=config.settings.local
# Production
DJANGO_SETTINGS_MODULE=config.settings.production
# Tests
DJANGO_SETTINGS_MODULE=config.settings.testKey dependencies
| Category | Package | Version |
|---|---|---|
| Framework | Django | 5.2 LTS |
| API | djangorestframework, drf-spectacular | 3.16, 0.28 |
| Auth | django-allauth (API mode, MFA), djoser | 65.19, 2.3 |
| Database | psycopg[c], pgvector | 3.2, 0.3 |
| Tasks | celery, django-celery-beat | 5.5, 2.9 |
| AI | langgraph, langchain (OpenAI, Anthropic, Google) | 0.4, 0.3 |
| Documents | docling (worker image only) | 2.126 |
| Payments | stripe | 12.0 |
| Storage, static files | django-storages[s3], whitenoise | 1.14, 6.12 |
| Monitoring | sentry-sdk, langfuse | 2.25, 4.15 |
| Server | gunicorn | 23.0 |
Python version
Python 3.12 is required. Docling and PyTorch don't support 3.13 yet.