Chapter 09

Deployment

RuleForge is a stateless ASP.NET Core service. No databases of its own, no leader election, no bootstrap dance. Two env vars and a rule source.

Environment variables

VariablePurposeDefault
RULEFORGE_RULE_SOURCElocal or dflocal
RULEFORGE_FIXTURES_DIRLocal rule directory (when source = local)./fixtures/rules
RULEFORGE_REFS_DIRLocal reference-set directory (when source = local)./fixtures/refs
RULEFORGE_DF_BASE_URLDocumentForge base URL (when source = df)https://documentforge.onrender.com
RULEFORGE_DF_API_KEYDocumentForge bearer token(required for df source)
RULEFORGE_ENVDF environment to read bindings fromstaging
RULEFORGE_API_KEYCaller-side X-AERO-Key shared secret(unset = open dev mode)
ASPNETCORE_URLSListening URL(s)http://localhost:5000

Docker

A multi-stage Dockerfile is the simplest path. Builds in 30 seconds on a warm cache, produces a ~110MB self-contained runtime image.

# Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/RuleForge.Api -c Release -o /out

FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /out ./
COPY fixtures ./fixtures
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "RuleForge.Api.dll"]

Render

A render.yaml Blueprint ships at the repo root. One-click deploy gives you a Docker web service co-located with DocumentForge (oregon region) so cold-path lookups stay in-region.

One-click deploy

  1. In the Render dashboard: New +Blueprint → select tailwind-retailing/ruleforge.
  2. Render reads render.yaml and provisions the service.
  3. In the dashboard, populate the two sync: false secrets — see the env-var table.
  4. You'll get a *.onrender.com URL within ~30 seconds.

What ships in the blueprint

The blueprint lists every environment variable the engine reads — it doubles as documentation. Vars marked (local mode only) are inert while RULEFORGE_RULE_SOURCE=df.

# render.yaml at the repo root
services:
  - type: web
    name: ruleforge
    runtime: docker
    plan: starter
    region: oregon                          # match DocumentForge for in-region cold paths
    dockerfilePath: ./Dockerfile
    healthCheckPath: /health
    autoDeploy: true

    envVars:
      # 1. Rule source — `df` or `local`
      - key: RULEFORGE_RULE_SOURCE
        value: df

      # 2. DocumentForge connection (when source=df)
      - key: RULEFORGE_DF_BASE_URL
        value: https://documentforge.onrender.com
      - key: RULEFORGE_DF_API_KEY
        sync: false                          # set in dashboard
      - key: RULEFORGE_ENV
        value: staging

      # 3. Local file source (when source=local — inert when df)
      - key: RULEFORGE_FIXTURES_DIR
        value: /app/fixtures/rules
      - key: RULEFORGE_REFS_DIR
        value: /app/fixtures/refs

      # 4. API auth — caller's X-AERO-Key shared secret
      - key: RULEFORGE_API_KEY
        sync: false                          # set in dashboard

      # 5. ASP.NET Core
      - key: ASPNETCORE_ENVIRONMENT
        value: Production

First request once deployed

curl -X POST https://ruleforge.onrender.com/v1/tax/pnr \
  -H 'X-AERO-Key: <your-secret>' \
  -H 'content-type: application/json' \
  -d '{
        "orig": "LHR",
        "taxCode": "GB1",
        "pax": [
          { "id": "p1", "ageCategory": "ADT" },
          { "id": "p2", "ageCategory": "CHD" }
        ]
      }'

Hits the per-pax tax fixture (rule-pnr-taxes@1) shipped in the image. Once the AERO admin team has authored their own tax rules and bound them to staging, those replace the bundled fixtures automatically — no engine change needed.

Co-located DocumentForge (advanced)

For maximum cold-path performance, run a private DocumentForge alongside the engine in the same Render service. Adds a pserv service block + a persistent disk; engine points at http://documentforge:5000 over the private network. Worth it if you're at scale, overkill for a tax-team trial — cross-region public DF is already fast in steady state thanks to the per-version cache.

  - type: pserv
    name: documentforge
    runtime: docker
    repo: https://github.com/tailwind-retailing/documentforge
    disk:
      name: data
      mountPath: /data
      sizeGB: 10

Then mirror the public DF into it once with ruleforge mirror --from https://documentforge.onrender.com --to http://documentforge:5000.

After a publish

The auto-router enumerates environment bindings once at boot. Two ways to pick up a new rule version:

curl -X POST -H 'X-AERO-Key: <secret>' https://ruleforge.onrender.com/admin/refresh
// → { "ok": true, "refreshedAt": "2026-04-28T09:00:00.000Z", "note": "Source caches dropped..." }

Admin endpoints

All gated by RULEFORGE_API_KEY (when configured). /health is the only bypass.

EndpointPurpose
GET /healthLiveness probe. Always open. Returns {ok: true} regardless of source state.
GET /admin/bindingsCurrently-bound endpoints + cache stats. Body: {bindings: [...], registeredAtBoot: [...], cache: {ruleSnapshots, refSets, refreshedAt}}.
POST /admin/refreshDrop in-memory caches (rule snapshots + reference sets + env bindings). Returns timestamp + note.

Security

API auth

Setting RULEFORGE_API_KEY activates middleware that checks every request for either:

Comparison is constant-time. Unauthenticated requests return 401 with WWW-Authenticate: AeroKey realm="aero-engine". /health is always allowed (so liveness probes don't need to ship the secret).

When the env var is unset, every request is allowed — useful for local dev and integration tests.

Network

The engine never originates outbound traffic except to DocumentForge. Lock down egress to whatever DF endpoint you've configured. No external SaaS dependencies.

Secrets

Health checks

GET /health returns 200 {"ok": true} regardless of rule-source state. It does not probe DocumentForge — health is "the engine is up", not "every dependency is healthy". Wire DocumentForge into a separate probe if your orchestrator needs that signal.

GET /admin/bindings dumps the current binding list (auto-router state). Useful for ops sanity but, like every other endpoint, gated by the API key when configured.

Logging

Standard ASP.NET Core logging — stdout with structured fields. The auto-router logs one line per bound endpoint at boot:

info: Bound POST /v1/ancillary/bag-policy → rule-bag-policy@7
info: Bound POST /v1/ancillary/tier-bonus → rule-tier-bonus@1
info: Now listening on: http://localhost:5050

Per-request tracing is opt-in via ?debug=true on the URL or X-Debug: true on the request. Production mode skips trace allocation entirely; debug mode adds ~10× overhead but emits per-node start times, durations, ctx reads/writes and sub-rule run IDs.

Scaling

Pure horizontal scale — add pods, no coordination required. Each pod independently caches rule snapshots; on publish, restart pods (or wait the 30-second env-binding TTL) for the new version to roll out. The benchmarks show a single pod can sustain ~73K req/s on 16 cores; multi-pod scales linearly.

Co-located DocumentForge keeps the cold path ~600× faster than cross-region. If you don't want a dfdb sidecar, the next-best option is putting DF in the same VPC as the engine.