Deployment
Strategy
One codebase, one server binary. Deployment behavior is determined by runtime configuration — a config.yaml file and optional mounted assets. Two delivery formats from the same build:
- Docker — separate container images for server, dashboard, and PWA. Used for SaaS and white-label customers comfortable with Docker.
- Single binary — one statically-linked executable with dashboard and PWA assets embedded. Customer runs
./rangedaypro-serverwith aconfig.yamlalongside it. No Docker, no runtime dependencies.
Both formats read the same config.yaml and behave identically. The same code runs the T.REX ARMS SaaS instance and every white-label customer deployment.
Build Artifacts
CI (GitHub Actions) produces two artifact types on every release:
Container Images
rangedaypro/server:latest
rangedaypro/dashboard:latest
rangedaypro/pwa:latest
All three images read configuration from /etc/rangedaypro/config.yaml and custom assets from /etc/rangedaypro/assets/ at startup.
Single Binary
rangedaypro-server-x86_64-linux # statically linked (musl)
rangedaypro-server-aarch64-linux # ARM64 variant
Built with cargo build --release --target x86_64-unknown-linux-musl. The dashboard and PWA static assets (WASM, HTML, CSS, JS) are embedded into the binary at compile time using rust-embed. The Axum server serves them directly — no separate web server needed.
# Minimal white-label deployment
rangedaypro-server # single binary (~20-40MB)
config.yaml # runtime configuration
assets/ # optional: custom logo, favicon, colors
The binary reads config.yaml from the working directory (or --config <path>). Everything else — API, dashboard, PWA — is served from one process on one port.
Build Pipeline
Path-based triggers on push to master build only what changed. Tagged releases build everything. Core and proto changes trigger all downstream builds.
Path triggers (push to master):
libs/core/** → core + server + dashboard + pwa + ios + android
proto/** → core + server + dashboard + pwa + ios + android
apps/server/** → server only
apps/dashboard/** → dashboard only
apps/pwa/** → pwa only
apps/ios/** → ios only
apps/android/** → android only
Tagged releases:
| Tag pattern | What builds | Deploys to |
|---|---|---|
v* (e.g., v1.2.0) | Everything | Server + dashboard + PWA auto-deploy; mobile artifacts staged |
server-v* | Server + dashboard + PWA | Auto-deploy to DO |
ios-v* | iOS | Archives build, staged for App Store submission |
android-v* | Android | Archives build, staged for Play Store submission |
CI runners:
| Job | Runner |
|---|---|
| Core, server, dashboard, PWA | Linux (standard GitHub Actions) |
| iOS | macOS (required for XCFramework + Xcode archive) |
| Android | Linux (Gradle + NDK) |
Release Cadences
Server, dashboard, and PWA deploy automatically on push to master or server-v* tags — fast iteration, no gating.
Mobile apps have a separate, controlled release cycle:
- iOS and Android builds are triggered by
ios-v*/android-v*tags - Builds produce signed archives staged for review, not auto-submitted
- App store submission is a manual step — allows testing the archive on physical devices before release
- App store review adds its own delay (1-3 days for iOS, hours to days for Android)
This means the server can ship multiple times per day while mobile releases are deliberate, tested, and batched.
Server Module Architecture
The server uses trait-based module interfaces with runtime-selected implementations. Modules are swappable via config.yaml without recompilation.
Trait Interfaces
// Billing — Stripe for SaaS, no-op for white-label
trait BillingProvider: Send + Sync {
fn check_subscription(&self, org_id: &OrgId) -> SubscriptionStatus;
fn handle_webhook(&self, payload: &[u8]) -> Result<()>;
}
// Auth — in-house for MVP, Zitadel later
trait AuthProvider: Send + Sync {
fn authenticate(&self, credentials: &Credentials) -> Result<AuthContext>;
fn issue_token(&self, user_id: &UserId) -> Result<TokenPair>;
fn refresh(&self, token: &RefreshToken) -> Result<TokenPair>;
}
// Observability — real exporters or no-ops
trait TelemetryExporter: Send + Sync {
fn export_traces(&self, spans: &[Span]) -> Result<()>;
fn export_metrics(&self, metrics: &[Metric]) -> Result<()>;
}At startup, the server reads config.yaml and wires the appropriate implementation:
let billing: Arc<dyn BillingProvider> = if config.features.billing {
Arc::new(StripeBilling::new(config.stripe))
} else {
Arc::new(NoOpBilling)
};Module Summary
| Module | SaaS Implementation | White-Label Implementation |
|---|---|---|
| Billing | StripeBilling | NoOpBilling (all features unlocked) |
| Auth | InHouseAuth (MVP) → ZitadelAuth (future) | Same, or customer’s IdP via Zitadel |
| Observability | OtlpExporter (Loki/Prometheus) | NoOpExporter or customer’s own |
| Client telemetry | TelemetryService (stores reports) | NoOpTelemetry or customer’s own |
Configuration
config.yaml
# --- Branding ---
branding:
app_name: "Range Day Pro"
org_name: "T.REX ARMS"
logo: "/etc/rangedaypro/assets/logo.svg"
favicon: "/etc/rangedaypro/assets/favicon.ico"
colors:
primary: "#1a1a1a"
secondary: "#f5f5f5"
accent: "#c8102e"
# --- Modules ---
modules:
billing:
enabled: true
provider: stripe
stripe_secret_key: "${STRIPE_SECRET_KEY}"
webhook_secret: "${STRIPE_WEBHOOK_SECRET}"
auth:
provider: inhouse # inhouse | zitadel
jwt_secret: "${JWT_SECRET}"
jwt_access_ttl: 900 # 15 minutes
jwt_refresh_ttl: 2592000 # 30 days
observability:
enabled: true
otlp_endpoint: "http://loki:3100"
prometheus_endpoint: "http://prometheus:9090"
telemetry:
enabled: true
# --- Feature Flags ---
features:
public_signup: true # open registration vs admin-provisioned
drill_catalog: true # public/published drill visibility across orgs
multi_tenant: true # cross-org features (personal score view, org discovery)
# --- Email ---
email:
sender_name: "Range Day Pro"
sender_address: "noreply@rangedaypro.com"
smtp_host: "${SMTP_HOST}"
smtp_port: 587
smtp_user: "${SMTP_USER}"
smtp_pass: "${SMTP_PASS}"White-Label Example
branding:
app_name: "Springfield PD Training Portal"
org_name: "Springfield Police Department"
logo: "/etc/rangedaypro/assets/logo.svg"
colors:
primary: "#1a3d5c"
accent: "#d4a017"
modules:
billing:
enabled: false
auth:
provider: inhouse
observability:
enabled: false
telemetry:
enabled: false
features:
public_signup: false
drill_catalog: false
multi_tenant: false
email:
sender_name: "Springfield PD Training"
sender_address: "training@springfieldpd.gov"
smtp_host: "smtp.springfieldpd.gov"SaaS Deployment (T.REX ARMS)
Infrastructure
The SaaS instance runs on Digital Ocean:
Digital Ocean
├── App containers (server, dashboard, PWA)
│ └── Pulled from container registry on deploy
├── Postgres
│ └── Managed (DO Managed Database) or self-managed in Docker
│ (configurable via DATABASE_URL)
├── Observability stack (Grafana, Loki, Prometheus)
│ └── Docker containers alongside app
└── Reverse proxy / TLS termination
└── Caddy or DO load balancer
Database
The server connects via DATABASE_URL — it doesn’t care whether Postgres is managed or self-managed:
# Managed (Digital Ocean)
# DATABASE_URL=postgres://user:pass@db-host:25060/rangedaypro?sslmode=require
# Self-managed (Docker alongside app)
# DATABASE_URL=postgres://user:pass@postgres:5432/rangedayproStart with self-managed in Docker for simplicity. Move to managed Postgres when uptime and backup guarantees matter.
CI/CD Flow
Push to master
→ GitHub Actions builds + pushes images + uploads binaries
→ Deploy step: SSH to DO server, pull new images, restart containers
The deploy step is deliberately simple — docker compose pull && docker compose up -d. No orchestrator at MVP. If horizontal scaling is needed later, move to Docker Swarm or Kubernetes.
SaaS docker-compose.yml
services:
server:
image: rangedaypro/server:latest
volumes:
- ./config.yaml:/etc/rangedaypro/config.yaml
- ./assets:/etc/rangedaypro/assets
environment:
- DATABASE_URL=postgres://user:pass@postgres:5432/rangedaypro
ports:
- "8080:8080"
dashboard:
image: rangedaypro/dashboard:latest
volumes:
- ./config.yaml:/etc/rangedaypro/config.yaml
- ./assets:/etc/rangedaypro/assets
pwa:
image: rangedaypro/pwa:latest
volumes:
- ./config.yaml:/etc/rangedaypro/config.yaml
- ./assets:/etc/rangedaypro/assets
postgres:
image: postgres:16
environment:
- POSTGRES_DB=rangedaypro
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
volumes:
- pgdata:/var/lib/postgresql/data
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
loki:
image: grafana/loki:latest
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
caddy:
image: caddy:latest
ports:
- "443:443"
- "80:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
volumes:
pgdata:White-Label Deployment
White-label customers choose their preferred deployment method:
Option A: Docker
Customers receive:
- A
docker-compose.yml(app containers + Postgres, no observability by default) - A
config.yamltemplate with their branding and feature flags - An
assets/directory for custom logos, favicon, and optional CSS overrides
The customer runs docker compose up and has a working deployment.
Option B: Single Binary
Customers receive:
- The
rangedaypro-serverbinary for their architecture (x86_64 or aarch64) - A
config.yamltemplate - An
assets/directory for branding
The customer runs ./rangedaypro-server and has a working deployment. They provide their own Postgres instance (local, managed, or cloud). No Docker, no containers, no runtime dependencies — one process, one port.
This option works well for:
- Air-gapped or restricted environments with no container runtime
- Customers running on bare metal or minimal VMs
- Simplified IT deployments where Docker expertise isn’t available
See White-Label Deployment for full details.
What Differs Between Deployments
| Concern | SaaS (T.REX ARMS) | White-Label |
|---|---|---|
| Delivery format | Docker (separate containers) | Docker or single binary |
| config.yaml | Billing on, public signup, multi-tenant | Billing off, admin-provisioned, single-tenant |
| Postgres | Managed or self-managed, growing | Bundled in Docker, small |
| Observability | Full stack (Grafana/Loki/Prometheus) | Optional |
| CI/CD | GitHub Actions → auto-deploy | Customer manages updates (pull new images) |
| TLS | Caddy or DO load balancer | Customer’s infrastructure |
| Network | Public internet | May be private/air-gapped |