Hosting setup
Staging and production are independent deployment targets. Each GitHub
environment supplies its own SSH host, account, key, and trusted host-key entry.
They can point at the same server or different servers. Aspire is only the local
launcher; application hosting uses deploy/staging and deploy/production.
Each server runs one host-local Caddy edge, explicitly configured through
the EDGE_PROFILE variable on its GitHub deployment environment:
| Layout | Staging environment's EDGE_PROFILE |
Production environment's EDGE_PROFILE |
|---|---|---|
| Both applications on one server (current layout) | shared |
shared |
| Separate servers | staging |
production |
There is no default. Missing, unknown, or mismatched profiles fail closed.
shared is an intentional configuration, not an inferred relationship between
hosts. Each host has its own ogb-edge Docker network and Caddy certificate
volumes; the identical names on separate machines do not connect those machines.
An isolated profile has no routes, web mounts, or API aliases for the other
environment. Shared hosting still shares host/Docker privileges and outage risk;
it is not a security isolation boundary.
Runtime trust boundaries
The reviewed SDK, ASP.NET runtime, and Caddy tags are paired with immutable image digests. Dependabot continues to propose tag/digest updates, but a registry retag cannot change an unreviewed build or edge deployment. Each built API image is also deployed by its generated GHCR digest. Dependabot's Docker cooldown is best effort: when the registry supplies no publication date for a digest update, its PR reports that the cooldown could not be applied. The digest still requires review and merging before it is used.
The API image declares the .NET image's non-root application user. CI and the
deployment job verify the effective UID is nonzero, the application assembly is
readable, /app is not writable by that user, and liveness succeeds before the
image can be deployed. The API Compose services publish no host port; Caddy
reaches them only through the external ogb-edge network.
Caddy terminates HTTPS and replaces client-supplied X-Forwarded-For,
X-Forwarded-Proto, and X-Forwarded-Host values before proxying. During
activation, scripts/deploy-app.sh reads the actual ogb-edge IPAM subnets and
passes only those CIDRs to the API. Forwarded-header middleware accepts one hop
from those networks and runs before HTTPS redirection. It does not trust every
private address or an arbitrary direct client. If the host network has no IPAM
subnet, activation fails before the candidate starts.
The host deployment account is separate from the API process identity. It needs
write access only below /srv/opengamebuilder, SSH access with the configured
key, and the Docker operations used by the reviewed deployment scripts. Docker
daemon access is host-privileged; do not reuse this account or key for application
traffic, interactive contributor access, or unrelated automation.
Host edge changes
🌐 CD Edge (.github/workflows/cd-edge.yml) owns edge updates. Dispatch it
from main, selecting the GitHub environment that supplies the target host's
credentials and approval rules. An edge serving both environments must be
dispatched through production, never staging. Edge updates queue rather than
cancelling one another, even when two environments happen to share a host.
scripts/render-edge.sh combines the common pinned deploy/edge/compose.yml
with the selected compose.<environment>.yml mount overrides and
Caddyfile.<environment> site fragments. It produces standalone compose.yml
and Caddyfile candidates with an explicit profile marker. Only those rendered
files are transferred; do not deploy the common Compose base by itself.
Run the edge workflow before the first application deployment on a new host.
The workflow transfers candidate files to a separate directory on the host.
scripts/apply-edge.sh checks the candidate Compose definition, pulls its Caddy
image, and validates the candidate Caddyfile with that image before changing
the active files. If validation fails, the running configuration and active
files remain in place. A Caddyfile-only update copies the validated file into
the existing bind mount and calls caddy reload; it does not recreate Caddy.
An intentional Compose change runs docker compose up -d, which may recreate
the service. A failed reload restores the previous files and attempts to reload
the previous configuration. The workflow checks each selected site's /health
over HTTPS on the SSH target host, forcing its hostname to loopback while
still validating its TLS certificate. This tests edge readiness without needing
an application installed, and cannot accidentally validate an old DNS target.
DNS and certificate issuance must be ready for this check to pass; a new edge
may be installed while this check fails during a planned DNS cutover. It is not
application acceptance: each app deployment separately checks the real API and
browser revision. Inspect the job log and host state if activation or recovery fails;
do not assume a failed job automatically restored service availability.
An unchanged candidate leaves a running Caddy container alone, but starts it if
it is stopped. This cannot repair a port conflict with another host process.
The installed profile marker must agree with an application's configured
profile before release files are transferred. A normal edge update also refuses
to change an installed profile. Intentional migrations require
allow-profile-change and production approval in a separate authorization
job, then use the selected environment's own credentials and approval rules to
apply the change. Normal isolated staging updates do not enter the production
environment. Existing unmarked edge files
from the previous shared-only setup may be adopted only as shared through
production; they cannot silently become an isolated edge.
Application workflows update only their own release directory and API. They
require their host's edge network and never sync or restart Caddy. Staging
deployments queue instead of cancelling an in-flight deployment. Production
/api/alive is checked before and after staging updates only when staging's
profile is shared. Isolated staging neither requires production's URL nor
depends on production being available.
Shared-profile adoption
The explicit-profile workflow is merged. Read-only GitHub inspection on
2026-09-22 confirmed EDGE_PROFILE=shared in both environments and a
successful CD Edge run using the production environment and shared profile; see the
recorded evidence. This replaces the earlier
pre-merge setup instructions. Current host state and application acceptance
after that edge run still need the checks listed below.
For a host still using the unmarked legacy shared layout, the deployment
administrator first confirms EDGE_PROFILE=shared in both environments under
Settings > Environments > staging/production > Environment variables and
confirms each environment's SSH host/key configuration. With deployment
authorization, dispatch CD Edge from main, select production, leave
allow-profile-change off, and approve it. This adopts the shared marker while
preserving the Compose project and certificate-volume names. Application
preflight accepts the legacy shared layout during adoption. Record the edge
run, both host-local HTTPS checks, and subsequent application smoke evidence;
merging the workflow alone does not complete adoption.
Later: move staging to its own server
- Provision the new host with Docker/Compose, curl, a deployment account with
write access under
/srv/opengamebuilder, and ports 80/443 available for its edge. Do not run a competing native Caddy service. - Verify the new server's SSH host key through the
host-key guide. Change only staging's SSH
secrets/host-key variable to the new host and set its
EDGE_PROFILE=staging. - Coordinate DNS and certificate issuance for
staging.opengamebuilder.com. Run CD Edge frommainwith environmentstaging, then CD Staging. A first edge check may need rerunning after DNS/certificates are ready; it deliberately does not accept a successful response from the old server. - Verify the new host's edge, API revision, and browser flow. Then set
production's
EDGE_PROFILE=productionand dispatch CD Edge through production withallow-profile-changeenabled. This deliberately removes the obsolete staging route/mount from the old host; coordinate the brief proxy interruption. Keep the old staging data until the cutover is accepted.
Pause conflicting deployment runs during the move. Moving credentials/profile alone does not migrate application data, DNS, certificates, or release history. Retain a documented recovery plan; no migration or cleanup is performed by a normal application deployment.
To move production instead, provision and verify its new host, set
production's own SSH configuration and EDGE_PROFILE=production, then run its
edge and application workflows and verify the cutover. Once production is
accepted on the new host, leave staging's SSH configuration pointing at the old
host, set staging's EDGE_PROFILE=staging, and dispatch CD Edge with
environment staging and allow-profile-change enabled. Its separate
production approval authorizes removing the old production route/mount; the
apply job still uses staging's credentials to reach the old host. You do not
need to point production credentials back at that host. The same DNS,
certificate, data-retention, and recovery precautions apply in either direction.
Host Caddy conflicts and read-only verification
Only the Docker edge should own this server's ports 80 and 443. A separately
installed caddy.service can start at boot, occupy those ports, and prevent
ogb-edge-caddy-1 from starting. An API container being up does not establish
that a public site is reachable.
In an existing root SSH session on the Hetzner server, from any directory, these commands inspect state without changing services or printing environment variables or private keys:
systemctl is-enabled caddy
systemctl is-active caddy
ss -ltnp '( sport = :80 or sport = :443 )'
docker ps --all --filter name=ogb- --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
docker inspect --format '{{.Name}} image={{.Config.Image}} status={{.State.Status}} restarts={{.RestartCount}}' ogb-edge-caddy-1
head -n 1 /srv/opengamebuilder/edge/Caddyfile
docker logs --since 2h --tail 150 ogb-edge-caddy-1 2>&1
# Choose an application actually deployed on this host; repeat for the other
# only if this is a shared host.
environment=staging
docker logs --since 2h --tail 150 "ogb-${environment}-api-1" 2>&1
The native Caddy service should be disabled/inactive (or not installed).
systemctl returns a nonzero status for some of those expected states; run
these inspection commands individually, not in a fail-fast script. Container
logs may contain client addresses or request data; redact sensitive content
before sharing them.
If inspection confirms the native service is the conflicting, superseded OGB proxy, coordinate a short interruption for the sites on this host, then run:
systemctl disable --now caddy
docker start ogb-edge-caddy-1
Do not stop a service hosting unrelated sites. No root-password reset or web
console is needed when the existing SSH session works. Restarting the container
restores its existing image; it does not apply a new image pin. After an edge
change is reviewed and merged, use GitHub Actions > CD Edge > Run workflow,
select main and the correct environment (production for a shared host), and
approve it. A Compose image change can briefly interrupt the sites on that host.
Verify the running image reference matches deploy/edge/compose.yml and that
the selected host's edge checks and its deployed applications' smoke tests pass.
Outstanding host verification
The remaining evidence belongs to ostomachion, the deployment and host
administrator. As of the 2026-09-22 documentation check, the following
items remain unverified; no SSH session or host change was performed for this
check. Record dated, redacted results here when completed.
| Remaining check | Owner | Required evidence |
|---|---|---|
| Original SSH host-key trust source for each environment | ostomachion (deployment administrator) |
Confirm whether each pin came from an authenticated independent channel or an established trusted SSH connection, using the host-key guide. Successful strict SSH alone does not establish how the first key was authenticated. |
| Current installed profile and running Caddy digest | ostomachion (host administrator) |
Capture the marker and running container image reference with the read-only commands above; compare with the intended profile and the pinned image in the reviewed deploy/edge/compose.yml. A successful past edge apply is not a fresh host inspection. |
| Competing native Caddy boot and listener state | ostomachion (host administrator) |
Record systemctl is-enabled/is-active caddy and port-owner output. The native service must be disabled/inactive or absent, and the Docker edge must own 80/443. Remediation, if needed, is a separately authorized operation. |
| Application acceptance after shared-profile adoption | ostomachion (deployment operator) |
Record the next authorized staging browser smoke and production application check after edge run 35681866962. Its host-local /health checks establish edge readiness only; the latest inspected staging browser run preceded the edge apply. |
Separate-host migration and backup-operator readiness are not established by shared-host acceptance. Follow the migration procedure only when that move is authorized; operator continuity remains tracked in practical stewardship.
Application artifacts
After source validation, the package job produces one Release web archive.
After environment approval, deployment verifies that archive and builds and
pushes the API image once to the triggering repository owner's package namespace.
It pins API_IMAGE to the pushed image digest (not its mutable commit tag),
checks that exact image's API liveness locally, and stores
releases/<release-id>/release-manifest.txt with the source SHA, image digest
reference, and web archive SHA-256. The same web archive can be served at any
hostname with an /api/* reverse proxy; the browser calls the page's origin.
The only cross-origin URL is the explicit local Development override. Separate
staging and production runs package independently, so compare their manifests
rather than assuming identical bytes for the same commit.
Application activation and rollback
The host keeps immutable application metadata in
/srv/opengamebuilder/<environment>/releases/<release-id>/ and its web files in
web/releases/<release-id>/. The current and previous files contain paths to
those metadata directories. The root web/index.html is atomically replaced with
a redirect to the active /releases/<release-id>/index.html file. The deploy
script removes old root index.html compression sidecars before that switch so
Caddy cannot serve a stale compressed page. It also removes the packaged
release's index sidecars because they retain the original / base path. Blazor
maps the concrete index.html URL to the home page. Loaded pages continue to
request their own release's assets, and the prior web directory remains
available. The first deployment with this layout copies the
old in-place web files into a legacy-* release and retains its original root
assets when a prior release-manifest.txt exists. For an initial deployment,
there is no current or previous release, root manifest, or root index. The
candidate's releases/<release-id>/predecessor records none; an upgrade records
the exact releases/<previous-id> path instead. This record is written before
the pending marker and before starting the API. Recovery never interprets a
missing previous file as evidence of an initial deployment.
scripts/deploy-app.sh checks the archive checksum, stages the new files,
records the previous release, pulls and starts the API by its digest reference,
records the edge network as its forwarded-header trust boundary, then switches
the root page. The deploy job uses a browser to follow that page,
observe the frontend's /api/about request, and compare the API's source revision
with the protected commit being deployed. It also checks that the page renders
the returned application name and version. A failed activation or browser check
invokes rollback. For an upgrade, it validates the previous release against the
candidate's predecessor record and restarts that API image without rebuilding it.
A missing or corrupt expected predecessor fails recovery and retains pending
for operator investigation. A successful browser check clears the pending marker
with finalize.
For a pending initial deployment, rollback captures available container logs in
releases/<release-id>/recovery.log, then uses the candidate's Compose files to
stop and remove its API container, including one partially started by a failed
activation. It removes the root index and compression sidecars, current, and
the root release manifest. This is the defined undeployed state: no running
application API or active root page. The shared edge remains in place. Incoming
files, candidate metadata, recovery logs, and versioned web assets are retained;
retained assets may still be reached directly by their release URLs.
Recovery clears pending only after cleanup succeeds. If stopping the API or
removing active files fails, inspect the error and recovery log, correct the
cause, and repeat rollback for the same pending release. New activations remain
blocked until recovery succeeds. Retry deployment with a new release ID (a new
workflow run or attempt), since failed candidates are retained. After a first
release is finalized, it has no previous release to restore; a later successful
upgrade establishes that rollback target.
For a controlled staging rehearsal after a successful normal rollout, dispatch
CD Staging from main with rehearse-rollback enabled. It first passes
the browser check, then deliberately fails before finalize; the recovery step
must restore previous. This run is expected to be red, so inspect the recovery
step and independently verify the public page and /api/about afterward. Leave
the input off for normal staging updates.
Inspect current, previous, pending, the pending candidate's predecessor,
and the manifests before manual recovery. From a deployment checkout with SSH
access, recover a pending activation with its exact release ID:
ssh -i <deployment-key> <deploy-user>@<deploy-host> \
bash -s -- rollback /srv/opengamebuilder/staging <pending-release-id> < scripts/deploy-app.sh
Use production in place of staging for production. Without a pending
activation, omit the release ID to restore the retained previous release. For
an upgrade rollback, verify the public page and /api/about after it returns;
for initial-deployment recovery, verify the API is stopped and the active root
files are absent. If predecessor state is missing or damaged, investigate and
repair it from verified release records; do not delete pending or invent none
to bypass recovery. Do not delete a release directory while old browser sessions
may still request its assets. Coordinate manual recovery with the environment's
deployment queue.
The Compose service is replaced before the root web redirect switches. During that short interval, an already loaded page can call the new API, so API changes must remain compatible with the retained frontend until its clients have aged out. This mechanism provides an atomic web switch and a recoverable pair; it is not a zero-downtime atomic swap of the API and web processes. See the recorded deployment evidence for rehearsal results.
To recover an edge change, fix the candidate on main and dispatch CD Edge
for the affected host again. For an urgent host-side recovery, use the last known-good edge
files retained in the host's .rollback.* directory after a failed activation,
validate them with caddy validate, and reload Caddy. Do not restart or
recreate Caddy for a Caddyfile-only correction. Coordinate host-side changes
with any active edge workflow so its next write does not overwrite the repair.
Recorded deployment evidence
Read-only GitHub inspection on 2026-09-22 confirmed that
PR #113 merged at
e164922c19213ae1ca2936554cca3970269cfac6, and both environment EDGE_PROFILE
values are shared. CD Edge run 35681866962
at that commit selected production credentials and the shared profile. Its apply
step recreated and started Caddy, and both selected host-local edge checks passed
at 03:27 UTC on 2026-09-22. This establishes recorded shared-profile adoption,
not just a merged configuration change.
The staging run at the same commit passed profile preflight and browser smoke by 03:06 UTC, before that edge apply. It does not establish application acceptance after adoption. The outstanding checks retain that follow-up and current-host/key-provenance gaps with their owner.
The successful staging rollout and 2026-09-20 rollback rehearsal record activation and recovery of an existing installation. The rehearsal deliberately failed after browser acceptance and restored the prior release without rebuilding; its red status is intentional. It did not exercise recovery of a failed initial deployment with no predecessor. That recovery path has local mocked regression coverage, but no live rehearsal is recorded here.
The production v0.10.0 release and subsequent staging rollout record later application acceptance. These historical runs do not establish current host state, original SSH-key provenance, separate-host migration, or a backup operator's readiness. Use the procedures above for current operations and record new live verification separately from local checks.