AWS App Runner
Deploy rtcstats-server on AWS App Runner in two stages: boot it, then make it keep your data.
You have a WebRTC app sending data to rtcstats-js. Now you need rtcstats-server somewhere your clients can actually reach. Not your laptop. Not a Docker container on a colleague's machine. A real URL with HTTPS that stays up.
AWS App Runner is one of the simplest paths to get there from inside AWS. It picks up your repo from GitHub, runs a managed Node.js runtime, and gives you a *.awsapprunner.com hostname. This guide walks the deploy in two stages: first it boots, then it does something useful. We won't pretend the first version is production-ready. It isn't. We'll be honest about what's missing as we go.
Before you start
You need:
- An AWS account with permission to create App Runner services, IAM roles, and S3 buckets
- A GitHub account, connected to App Runner via the GitHub connection flow in the App Runner console
That's it. No Docker installed locally. No Kubernetes. No Terraform.
Stage 1: get it running
Stage 1 is the smallest version of "running." You set one environment variable. App Runner's managed Node.js runtime does the rest. The result is a server that boots and accepts connections - but isn't actually useful yet. We'll fix that in Stage 2.
Step 1: choose your source
In the AWS console, open App Runner and click Create service, then pick Source code repository.
- Provider: GitHub
- Connection: your GitHub connection (create one if you don't have one yet)
- Repository:
rtcstats/rtcstats - Branch:
main - Source directory: leave empty. The start command runs from the repo root and targets the workspace.
- Deployment trigger: Manual. This is a server you deploy and forget. You don't want a
git pushto main to silently redeploy production.
Click Next.
Step 2: configure the service
- Configuration source: Configure all settings here (no
apprunner.yamlfor now) - Runtime: Node.js 22 (the managed Node.js runtime)
- Build command: leave empty for now. You'll add one later if you enable GeoIP enrichment.
- Start command:
npm start --workspace=packages/rtcstats-server - Port: 8080
- CPU / Memory: 1 vCPU, 2 GB. You can scale up later from real load, not from anxiety.
The 8080 default comes from config/default.yaml:7 (httpPort: 8080). Match it on both sides or both will be wrong in different ways.
The workspace flag in the start command is what lets you start the server from the monorepo root.
Step 3: add a health check
Under Health check:
- Protocol: HTTP
- Path:
/healthcheck
rtcstats-server exposes this endpoint at packages/rtcstats-server/rtcstats-server.js:94. It returns 200, no auth required. Use HTTP, not the default TCP check. TCP only confirms the port is open. HTTP confirms the app is actually serving.
Step 4: add one environment variable
Service-level environment variables, one row:
| Key | Value |
|---|---|
NODE_ENV |
not-yet-production |
That's the whole environment for Stage 1. Pick any value that reminds you this deploy isn't real yet. We use not-yet-production because it shows up in logs and reminds future-you what state this app is in.
Click Create & deploy and let it provision. After a few minutes you'll get a URL ending in .awsapprunner.com. Hit https://<service-id>.<region>.awsapprunner.com/healthcheck. You should see a blank 200.
That's Stage 1. The server is up. It's also, right now:
- Not enriching anything with GeoIP (no MaxMind data)
- Storing dump files on the App Runner instance's ephemeral disk
- Not authenticating any clients (anyone with the URL can post data)
We're going to configure persistence in Stage 2. GeoIP enrichment is covered in How to enrich rtcstats-server with GeoIP data. Auth is covered at the end of this page - and it's the most important one before you point real traffic at this.
One App Runner-specific thing worth knowing: the platform enforces a per-request idle timeout. Long WebRTC sessions can hit it and reconnect mid-call. Validate against your real session lengths before you point production at this.
Stage 2: make it keep your data
Gathering dumps from your clients and storing them in object storage is what rtcstats-server is built around. Stage 2 wires up the storage half of that - a bucket the server can write each finished dump into, and access for the upload.
S3 is the native fit here: it's the original of the S3 API that rtcstats-server speaks, lives in the same console as your service, and the AWS SDK that rtcstats-server uses talks to it without any endpoint override. Pointing the server at a bucket is a few minutes of clicking and one environment variable.
Step 1: create an S3 bucket
In the AWS console, go to S3 → Create bucket.
- Region: pick the same region as your App Runner service to avoid cross-region transfer.
- Block all public access: ON. rtcstats dumps are private.
- Versioning: off. Dumps are write-once.
- Name: something you'll recognise, e.g.
rtcstats-dumps-prod. The name is global across AWS S3, so generic names are taken.
Note the bucket name and region. You'll need both in Step 3.
Step 2: give App Runner access to the bucket
Two ways to do this. Prefer the first.
Instance role (recommended). App Runner can assume an IAM role on every request, and the AWS SDK inside rtcstats-server picks the credentials up automatically. No static keys to manage.
In IAM → Roles → Create role:
- Trusted entity: Custom trust policy
- Trust principal:
tasks.apprunner.amazonaws.com(the App Runner tasks service, distinct from the build-time access role) - Policy: grant
s3:PutObject,s3:GetObject, ands3:ListBucketon the bucket ARN you just created - Name: something like
rtcstats-server-instance-roleso you can revoke it later without guessing
Back in App Runner, edit the service Security settings and set the Instance role to this role.
Static access keys (fallback). If you can't use an instance role, create an IAM user with the same S3 policy, generate an access key, and pass the credentials in NODE_CONFIG (Step 3 below). The instance role is the state-of-the-art on App Runner. Static keys are a fallback.
Step 3: configure rtcstats-server
rtcstats-server reads storage settings from the storage.s3 block in config/default.yaml. Override them at runtime via the NODE_CONFIG environment variable - it's inline JSON consumed by the node-config package at boot, and YAML in the repo maps one-for-one to keys here.
Replace the Stage 1 NODE_CONFIG (if you set one) with this. Mark it encrypted even when it carries no secret - the next maintainer shouldn't have to wonder.
| Key | Value |
|---|---|
NODE_CONFIG |
see below |
With an instance role, the config has no credentials block at all:
{
"storage": {
"s3": {
"region": "us-east-1",
"bucket": "rtcstats-dumps-prod"
}
}
}
With static keys, it looks more like the DigitalOcean version:
{
"storage": {
"s3": {
"credentials": {
"accessKeyId": "AKIAEXAMPLE",
"secretAccessKey": "exampleSecretKeyReplaceMe"
},
"region": "us-east-1",
"bucket": "rtcstats-dumps-prod"
}
}
}
Two things worth calling out:
regionis the actual S3 region (us-east-1,eu-west-1, etc.), not a hard-coded default. Unlike DigitalOcean Spaces - whereus-east-1is the safe value regardless of where your Space lives - AWS S3 requires the real region.endpointis omitted entirely. The SDK derives it fromregion. Theendpointfield inconfig/default.yamlis there for S3-compatible APIs (DO Spaces, Supabase Storage), not for AWS S3 proper.
forcePathStyle is left at the default (false). AWS S3 supports virtual-hosted style; only flip it on for backends that require path-style.
Step 4: verify dumps land in the bucket
Save and redeploy. Connect a client over the websocket (the default transport) and let it disconnect. Watch the App Runner runtime logs for a line like:
Connection with uuid <uuid> disconnected, starting to process data
Note the UUID. Then open your bucket in the S3 console - you should see an object whose key matches it. If the bucket stays empty, the most likely culprits are:
- Instance role policy doesn't actually grant
s3:PutObjecton the bucket ARN, or grants it on a different ARN. regioninNODE_CONFIGdoesn't match the bucket's region.bucketname typo - rtcstats-server logs an S3 error in the runtime logs when this happens.- If you went with static keys: the IAM user's access key is inactive, or its policy doesn't cover the bucket.
Before this sees real traffic: lock down auth
Everything above gets you a working rtcstats-server you can poke at. You should make it more robust for production, partially by adding auth. This also doubles as our identifier mechanism.
The configuration in this guide does not set authorization.jwtSecret. From config/default.yaml:
JWT secret key to use for authorizing clients. If not set, no authorization is performed.
In plain English: anyone who finds your *.awsapprunner.com URL can post data to it. That's fine for a deploy you're poking at. It's not fine the moment real WebRTC traffic starts flowing.
For production, generate a JWT and authorize clients with it. The full setup is documented in the How to authenticate clients with rtcstats-server guide and also think about identifying users and sessions.
Set authorization.jwtSecret in NODE_CONFIG, sign tokens for your clients, and configure rtcstats-js to send them. That's the single change that turns this from "it deploys" into "it deploys safely."
What's next
- How to enrich rtcstats-server with GeoIP data - add country and city data to your sessions
- How to horizontally scale rtcstats-server - when a single instance isn't enough
- How to configure rtcstats-server for privacy - anonymize IPs, strip PII
Was this page helpful?