Saturday's Spinout
A full-stack web application for race logging, built with Go, Vue 3, and deployed to AWS.
Disclaimer
This is a toy project, meant to scratch an itch while also giving me time to play with the core elements of my craft
without being distracted by things like mentoring, orchestrating and politicking. There are no delivery timelines, worries
about will this make sense & be maintainable to junior engineers, and so on. So there is lots of over-engineering, which feels
appropriate given the itch this is scratching is focused around an activity where we are driving simulated cars as fast
as we can just for shits and giggles.
Budget however is a real issue, and even though this architecture at its core would scale as far as your wallet allows
it should be incredibly inexpensive well beyond the point where the user base gets big enough to be problematic on its own.
So, the technologies in use are serverless and billed based on usage. Expectation is the primary cost center will be Route53
fees, maybe a couple bucks a month in dynamo storage, and pocket change for compute (via Lambdas, all of which have very low
reserved concurrency limits set as a safety).
Architecture Overview
flowchart TB
Browser["Browser"]
Browser -->|"Load SPA"| CF1
Browser -->|"REST API"| CF2
Browser <-->|"WebSocket"| CF4
subgraph CloudFront["CloudFront CDN"]
CF1["app.* (Frontend)"]
CF2["api.* (API)"]
CF3["www/root (Website)"]
CF4["ws.* (WebSocket)"]
end
CF1 --> S3_SPA["S3 Bucket<br/>(Vue SPA)"]
CF2 --> APIGW["API Gateway<br/>(REST)"]
CF3 --> S3_Static["S3 Bucket<br/>(Static)"]
CF4 --> WS_APIGW
APIGW --> Lambda["API Lambda<br/>(Go)"]
Lambda --> DynamoDB["DynamoDB"]
Lambda --> SecretsManager["Secrets Manager"]
Lambda --> SQS["SQS<br/>(Race Ingestion)"]
Lambda --> iRacingAPI["iRacing API"]
SQS --> IngestionLambda["Ingestion Lambda<br/>(Go)"]
IngestionLambda --> DynamoDB
IngestionLambda --> iRacingAPI
IngestionLambda -->|"Push Updates"| WS_APIGW
WS_APIGW["API Gateway<br/>(WebSocket)"] --> WS_Lambda["WebSocket Lambda<br/>(Go)"]
WS_Lambda --> DynamoDB
WS_Lambda --> SecretsManager
Project Structure
.
├── .github/workflows/ # CI/CD pipeline (GitHub Actions)
├── aws_account_prep/ # One-time AWS account setup (see aws_account_prep/README.md)
├── api/ # API endpoint handlers and HTTP setup
├── auth/ # JWT creation with ES256 signing and AES-GCM encryption
├── cmd/ # Application entry points
│ ├── lambda-based-api/ # AWS Lambda handler (REST API)
│ ├── race-ingestion-processor/ # SQS consumer for race data ingestion
│ ├── standalone-api/ # Local development server
│ └── websocket-lambda/ # WebSocket Lambda handler
├── correlation/ # Request correlation ID middleware
├── ingestion/ # Race data ingestion processing
├── iracing/ # iRacing API client and OAuth integration
├── store/ # Data persistence layer (DynamoDB)
├── tracks/ # Track data service (merges iRacing track info + assets)
├── ws/ # WebSocket handler package
├── frontend/ # Vue 3 SPA
├── terraform/ # Infrastructure as Code
├── website/ # Static marketing site
├── scripts/ # Build scripts
└── Makefile # Build orchestration
Backend (Go)
The API is built with chi and can run either as an AWS Lambda function or as a standalone HTTP server.
Entry Points
Both entry points share the same API setup via cmd/api.go, which configures:
- Structured logging with zerolog
- AWS X-Ray tracing
- Environment-based configuration
API Layer
API Naming Conventions
Path parameters and resource IDs use descriptive prefixes to avoid confusion with iRacing's own identifiers. For example, driver_race_id (the unix timestamp of the race start time, used as an ID for a driver's race record) is distinct from subsession_id (iRacing's identifier for a session). This makes it clear which system "owns" the identifier and prevents ambiguity in API contracts.
Authentication
The auth/ package handles JWT creation with ES256 (ECDSA P-256) signing and AES-GCM encryption. JWTs contain encrypted iRacing tokens, allowing the backend to make iRacing API calls on behalf of authenticated users. Keys are stored in Secrets Manager and loaded at Lambda cold start.
| File |
Purpose |
auth/service.go |
Auth service orchestrating OAuth callback flow |
auth/jwt.go |
JWT creation with ES256 signing and AES-GCM payload encryption |
auth/keys.go |
Key parsing utilities for PEM and base64 encoded keys |
iRacing Integration
The iracing/ package provides OAuth and API client functionality for iRacing.
Middleware
Data Store
The persistence layer uses DynamoDB with a single-table design.
driver#<id> partition
| Sort Key |
Description |
Attributes |
info |
Driver record |
driver_name, member_since, races_ingested_to, first_login, last_login, login_count, session_count, entitlements |
ingestion_lock |
Distributed lock for race ingestion |
locked_until, ttl |
ws#<connectionId> |
WebSocket connection |
connected_at, ttl |
session#<timestamp> |
Race participation (list view) |
subsession_id, track_id, series_id, series_name, car_id, start_time, start_position, start_position_in_class, finish_position, finish_position_in_class, incidents, old_cpi, new_cpi, old_irating, new_irating, old_license_level, new_license_level, old_sub_level, new_sub_level, reason_out |
journal#<race_id> |
Journal entry for a race |
driver_id, race_id, notes, tags, replay_video (optional), created_at, updated_at |
websocket#<id> partition
| Sort Key |
Description |
Attributes |
info |
Websocket Record |
driver_id |
Note, this is basically just indexing websockets -> driver, could be a GSI but seems like less fuss just to explicitly write things
global partition
| Sort Key |
Description |
Attributes |
counters |
Aggregate counts |
drivers |
WebSocket
The ws/ package handles real-time WebSocket connections via API Gateway WebSocket APIs.
| File |
Purpose |
ws/handler.go |
Main router - dispatches to route-specific handlers |
ws/push.go |
Pusher abstraction for sending messages and managing connections |
ws/auth/handler.go |
Authentication handler - validates JWT, stores connection |
ws/ping/handler.go |
Heartbeat handler - verifies connection, responds with pong |
Connection Flow:
- Client connects to
wss://ws.{domain}
- Client sends
{"action": "auth", "token": "<JWT>"} to authenticate
- Server validates JWT, stores connection mapping in DynamoDB
- Client sends periodic
{"action": "pingRequest", "driverId": <id>} for heartbeat
- Connections have 24h TTL in DynamoDB for automatic cleanup
Race Ingestion
The ingestion/ package handles asynchronous ingestion of race history from the iRacing Data API. Processing is decoupled from the REST API via SQS.
Ingestion Flow:
- REST API receives request at
POST /ingestion/race with authenticated user
- API checks for active ingestion lock; returns 429 with Retry-After if locked
- API enqueues message to SQS with driver ID and iRacing access token
- Race Ingestion Lambda consumes message, acquires distributed lock (conditional write)
- If lock already held, logs warning and returns success (SQS message acknowledged)
- Queries iRacing
/data/results/search_series, filters to races only (event_type=5)
- For each race, fetches session results to get the driver's detailed stats
- Stores driver's race participation record in DynamoDB (skips if already exists)
- Driver's
races_ingested_to timestamp is updated for incremental sync
- Lock released before recursing; allowed to expire naturally when up-to-date (cooldown period)
The iRacing search API returns chunked responses (results split across multiple S3 URLs). The client fetches all chunks and combines them. Search window is configurable (default 10 days) via SEARCH_WINDOW_IN_DAYS.
Distributed Lock: The ingestion lock prevents concurrent ingestion for the same driver. It uses a DynamoDB conditional write with TTL for automatic cleanup. The lock duration (default 15 minutes) serves as both a timeout for long-running ingestion and a cooldown period after completion.
Frontend (Vue 3 + TypeScript)
A single-page application built with Vue 3, TypeScript, and Vite.
All AWS infrastructure is defined in Terraform with workspace support for multiple environments.
| File |
Purpose |
terraform/api.tf |
REST API Lambda, API Gateway, certificates, environment variables |
terraform/race-ingestion.tf |
SQS queue, Race Ingestion Lambda, event source mapping |
terraform/websockets.tf |
WebSocket API Gateway, custom domain, routes |
terraform/websockets-lambda.tf |
WebSocket Lambda function and IAM permissions |
terraform/front-end.tf |
S3 bucket, CloudFront distribution for SPA |
terraform/website.tf |
S3 bucket, CloudFront for static site |
terraform/store.tf |
DynamoDB table (with TTL for WebSocket connections) |
terraform/secrets.tf |
Secrets Manager secrets (iRacing credentials, JWT signing/encryption keys) |
terraform/iracing-cache.tf |
S3 bucket for caching iRacing global data (tracks, cars) |
terraform/backend.tf |
S3 backend for Terraform state |
CI/CD
Continuous Integration
GitHub Actions runs on push and PR to main. The workflow (.github/workflows/ci.yml):
- Backend tests - Runs Go tests with race detection and coverage against a DynamoDB Local service container
- Frontend tests - Runs Vitest with coverage
- Build - Builds the Lambda deployment package (only after tests pass)
Test results are published as GitHub check annotations and coverage is uploaded to Codecov.
Deployment
Deployments are triggered by publishing a GitHub release. The workflow (.github/workflows/deploy.yml):
- Build Lambdas - Compiles Go Lambda functions
- Terraform Apply - Updates AWS infrastructure
- Build Frontend - Compiles Vue SPA with production API URLs
- Deploy Frontend - Syncs to S3, invalidates CloudFront cache
- Deploy Website - Syncs static site to S3
Required GitHub Secrets
| Secret |
Description |
AWS_ACCOUNT |
AWS account ID for OIDC role assumption |
STATE_BUCKET |
S3 bucket name for Terraform state |
IRACING_CLIENT_ID |
iRacing OAuth client ID for frontend build |
CODECOV_TOKEN |
Codecov upload token (for CI coverage) |
AWS Authentication
The deploy workflow uses OIDC federation to assume an IAM role (github-actions-deploy) without storing long-lived credentials. The role and trust policy are managed in aws_account_prep/github-actions.tf.
Development
See CODING_STANDARDS.md for coding conventions and patterns.
Prerequisites
- Make (included on Linux/macOS; Windows users can use GnuWin32 or WSL)
- Go 1.21+
- Node.js 18+
- Terraform 1.0+
- AWS CLI (configured)
- Docker (for local DynamoDB)
Run make or make help to see all available targets.
Initial Setup
AWS Account Prep
Before deploying infrastructure for the first time in a new AWS account, run the one-time account setup. This creates the GitHub Actions OIDC provider, API Gateway CloudWatch logging role, and DNS records. See aws_account_prep/README.md for details.
cd aws_account_prep
terraform init -backend-config="bucket=your-state-bucket-name"
terraform apply -var="state_bucket=your-state-bucket-name"
The Terraform backend uses a partial configuration. You'll need to provide the S3 bucket name during init:
cd terraform
terraform init -backend-config="bucket=your-state-bucket-name"
Frontend
Copy the example environment file and configure your iRacing OAuth client ID:
cp frontend/.env.local.example frontend/.env.local
Edit frontend/.env.local and set VITE_IRACING_CLIENT_ID to your iRacing OAuth client ID.
Local Development
Run the backend API:
make run-rest-api
Run the frontend (in a separate terminal):
make run-frontend
The frontend dev server runs on http://localhost:5173 and the API on http://localhost:8080.
The make run-rest-api target automatically sources environment variables from Terraform, ensuring local development uses the same configuration as the deployed Lambda. This is accomplished via the app_env_vars output:
# In terraform/api.tf
locals {
app_env_vars = {
LOG_LEVEL = "info"
CORS_ALLOWED_ORIGINS = "..."
IRACING_CREDENTIALS_SECRET = data.aws_secretsmanager_secret.iracing_credentials.arn
JWT_SIGNING_KEY_SECRET = aws_secretsmanager_secret.jwt_signing_key.arn
JWT_ENCRYPTION_KEY_SECRET = aws_secretsmanager_secret.jwt_encryption_key.arn
}
}
# Lambda uses the same map
resource "aws_lambda_function" "api_lambda" {
environment {
variables = local.app_env_vars
}
}
# Output for local dev (formatted as KEY=VALUE pairs)
output "app_env_vars" {
value = join(" ", [for k, v in local.app_env_vars : "${k}=${v}"])
}
The Makefile then uses this output:
run-rest-api:
env $(terraform -chdir=terraform output -raw app_env_vars) LOG_LEVEL=trace go run ...
This pattern ensures that any new environment variables added to the Lambda are automatically available during local development without manual synchronization.
Testing
Tests use a local DynamoDB container. Manage it with:
make dynamo-start # Start local DynamoDB (creates container if needed)
make dynamo-stop # Stop the container (preserves data)
make dynamo-rm # Stop and remove the container
make dynamo-status # Check container status
Run tests:
go test ./...
Building
# Build Lambda deployment package
make build
# Build frontend for deployment
make build-frontend
Deploying
The frontend build sources the API URL from Terraform output (terraform output -raw api_url), so the build is workspace-specific. When switching Terraform workspaces, always rebuild the frontend before deploying:
# Switch workspace
cd terraform && terraform workspace select <workspace> && cd ..
# Clean and rebuild frontend for the new workspace
make clean
make build-frontend
# Deploy
make deploy-frontend
Other deploy commands:
# Deploy static website
make deploy-website
Environment Variables
Backend
These are managed in terraform/api.tf as local.app_env_vars and automatically provided to both Lambda and local development.
| Variable |
Description |
LOG_LEVEL |
Logging level (trace, debug, info, warn, error) |
CORS_ALLOWED_ORIGINS |
Comma-separated list of allowed origins |
IRACING_CREDENTIALS_SECRET |
ARN of Secrets Manager secret containing iRacing OAuth credentials |
JWT_SIGNING_KEY_SECRET |
ARN of Secrets Manager secret containing ECDSA P-256 private key (PEM) |
JWT_ENCRYPTION_KEY_SECRET |
ARN of Secrets Manager secret containing AES-256 key (base64) |
IRACING_CACHE_BUCKET |
S3 bucket name for caching iRacing global data (tracks, cars) |
Race Ingestion Lambda
| Variable |
Description |
LOG_LEVEL |
Logging level (trace, debug, info, warn, error) |
DYNAMODB_TABLE |
DynamoDB table name |
SEARCH_WINDOW_IN_DAYS |
Days to search per invocation (default: 10) |
INGESTION_LOCK_DURATION_SECONDS |
Duration of the distributed lock to prevent concurrent ingestion (default: 900) |
IRACING_CACHE_BUCKET |
S3 bucket name for caching iRacing global data (tracks, cars) |
Frontend
| Variable |
Required |
Description |
VITE_API_BASE_URL |
No |
API base URL (defaults to http://localhost:8080) |
VITE_WS_BASE_URL |
No |
WebSocket base URL (defaults to ws://localhost:8081) |
VITE_IRACING_CLIENT_ID |
Yes |
iRacing OAuth client ID (see frontend/.env.local.example) |