OVH deployment¶
Observed environment¶
A read-only inspection on September 27, 2026 found the existing AndreaWeb
deployment under /opt/andreaweb, with system Nginx terminating public HTTP
and HTTPS. Its application containers bind loopback ports 3000, 3100, 3902, and
9000. Moto X Dashboard therefore defaults to unused loopback port 8088.
On September 30, 2026, DNS and a dedicated HTTPS site were configured for
retroapi.everybodypackit.com. Let's Encrypt issuance and simulated renewal
passed; AndreaWeb's configuration was unchanged and its HTTPS check passed.
The site initially returns a deliberate 503 placeholder. API deployment and
switching that placeholder to a proxy are separate operations; do not infer
deployment merely from successful certificate issuance.
Activation completed September 30, 2026 (America/Bogota): release
b2221b742993db040467c92064877e83bbd83450 is running as the healthy
motoxdashboard-api-1 container, with /opt/motoxdashboard/current pointing
to releases/b2221b742993. The dedicated HTTPS site now proxies to loopback
8088; public readiness and the schema-1 empty dashboard manifest passed.
AndreaWeb's configuration was unchanged, its HTTPS check passed, and all six
containers remained healthy. The previous site backup is
/var/backups/retroapi-deploy.JzKoAfOg. Provider integration and Android polling
were not deployed at that activation; the provider rollout below supersedes
that scaffold. Future releases use
the normal release workflow, not the one-time bootstrap.
A read-only check early on 1 October reconfirmed that current and
shared/deployments/api.commit still identify that scaffold. The locally frozen
candidate and its 256 MiB rehearsal results are recorded in the
PoC release receipt; it has
then had not been deployed. The later authorized rollout follows.
Provider deployment: 1 October 2026¶
The exact saved candidate a47e94caab8c1a33c1db33df7e27de95f9888a1e is
running on OVH. No rebuild, bootstrap, DNS change, Nginx edit or reload occurred.
Transferred archives passed SHA-256 verification; the loaded Docker 24 image ID
matched sha256:927c0cb13965405529e33c3420b886b4f13eb7c3877576463ac861b2c4a13201.
current points to /opt/motoxdashboard/releases/a47e94caab8c; deployment
records identify the same commit/image. Saved artifacts remain in
/opt/motoxdashboard/artifacts/a47e94caab8c.
Fresh preflight found 24 GB free, about 2.6 GiB available RAM and 1 GiB swap
(about 7 MiB used). All six AndreaWeb containers were healthy, and its public
HTTPS response was 200 before and after deployment. All Nginx site file hashes
were unchanged. sudo -n nginx -t required interactive authentication and did
not run; no configuration was changed or reloaded.
Public HTTPS readiness, anonymous manifest 401, authenticated manifest 200/304, and every returned asset's anonymous 401, authenticated 200, SHA-256 and 304 checks passed. Weather, NYT and Guardian are live; Yankees is missing. MLB returns HTTP 406 from the edge reached by the API on OVH. Bounded diagnostic requests with the same URL/headers returned 200 through one Fastly edge and 406 through another, suggesting edge-dependent rejection rather than a proven query defect. No edge IP was pinned in production or provider implementation changed. Four-source production acceptance remains open; normal retries continue. A Docker sample showed about 47 MiB of 256 MiB; this is not a peak-memory measurement.
Device xt1058-bedroom maps to profile bedroom. The raw token stays in the
workstation's private
/home/justin/.local/state/motoxdashboard/production-20261001/device.token
and phone app-private storage. Only its hash was installed on the server.
Public probe evidence is alongside the local token under probes/; never
publish this private directory. The XT1058 activated/displayed real cards over
production HTTPS with no ADB reverse mapping. Physical Wi-Fi-off testing
confirmed no active network, failed refresh and continued cached cards;
restoring Wi-Fi and resuming produced a successful 304 refresh. Just Clock
survived force-stop/relaunch without polling during observation. Highlights
returned to Just Clock. Bulletins selection persisted across restart; no
scheduled wall-clock boundary was forced. The phone was left in Dashboard mode
with Wi-Fi restored.
Moto X Play enrollment: 2 October 2026¶
The Android 10 XT1563 was enrolled separately as xt1563-bedroom in the same
bedroom profile. Its raw token remains in the workstation's private
/home/justin/.local/state/motoxdashboard/play-xt1563-20261002/device.token
and in the Play's app-private storage. Only its SHA-256 was appended to
/opt/motoxdashboard/shared/production.env; the original environment was
saved at /opt/motoxdashboard/shared/backups/pre-play-20261002/production.env
before the change. The existing a47e94caab8c container was recreated with
that updated configuration. No image rebuild, release switch, Nginx or DNS
change occurred. Health and AndreaWeb HTTPS remained 200; all six AndreaWeb
containers stayed healthy.
The Play's old debug loopback override was removed. With no ADB reverse
mapping, the phone fetched and rotated real weather/NYT/Guardian cards from
the production HTTPS origin. Its token returned manifest 200, conditional
304, and an asset 200 whose bytes matched the manifest SHA-256. Just Clock and
Dashboard were selected on-device. During physical Wi-Fi loss, cached cards
remained visible despite UnknownHostException; after Wi-Fi returned, the
app received 304 and resumed rotation. It was left in Dashboard mode with
Wi-Fi on. Yankees remains absent due to the upstream MLB 406.
To revoke only the Play, remove its xt1563-bedroom:bedroom:<hash> entry from
the private credential list and recreate only the API container using the
current release's install-release.sh, then verify anonymous 401 and the Moto
X credential still returns 200. Restoring the entire saved environment is
appropriate only if no later credential changes need preservation. Never put
the raw token into the VPS environment or command arguments.
Configuration-aware recovery and rollback¶
Owner-only backup directory:
/opt/motoxdashboard/shared/backups/pre-provider-20261001. It contains the
old production.env, current.txt, deployment records and Nginx checksums.
provider-production.env separately preserves the new hash-bearing environment.
The installer updates deployment records before public acceptance, so those
records were backed up too.
To recover the exact authenticated image without rebuilding:
cp -p /opt/motoxdashboard/shared/backups/pre-provider-20261001/provider-production.env /opt/motoxdashboard/shared/production.env
cd /opt/motoxdashboard/artifacts/a47e94caab8c
sha256sum -c SHA256SUMS
docker load -i image.tar
test "$(docker image inspect --format '{{.Id}}' motoxdashboard-api:a47e94caab8c)" = "$(cat image.id)"
cd /opt/motoxdashboard/releases/a47e94caab8c
bash scripts/install-release.sh /opt/motoxdashboard/shared/production.env a47e94caab8c1a33c1db33df7e27de95f9888a1e
Repeat public auth/asset probes before accepting recovery and restore current
to that release after they pass. Restoring this environment would undo later
token rotations; review it before use.
To revert configuration to the old scaffold, first stop only this API:
docker stop motoxdashboard-api-1
cp -p /opt/motoxdashboard/shared/backups/pre-provider-20261001/production.env /opt/motoxdashboard/shared/production.env
cp -p /opt/motoxdashboard/shared/backups/pre-provider-20261001/deployments/api.commit /opt/motoxdashboard/shared/deployments/api.commit
cp -p /opt/motoxdashboard/shared/backups/pre-provider-20261001/deployments/api.image /opt/motoxdashboard/shared/deployments/api.image
ln -sfn /opt/motoxdashboard/releases/b2221b742993 /opt/motoxdashboard/current
This deliberately leaves public requests receiving 502: it restores old configuration/records, not a running old service. Never restart the scaffold while Nginx exposes port 8088. A separately reviewed privileged 503-placeholder change or equivalent isolation must precede any scaffold restart. No rollback was executed during this three-source rollout; MLB acceptance remains open.
A fresh pre-deployment inspection found 24 GB free on the 39 GB root disk, 2.6 GiB available memory, and an existing 1 GiB swap file. All six AndreaWeb containers were healthy. These are observations, not ongoing capacity guarantees.
Initial activation uses scripts/bootstrap-retroapi.sh, reviewed and uploaded
alongside the locally prepared artifacts. Load the image as the deployment
user, then run sudo bash /home/justin/bootstrap-retroapi.sh
/home/justin/retroapi-release-<short-commit> interactively. The script refuses
an existing /opt/motoxdashboard, validates the staged checksums/image ID,
checks port availability and AndreaWeb, and backs up the dedicated Nginx site.
It extracts/executes application release code as justin, not root. On failure
it restores the placeholder and stops only this API; retained files must be
inspected before retrying. Do not rerun the earlier HTTPS-placeholder setup
against the activated proxy site.
Intended topology¶
system Nginx :443
-> 127.0.0.1:8088 MotoXDashboard API
Docker Compose project: motoxdashboard
-> stateless ASP.NET Core API container
The public hostname is retroapi.everybodypackit.com. The implemented transport
uses a normally renewed public certificate plus app-scoped ISRG Root X1 and
ISRG Root YR anchors alongside Android's system roots. It excludes user-installed
CAs; installing a user CA is not needed for this app. Hostname and chain
validation remain mandatory; never pin the renewable leaf certificate or disable
verification. See
ADR 0002.
Port 8088 is only the collision-free host-side loopback mapping; the ASP.NET
container continues to listen on its normal internal port 8080. It is not a
public port or protocol requirement and may change if the VPS allocation changes.
Add Moto X Dashboard through a separate Nginx site/server block. Do not insert its routes into AndreaWeb's site or reuse AndreaWeb's business media storage. Future Moto media should use a dedicated durable path and backup boundary.
One-time server preparation¶
The release scripts expect:
/opt/motoxdashboard/
current -> releases/<commit>
releases/
shared/
production.env
artifacts/<commit>/
image.tar
source.tar
SHA256SUMS
The directory is created on the VPS, not in the local checkout. A one-time
reviewed privileged script creates it with deployment ownership for justin.
Nginx and certificates remain root-managed. shared/production.env must be
mode 0600 and must never enter Git. A minimal private environment begins with:
Baseball evaluation rollout¶
The checked-in evaluation profile assigns separate Yankees cards to API-Sports
Baseball (TeamId 25) and BALLDONTLIE MLB (TeamId 19), with distinct
attribution. The old MLB Stats source is disabled and unassigned. The five-card
evaluation release was deployed on 2 October. The phone contract and device
credentials did not change.
The private production.env needs MOTO_API_SPORTS_BASEBALL_KEY and
MOTO_BALLDONTLIE_BASEBALL_KEY in addition to its existing device hashes.
compose.prod.yaml maps them explicitly to the corresponding .NET options;
Compose's --env-file alone does not inject them. Never place raw keys in
appsettings.json, Git, an APK, a release receipt, or a shell argument. The
keys disclosed in chat are suitable for this bounded evaluation but should be
rotated before any longer-lived or wider deployment.
Read-only OVH probes on 2 October returned HTTP 200 from both providers. The
API-Sports date-only response included the next Yankees game; its observed
free plan denied a season=2026 filter. BALLDONTLIE returned a recent result
and next game with a nine-day, team-filtered query. These checks did not
activate either source in the public API. Before deployment, run the normal
format/build/test and script checks, then prepare a clean exact image. The
rehearsal helper requires MOTO_API_SPORTS_KEY_FILE and
MOTO_BALLDONTLIE_KEY_FILE pointing to private local key files; it passes
them through a short-lived Docker env file and removes that file afterward.
Its accelerated repeat/failure checks cover weather and news. Baseball keeps
quota-aware refresh floors, so the bounded rehearsal verifies each baseball
card and asset once, not repeated baseball updates or failure recovery within
minutes.
After reviewing the deployment plan and applying the exact image, verify both attributed sports slides and assets over authenticated public HTTPS, plus the unaffected weather/news cards, anonymous 401 and conditional 304. Inspect provider request counts and the container's memory/CPU/PID limits. A later end-user policy could choose one source and use the other as a controlled fallback; this evaluation intentionally shows both. Rollback must restore the previous image/source configuration and the prior private environment together.
Dual Yankees evaluation deployment: 2 October 2026¶
The exact clean commit dc7568c1e72bd040fdabf5101f17cf9db4ab1ac5 was
prepared, rehearsed, and deployed as motoxdashboard-api:dc7568c1e72b.
The Docker image ID was
sha256:a3558bb4e18b833026e5fea0a31aab61f03bdc2c0cfb7f3602c589bfca022f70
in both the saved local release and the deployed image. The current symlink is
/opt/motoxdashboard/releases/dc7568c1e72b; the full commit is recorded in
shared/deployments/api.commit. No DNS, Nginx or certificate change was needed.
Before deployment, AndreaWeb returned HTTPS 200, all existing containers were healthy, the root filesystem had about 24 GB free, and about 2.5 GiB RAM was available. The exact saved image passed anonymous 401, authenticated 200/304, five real card and PNG hash checks, repeated weather/news publication, and a provider-network outage/recovery. Its kernel memory peak was 67,362,816 bytes under the 256 MiB cap; OOM counters stayed at zero. The rehearsal retained the 0.5 CPU and 128 PID limits. This bounded test does not establish live-game freshness or a multi-hour bedside soak.
The private production environment contains both provider keys; the existing
device hashes were preserved. The previous file is preserved
at /opt/motoxdashboard/shared/backups/pre-dual-providers-20261002/production.env.
The new public HTTPS manifest returned five distinct cards: Open-Meteo weather,
API-Sports Yankees, BALLDONTLIE Yankees, NYT, and Guardian. Anonymous requests
returned 401, conditional requests returned 304, and all five authenticated
PNG assets matched their declared SHA-256 hashes. Public readiness and AndreaWeb
HTTPS returned 200 after deployment. The XT1058, with no ADB reverse mapping,
completed a Dashboard lap through all five cards and began another lap.
Android commit 9be243c corrected the client-side bulletin cap, which had still
clamped the new 75-second server value to 60 seconds. The updated debug APK was
installed on the XT1058 without clearing its app data, and 33 on-device
instrumented tests passed. Dashboard rotation had already worked; this client
fix allows a scheduled five-card bulletin to reach its final card. The backend
image and private configuration were not changed by this APK update.
To roll back the evaluation, first review the saved environment against any
credentials added since this deployment. Restore a matching private environment
for retained release a47e94caab8c, verify its saved archive, and run that
release's scripts/install-release.sh as in
configuration-aware rollback.
Restore the prior environment and image together: the earlier image expects its
own provider configuration and will again lack a Yankees card because its MLB
Stats upstream returned 406. Do not restore the pre-dual environment blindly
if credentials changed later. Validate public 401/200/304, assets, AndreaWeb,
and both devices before moving the current symlink. No rollback was executed.
The credential value is a comma-separated list of
deviceId:profileId:sha256hex entries. compose.prod.yaml maps
MOTO_DEVICE_CREDENTIALS to the API's Dashboard__DeviceCredentials
setting; Compose's --env-file alone only interpolates variables. Generate a
token with scripts/new-device-token.sh DEVICE_ID PROFILE_ID TOKEN_FILE.
The script writes the raw bearer token to a new mode-0600 file and prints only
the matching hash entry. Keep that file on the provisioning workstation, not
on the VPS or in Git. Provision it over USB with
scripts/provision-device-token.sh TOKEN_FILE; the token is streamed on
standard input into app-private storage and is not placed in command arguments
or logs. These helpers require a debuggable APK for run-as. To revoke a
device, remove its entry from the private environment and restart the API.
ADB is optional: tap the clock, open Connection, paste the token and tap Save. This also works with non-debug APKs. New APK installations contain no token; server-side hash registration is still required. Automatic enrollment and QR/short-code pairing are not implemented.
After a token is imported, a fresh app process is currently needed to recover from an already displayed authentication failure. Resume imports the token and requests a refresh, but the current presenter does not clear that failure state until it is recreated. Include this cold restart in token-rotation procedures.
Release workflow¶
Build on the local Windows/WSL machine using Docker Desktop's Linux engine,
not on the VPS. In WSL use the repository's Linux path. In Windows use a
native clone and Git Bash (the helpers are Bash scripts), not Windows Gradle
or build tools against a UNC path. SSH must resolve the ovh alias in that
environment; verify with ssh -o BatchMode=yes ovh hostname first.
Packaging also requires curl, tar, sha256sum, and jq. The saved image's
configuration digest is recorded as image.id, matching OVH's Docker 24 image
ID rather than Docker Desktop's containerd manifest-index ID.
Run the backend checks in Testing and quality before building a release. A standalone local packaging/rehearsal command is:
bash ./scripts/test-deployment-helpers.sh
bash ./scripts/test-prepare-release.sh
bash ./scripts/test-rehearse-release.sh
bash ./scripts/prepare-release.sh <commit> /absolute/private/releases/new-release
bash ./scripts/rehearse-release.sh /absolute/private/releases/new-release /absolute/private/rehearsals/new-report
The helper refuses a dirty tree, archives the exact commit, builds a
linux/amd64 image, starts a temporary constrained container on an ephemeral
loopback port, checks readiness and the dashboard route, then removes that
temporary container. It retains artifacts for inspection. image.tar is a
Docker save archive, not an executable, ZIP, OCI registry, or docker
export filesystem snapshot; load it with docker load -i image.tar.
The final image includes the ASP.NET runtime base layer and published DLLs; the SDK stays only in the build stage. No host .NET installation is needed. The currently referenced .NET base tags are mutable; the saved image and its recorded image ID are the rollback artifact, not a later rebuild of those tags.
Plan without changing local or remote state:
After reviewing and committing a clean tree:
The apply action builds and smoke-tests locally, transfers the source archive
and commit-tagged Docker image, verifies archive SHA-256 checksums, and loads
the image on the VPS. It starts the stable motoxdashboard Compose project
with --no-build --pull never, checks readiness, records the commit/image ID,
and only then moves current. It does not alter Nginx or delete old releases.
Automatic application CI/CD and commit gates remain deferred.
Local release preparation checks anonymous manifest denial (401), authenticated manifest delivery (200), and conditional-manifest reuse (304). A changed-ETag 200 between probes is retried at most five times because providers can publish during cold start; unchanged-ETag 200 and other failures remain errors. The VPS installer independently checks readiness and verifies that an anonymous manifest request returns 401 before it records the installed commit/image. It does not have the raw device token and does not probe authenticated assets or real provider content. Record public Nginx/API acceptance separately after an authorized rollout.
Frozen release candidate¶
Commit the reconciled implementation and scripts/rehearse-release.sh as code
commit A. Run scripts/prepare-release.sh A <new-absolute-output-directory>
once, then run the full release rehearsal against that saved image.tar.
Record its image ID, artifact directory, and results in the release-candidate
record in the PoC plan, then commit that record as
documentation-only commit B. B documents the exact artifact built from A;
it does not change the tested code identity.
scripts/deploy-ovh.sh apply --commit A builds a new image when run. It does
not deploy the already rehearsed tar byte-for-byte. A later deployment of the
frozen candidate must use A's preserved image.tar, source.tar,
commit.txt, image.id, and SHA256SUMS; do not run apply, which
builds a new image. Transfer those files to the VPS, verify them with
sha256sum -c SHA256SUMS, load the saved image using docker load -i image.tar,
and extract source.tar to the release directory for commit A. Run that
retained source tree's scripts/install-release.sh with the matching private
production.env and full commit A. Do not rebuild during installation.
Compare the loaded image's configuration digest with the preserved image.id
(OVH Docker 24 .Id, not Docker Desktop's manifest-index ID) before moving
current. After an authorized rollout, use the workstation-held token to
verify public anonymous 401, authenticated manifest and asset 200, conditional
304 responses, and cards from the expected real providers.
The installer does not move current. After the checks pass, the deployment
user explicitly runs ln -sfn /opt/motoxdashboard/releases/<short-commit>
/opt/motoxdashboard/current. Ordinary updates preserve the dedicated Nginx
proxy and do not rerun bootstrap. Preserve the previous link target and private
environment for a compatible rollback.
The initial bootstrap used a user-home staging directory until /opt was
prepared. Its reviewed privileged script installed the tested scaffold release,
then replaced only the dedicated site's placeholder with a proxy to
127.0.0.1:8088. Preserve HTTP ACME handling and the renewal hook. Run
nginx -t before reloading, use bounded retries after asynchronous reload,
verify both public health/manifest routes, and compare AndreaWeb configuration
checksums and HTTPS before/after. On failure restore the previous dedicated
site. That first rollout had no older API release; its rollback was the
placeholder plus stopping only the motoxdashboard API. Later rollback must
account for the authenticated provider release as described below.
The container is non-root, read-only, capability-free, and constrained to 256 MiB RAM, 0.5 CPU, and 128 PIDs. Docker JSON logs rotate at 5 MB with three files. These are the production scaffold limits; measure and review them against full provider refresh and rendering behavior before rollout. Host Nginx logs use the VPS's separate logrotate policy; Docker settings do not rotate Nginx logs. The API keeps current and two previous snapshots per profile in memory, including their PNG bytes. New publications evict older snapshots; responses already holding an asset reference may finish. There is no daily filesystem asset retention or cleanup job, and no Nginx static-assets location. Restarting discards snapshots; expired cards are omitted during recomposition. Android separately sweeps unreferenced cache/staging files within its 32 MiB bound while protecting its displayed generation. Photographs/music will require a separate durable-storage design, not an extension of this volatile cache.
Read-only operational commands:
Rollback uses a retained release and saved image, after verifying contract
and authentication compatibility. The previously deployed
b2221b742993db040467c92064877e83bbd83450 scaffold serves its schema-1
manifest anonymously (200); the provider release requires a device token
(anonymous requests return 401). Its older Compose configuration and installer
also predate MOTO_DEVICE_CREDENTIALS and the anonymous-401 assertion. A
rollback to that scaffold therefore restores an unauthenticated API contract.
Do not point the public Nginx site at it as an ordinary rollback: restore the
dedicated site's deliberate 503 placeholder (or keep the API otherwise
unreachable) before reactivating the scaffold, then separately decide how to
serve it safely. A rollback to another authenticated release can use that
release's own compatible configuration and installer.
For a compatible retained release, load its checked archive, run that release's
scripts/install-release.sh with the matching private environment and full
commit, verify health and the expected authentication behavior, then move
current to it. Do not rebuild during rollback or delete release directories
as a side effect. Retained image archives consume disk; explicit reviewed retention/cleanup is
a later operational task, not part of each deploy.
Acceptance boundary¶
Check https://retroapi.everybodypackit.com/health/ready and
https://retroapi.everybodypackit.com/api/v1/dashboard without bypassing TLS.
The old scaffold returned an empty manifest anonymously. For the deployed
provider release, require anonymous manifest 401, authorized manifest 200 with
the expected live cards, conditional manifest 304, and authorized asset 200
and 304. Verify Android 5.1 transport with the actual device as a separate
acceptance check; desktop HTTPS success does not prove its trust store accepts
the chain. Production release, Nginx activation, and Android token provisioning
remain separate authorized operations.