HyperSaaS
BackendDeployment

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 main branch 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 main branch.

  • 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

PieceSetting in stagingWhy
ECS on Fargateweb 0.5 vCPU / 1 GB, worker 2 vCPU / 8 GB, beat 0.25 vCPU / 0.5 GB, frontend 0.5 vCPU / 1 GBContainers without servers to patch, sized per process. Rolling deploys, health checks and automatic rollback are built in.
Fargate SpotOn in stagingUp to 70% cheaper. AWS can stop a task with two minutes' notice, and ECS starts another. Use regular Fargate in production.
Application Load BalancerTLS 1.3 policy, idle timeout 300 sOne entry point for both hosts. The long timeout keeps streamed replies open.
ACMOne certificate for both hostsFree, and renews itself while its DNS records stay in place.
RDS PostgreSQL 17db.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 8cache.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.
S3Private, encrypted, CORS for the app host
Secrets ManagerTwo secrets per environmentSee the two secrets.
ECRThree image repositories, plus a build cacheImage tags are commit SHAs and can't be overwritten, so a tag always means one build. The last 50 images are kept for rollbacks.
CloudWatchLogs (kept 14 days) and alarms, emailed through SNSFour alarms, listed below.
AWS BudgetsOne monthly budget for the accountEmails at 80% of the forecast and at 100% of actual spend.
Terraform1.10 or newerEverything 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:

OptionVerdict
ECS on FargateChosen. Runs the web, worker, beat and frontend containers as they are, each sized separately, with no servers to maintain.
Elastic BeanstalkIts worker tier is built on SQS, not Celery, and keeping exactly one beat while scaling is awkward.
App RunnerRuns 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 ComposeThe 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

WorkflowRepositoryWhenDoes
CIbothEvery 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.
ReleasebothAfter CI passes on main, or by handBuilds the images, pushes them to ECR tagged with the commit SHA, then runs Deploy for staging.
DeploybothCalled by Release, or run by handNew task definition revisions with the commit's images, migrations (backend), service updates. It waits for each rollout and fails if ECS rolls one back.
ImagesbackendPull requests that touch the Dockerfile or requirements; Mondays without the cacheProves both images still build, before a deploy needs them.
TerraformbackendChanges under infra/terraformterraform fmt and validate.
DependabotbothWeeklyUpdate 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):

ServiceImagePortPurpose
DjangoCustom (Python 3.12)8000API server (runserver_plus)
PostgreSQLpgvector/pgvector:pg175432Database with vector search
Redisredis:66379Cache + Celery broker
Celery WorkerSame as Django—Async task processing
Celery BeatSame as Django—Scheduled tasks
FlowerSame as Django5555Celery monitoring
Mailpitaxllent/mailpit8025Email 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 email

Select 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.test

Key dependencies

CategoryPackageVersion
FrameworkDjango5.2 LTS
APIdjangorestframework, drf-spectacular3.16, 0.28
Authdjango-allauth (API mode, MFA), djoser65.19, 2.3
Databasepsycopg[c], pgvector3.2, 0.3
Taskscelery, django-celery-beat5.5, 2.9
AIlanggraph, langchain (OpenAI, Anthropic, Google)0.4, 0.3
Documentsdocling (worker image only)2.126
Paymentsstripe12.0
Storage, static filesdjango-storages[s3], whitenoise1.14, 6.12
Monitoringsentry-sdk, langfuse2.25, 4.15
Servergunicorn23.0

Python version

Python 3.12 is required. Docling and PyTorch don't support 3.13 yet.

On this page