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.

EC2
A server that runs continuously. You pay by the hour whether or not traffic is arriving. The process stays alive between requests, so in-memory state, background workers, WebSocket connections, and long-running jobs all work naturally.
Lambda
A function that wakes up per request and goes back to sleep. You pay per invocation. There is no persistent memory between calls, no background threads, and connections (database, cache) must be re-established on cold starts.

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.
You do not have to choose one. A common pattern: run your API on EC2 (steady traffic, persistent DB connections) and use Lambda for background tasks like sending email, processing uploads, or firing webhooks. Declare both in the same 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)
On-instance Postgres means the database and the app share the same failure domain. If the EC2 instance is terminated, corrupted, or needs to be replaced, you lose both the running process and the database. Daily backups limit data loss to at most 24 hours, but recovery still requires a restore step. If you cannot afford to lose a day of data, use RDS.

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)
Upgrading is a one-line change. Cumulus stores 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

Coming soon: database provisioning from the connected CLI (on-instance Postgres and managed RDS) isn't wired yet. Today it's part of the operator bootstrap script (its --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 release task (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
Promote re-deploys the same git sha, not a new build. The binary that passed health checks on staging is exactly what goes to production. No surprises from a second build.

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 1 is enough. A misconfigured DATABASE_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 /health means 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
Without a health check, Cumulus waits a fixed interval and assumes success. This means a binary that starts but immediately crashes will cut over before the crash is detected. Configure [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)