Best practices
EC2 or Lambda?
Both targets run your code on AWS, but the operational model is fundamentally different. Picking the wrong one for your workload costs either money or operational headaches, and often both.
Choose Lambda when
- Traffic is spiky or unpredictable. Lambda scales to zero between bursts, so you pay nothing during quiet periods. An EC2 instance runs (and bills) continuously even at idle.
- The workload is event-driven. Webhook receivers, S3 event handlers, scheduled jobs, and async processing are a natural fit. Each invocation is independent.
- You want zero ops overhead. No server to patch, no instance type to size, no systemd to manage. Cumulus handles packaging and deployment; AWS handles the rest.
- Response times are under ~30 seconds. Lambda has a hard 15-minute timeout. Most HTTP APIs are fine; long-running batch jobs are not.
Choose EC2 when
- You have background workers or scheduled processes. Celery workers, queue consumers, and cron jobs need a process that is always running. Lambda cannot do this.
- You need persistent connections. WebSockets, Server-Sent Events, and long-polling require a stable process. Lambda invocations are short-lived.
- Traffic is steady. At constant load, a single EC2 instance is almost always cheaper than the equivalent Lambda invocation volume. The crossover is typically around a few hundred requests per minute.
- You are migrating an existing app. A Django, Rails, or Axum API already designed around persistent processes will run on EC2 with no code changes.
cumulus.toml.
Cost reference (us-east-1, 2024)
| Option | ~Monthly cost | Best for |
|---|---|---|
t4g.small EC2 |
~$12 | Always-on API, workers, early-stage apps |
t4g.medium EC2 |
~$25 | API + worker on one box, more headroom |
| Lambda (512 MB) | ~$0 – $5 at low volume | Event handlers, webhooks, low-traffic APIs |
| Lambda (512 MB) | ~$15 – $60+ at scale | High-traffic APIs (compare against EC2 at this point) |
Choosing a database
When you provision an EC2 server with --with-postgres, Cumulus installs PostgreSQL on the same instance as your application. It is the fastest way to get a database running, and it costs nothing beyond the instance you are already paying for. But it comes with trade-offs worth understanding before you have paying users.
| Concern | On-instance Postgres | RDS |
|---|---|---|
| Monthly cost | $0 extra | ~$15 – $50 (db.t4g.micro – small) |
| CPU / memory | Shared with the app | Dedicated |
| Survives host replacement | No; the DB dies with the EC2 instance | Yes; data is independent of the host |
| Data loss on host failure | Up to 24 h (daily backup) | Near-zero (continuous point-in-time recovery) |
| Automated backups | Daily pg_dump → S3, 30-day retention |
Automated snapshots + PITR (AWS managed) |
| Automatic failover | No | Optional (Multi-AZ) |
Our recommendation
Start with on-instance Postgres. It is free, zero-configuration, and right-sized for early-stage products where the operational overhead of RDS is not yet worth it. Cumulus takes a daily backup automatically, so you are not flying blind.
Upgrade to RDS when any of these apply:
- You have paying customers and data loss is unacceptable
- The database has grown past a few gigabytes (disk and CPU contention with the app)
- You want the database to survive a host replacement automatically
- You need point-in-time recovery (restore to any second, not just the last daily backup)
DATABASE_URL in SSM, not in your application code. Pointing it at an RDS endpoint and redeploying is all it takes: no code changes, no downtime beyond the restart.
Provisioning a database
--with-postgres / --with-rds flags). To use an existing database, set DATABASE_URL and deploy:cumulus app env-set api DATABASE_URL="postgres://user:pass@host/db" --secret
cumulus app deploy api
Why staging matters
Deploying directly to production on every push is a habit that works fine until it doesn't, and when it doesn't, it is usually at the worst possible time. A staging environment is the smallest change you can make to your workflow that catches the most problems before they reach users.
What staging catches that testing doesn't
- Database migrations against real data shapes. A migration that passes on a small dev fixture can fail or corrupt a production table with millions of rows and years of accumulated oddities. Run it on staging first, against a recent production snapshot.
- Environment variable mistakes. A missing secret or a wrong endpoint URL causes a silent failure that unit tests never see. Staging shares the same SSM-based config model as production, so a deploy that fails there fails safely.
- Integration behavior at real scale. Third-party APIs, payment providers, and email services behave differently under load and with production credentials. Staging is where you find that your Stripe webhook handler has a race condition.
- The deploy process itself. A broken
releasetask (a migration that never exits, a cache warmup that OOMs) stalls the deployment. Finding this on staging means the old binary keeps serving production traffic while you fix it.
Set it up in five minutes
Add a staging server and override it per-environment in cumulus.toml:
[[app]]
name = "api"
server = "prod-1" # default: production
[app.environments.staging]
server = "staging-1" # smaller instance is fine
Then set your webhook to deploy pushes to staging, and promote manually to production:
# GitHub pushes to main land on staging automatically
cumulus project create --name my-saas --deploy-env staging
# When staging looks good, promote to production
cumulus app promote my-saas/api --from staging --to production
Staging does not need to be expensive
A t4g.micro (~$6/mo) running a stripped-down version of your stack is enough to catch most issues. It does not need to handle production load; it needs to run your migration and start your binary. On-instance Postgres is fine for staging even if production uses RDS.
Health checks
Cumulus probes your app after each deploy and rolls back automatically if the check fails. But the value of a health endpoint goes far beyond deployment gating. A well-designed /health route is the single most useful signal you have about whether your app is actually working.
What a good health endpoint checks
Returning 200 OK unconditionally is better than nothing, but it only tells you the process started. A useful health check also verifies the things the process depends on:
# Minimal: proves the process is alive
GET /health → 200 OK
# Better: proves the app can actually serve requests
GET /health → 200 { "status": "ok", "db": "ok", "latency_ms": 3 }
# Failed: the process is up but cannot reach the database
GET /health → 503 { "status": "degraded", "db": "error: connection refused" }
A 503 during the deploy health-check window causes Cumulus to roll back, and the old binary stays live. A 503 after cutover is how your monitoring knows something broke between deploys.
What to check
- Database connectivity. Run a lightweight query;
SELECT 1is enough. A misconfiguredDATABASE_URL, a migration that left the schema in a bad state, or an RDS failover all show up here first. - Required environment variables. If a missing secret would cause a panic or an unhandled error on the first real request, check for it at startup and surface it in
/health. - External dependencies (with care). Checking a third-party API in
/healthmeans their outage causes your deploy to roll back. Only include dependencies that your app truly cannot function without, and set aggressive timeouts.
Configure the health check
[app.health_check]
path = "/health"
port = 8080
timeout_s = 10 # per attempt; Cumulus retries for ~60 s before rolling back
[app.health_check] for any app that connects to a database or external service.
Quick implementation examples
// src/routes/health.rs
async fn health(State(pool): State<PgPool>) -> impl IntoResponse {
match sqlx::query("SELECT 1").execute(&pool).await {
Ok(_) => (StatusCode::OK, Json(json!({ "status": "ok" }))),
Err(e) => (StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "status": "degraded", "db": e.to_string() }))),
}
}
# myapp/views.py
from django.http import JsonResponse
from django.db import connection
def health(request):
try:
connection.ensure_connection()
return JsonResponse({"status": "ok"})
except Exception as e:
return JsonResponse({"status": "degraded", "db": str(e)}, status=503)
# urls.py
path("health", views.health),
# main.py
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from sqlalchemy import text
@app.get("/health")
async def health(db: AsyncSession = Depends(get_db)):
try:
await db.execute(text("SELECT 1"))
return {"status": "ok"}
except Exception as e:
return JSONResponse({"status": "degraded", "db": str(e)}, status_code=503)