Skip to content

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:

MOTO_API_PORT=8088
MOTO_DEVICE_CREDENTIALS=device-id:bedroom:<64-character-sha256-hex>

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:

./scripts/deploy-ovh.sh plan

After reviewing and committing a clean tree:

./scripts/deploy-ovh.sh apply --commit <commit>

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:

./scripts/operate-ovh.sh status
./scripts/operate-ovh.sh health
./scripts/operate-ovh.sh logs

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.