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-server with a config.yaml alongside 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 patternWhat buildsDeploys to
v* (e.g., v1.2.0)EverythingServer + dashboard + PWA auto-deploy; mobile artifacts staged
server-v*Server + dashboard + PWAAuto-deploy to DO
ios-v*iOSArchives build, staged for App Store submission
android-v*AndroidArchives build, staged for Play Store submission

CI runners:

JobRunner
Core, server, dashboard, PWALinux (standard GitHub Actions)
iOSmacOS (required for XCFramework + Xcode archive)
AndroidLinux (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

ModuleSaaS ImplementationWhite-Label Implementation
BillingStripeBillingNoOpBilling (all features unlocked)
AuthInHouseAuth (MVP) → ZitadelAuth (future)Same, or customer’s IdP via Zitadel
ObservabilityOtlpExporter (Loki/Prometheus)NoOpExporter or customer’s own
Client telemetryTelemetryService (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/rangedaypro

Start 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:

  1. A docker-compose.yml (app containers + Postgres, no observability by default)
  2. A config.yaml template with their branding and feature flags
  3. 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:

  1. The rangedaypro-server binary for their architecture (x86_64 or aarch64)
  2. A config.yaml template
  3. 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

ConcernSaaS (T.REX ARMS)White-Label
Delivery formatDocker (separate containers)Docker or single binary
config.yamlBilling on, public signup, multi-tenantBilling off, admin-provisioned, single-tenant
PostgresManaged or self-managed, growingBundled in Docker, small
ObservabilityFull stack (Grafana/Loki/Prometheus)Optional
CI/CDGitHub Actions → auto-deployCustomer manages updates (pull new images)
TLSCaddy or DO load balancerCustomer’s infrastructure
NetworkPublic internetMay be private/air-gapped