Host production and PR previews on the VPS #303

Closed
opened 2026-10-09 09:12:42 +00:00 by root · 16 comments
root commented 2026-10-09 09:12:42 +00:00 (Migrated from gitlab.core-project.space)

Imported from GitHub amaurycoudr/chess-project#303, opened by @amaurycoudr on 2026-10-09 09:12 UTC.
Closed on GitHub on 2026-10-09 12:20 UTC as completed.


Problem Statement

Production and every PR preview run on free Render web services (ADR 0024). A free instance sleeps after 15 idle minutes and takes about a minute to wake, so the first Player to open a Room link after a quiet spell waits, and sleeping wipes every in-memory Room. Render's free tier also offers nothing for what comes next: the free Postgres expires after 30 days and the free Key Value loses its data on every restart, so accounts (the next increment) have no home there.

The owner has a VPS that is already provisioned for this: Ubuntu 26.04, SSH by key only for a deploy user, ufw open on 22/80/443 only, Docker, a shared Traefik reverse proxy with Let's Encrypt, and DNS for chess.core-project.space and *.chess.core-project.space. Nothing of chess-project runs on it yet.

Solution

Production and PR previews move to the VPS, behind the shared Traefik, and Render goes away.

  • main deploys to https://chess.core-project.space once CI is green on it. It never sleeps; a Room is lost only when production redeploys.
  • Every PR opened from the repo gets a public preview at https://pr-<n>.chess.core-project.space, redeployed on each push, removed when the PR closes, and never indexed by search engines.
  • At most five previews run at once; deploying a sixth removes the one deployed longest ago.
  • A nightly job removes any preview whose PR is no longer open, and the images nothing uses any more.
  • After each preview deploy, the browser suite runs against the deployed preview, so a pass speaks about the image, the proxy and the stream exactly as a Player receives them.
  • The image is built once in GitHub Actions and pushed to GHCR; the VPS only pulls and runs it.

User Stories

  1. As a Player, I want https://chess.core-project.space to answer immediately at any hour, so that a Room link I receive never makes me wait for a server to wake.
  2. As a Player, I want the site served over HTTPS with a valid certificate, so that my browser shows no warning when I open a Room link.
  3. As a Player, I want plain http:// links to redirect to HTTPS, so that an old or hand-typed link still works.
  4. As a Player, I want the front and /rpc on one origin, so that nothing about cross-origin requests can break a Move.
  5. As a Player in an Online game, I want the Room change stream to reach me through the proxy without buffering, so that my opponent's Move appears as fast as it does today.
  6. As a Player, I want my Seat to survive a reload on the deployed site, so that the Seat token keeps working behind the proxy as it does locally.
  7. As a Spectator, I want to watch a Room on the deployed site, so that sharing a link with a third person works in production.
  8. As a Player, I want the origin guard to accept the deployed origin, so that my mutations are not refused as cross-origin.
  9. As the owner, I want production to deploy only after CI is green on main, so that the gate stays the only door to production.
  10. As the owner, I want a production deploy to run the exact image CI built for that commit, so that what was tested is what runs.
  11. As the owner, I want two production deploys never to run at the same time, so that a fast second merge cannot leave production half-updated.
  12. As the owner, I want exactly one production process at any time, so that the in-memory Room registry stays the single source of truth.
  13. As the owner, I want production to restart on its own after a crash or a VPS reboot, so that it stays up without me.
  14. As the owner, I want each PR from the repo to get its own preview URL, so that I can try a change in a real browser before merging.
  15. As the owner, I want a preview redeployed on every push to its PR, so that the preview always shows the PR's latest commit.
  16. As the owner, I want PRs from forks never to deploy, so that the deploy key and registry credentials never reach code I did not write.
  17. As the owner, I want a preview's URL posted where I see the PR, so that I do not have to build it by hand.
  18. As the owner, I want previews public but marked noindex, so that I can share a preview link without it showing up in search results.
  19. As the owner, I want at most five previews at once, with the one deployed longest ago removed first, so that previews never starve production of memory.
  20. As the owner, I want a preview removed when its PR is merged or closed, so that the VPS does not fill with dead stacks.
  21. As the owner, I want a nightly job to remove any preview whose PR is no longer open, so that a missed close event cannot leak a stack forever.
  22. As the owner, I want the nightly job to prune images no container uses, so that the disk does not fill with old builds.
  23. As the owner, I want the browser suite to run against each deployed preview, so that a regression only the proxy, the image or HTTPS can cause shows up on the PR.
  24. As the owner, I want every deploy and cleanup workflow to verify its own outcome on the VPS, so that a run that "succeeded" without the expected stack, certificate or header fails loudly.
  25. As the owner, I want the Traefik setup versioned in the repo, so that the proxy can be rebuilt from source if the VPS is reset again.
  26. As the owner, I want the deploy workflows to pin the VPS's SSH host key, so that a deploy never trusts an unknown host.
  27. As the owner, I want the VPS never to hold a long-lived registry credential, so that a compromise of the server does not leak access to the repo's packages.
  28. As the owner, I want Render and render.yaml gone once the VPS serves production and previews, so that there is one hosting setup to maintain.
  29. As the owner, I want an ADR recording the move off Render, so that the reasons in ADR 0024 are not silently contradicted.
  30. As a contributor, I want the README's deploy section to describe the VPS, so that I know where production lives and how a PR gets its preview.
  31. As the owner, I want each deploy stack able to carry its own data services later, so that the auth increment adds Redis and Postgres per stack without reshaping the deploy.

Seams

  • Deployed browser suite (e2e). The existing Playwright suite in apps/e2e, driven against a deployed URL through a new target mode that starts no server and takes its base URL from the environment. Run after each preview deploy against that preview. Covers stories 1–8, 23. The existing local and built modes, and the CI guard against falling back to dev servers, stay as they are.
  • Workflow post-conditions. Each deploy and cleanup workflow ends with a verification step against the VPS and the public URL: the expected stack is running (or gone), the preview count is at most five, HTTPS answers with a valid certificate, http:// redirects, and a preview carries X-Robots-Tag: noindex while production does not. A failed check fails the run. Covers stories 9–22, 24–28, 31.
  • Stories 29 and 30 are documentation and need no seam.

Implementation Decisions

  • One image. The API serves the built front and /rpc from one origin, as today (ADR 0024's one-origin decision survives the move). A multi-stage container build produces one image running the built API with the front's build output, the way the Render start command does now. No health endpoint is added; GET / (the served front) is the liveness probe.
  • Build in CI, pull on the VPS. GitHub Actions builds the image and pushes it to GHCR, tagged with the commit SHA, plus a moving tag per target (production, pr-<n>). The VPS never builds. The repo is private, so the package is private: each deploy logs the VPS into GHCR with the job's short-lived token for the pull and logs out after, so no registry credential stays on the server.
  • One deploy compose for every stack. Production and each preview run the same compose definition, parameterized by stack name, image tag and host. It joins the shared proxy network and declares its Traefik router (host rule, websecure entrypoint, the Let's Encrypt resolver). Previews add a response-header middleware for X-Robots-Tag: noindex. One replica, restart unless stopped. Stacks are named chess for production and chess-pr-<n> for previews.
  • Data services per stack, later. The compose shape lets each stack carry its own Redis and Postgres, so the auth increment adds them per stack with empty, ephemeral data in previews. Production never shares a data service with a preview. Nothing data-related is added in this spec.
  • Configuration. Each stack sets ORIGIN_ALLOWLIST to its own HTTPS origin, FRONT_DIST_DIR, and PORT. The origin guard (ADR 0028) is unchanged.
  • Production workflow. Triggered by CI's success on main. It deploys the image built for that commit, in a concurrency group that queues rather than cancels. A redeploy drops the in-memory Rooms, an accepted cost as under ADR 0024.
  • Preview workflow. Triggered by pushes to PRs whose head is in the repo; fork PRs are skipped. Before deploying a new preview while five are running, it removes the preview deployed longest ago (stacks carry their deploy time as a label). After deploying, it comments or updates one PR comment with the preview URL, then runs the deployed browser suite against it.
  • Cleanup workflows. On PR close, the PR's stack is removed with its volumes. A nightly scheduled run lists the chess-pr-* stacks, removes those whose PR is not open, then prunes unused images.
  • Access to the VPS. Workflows SSH as deploy with a dedicated deploy key stored as a repo secret, the VPS's host key pinned in a secret rather than trusted on first use. deploy runs Docker without sudo.
  • Traefik is versioned. The shared proxy's compose moves into the repo, next to the deploy compose, and is applied by hand: it changes rarely and is shared with whatever else runs on the VPS.
  • Render goes away last. Once production and previews have run on the VPS, render.yaml is removed and the Render services deleted.
  • ADRs. A new ADR supersedes ADR 0024: host production and PR previews on the VPS behind Traefik. It lands in a dedicated rules PR, per the agent rules, together with the ADR index row. ADR 0028 (origin guard) and ADR 0022 (e2e binds only to what a Player perceives) apply unchanged; the deployed target adds no hook to the front or the API.

Testing Decisions

  • Prior art: the browser suite's built target, which already drives build output rather than dev servers, and its guard that refuses to fall back to dev servers in CI. The deployed target follows the same pattern with a URL instead of started servers.
  • The deployed suite runs as-is: every test mints its own Room through the UI, so tests stay independent on a shared preview. presence.spec.ts moves only the browser's clock and needs nothing from the server.
  • Workflow post-conditions must cover the unhappy paths: a container that starts then exits (stack listed but not running), a certificate not yet issued (HTTPS check retries within a bounded time, then fails), a sixth preview (count back to five after deploy), a closed PR whose stack is already gone (cleanup succeeds idempotently), and a fork PR (no deploy step runs).
  • The production post-condition asserts the absence of noindex, so a preview middleware leaking into production fails the deploy.

Out of Scope

  • Accounts, authentication, and the Redis and Postgres services themselves (the next increment; this spec only leaves room for them per stack).
  • Persisting Rooms across redeploys.
  • Zero-downtime or blue-green deploys.
  • Monitoring, alerting, log shipping and backups.
  • Automating the Traefik deploy, and redeploying core-project on the VPS.
  • Retention of old images in GHCR.
  • Provisioning the VPS: already done by hand (see Further Notes).

Further Notes

  • Current VPS state, set up by hand: Ubuntu 26.04; deploy user with sudo, SSH by key only, root and password logins refused; ufw allows 22, 80 and 443 only; Docker 29 with Compose v5; Traefik v3.6 running as its own compose project on a proxy network, HTTP redirected to HTTPS, Let's Encrypt by HTTP challenge, no dashboard, containers exposed only when labelled; DNS A records chess and *.chess on core-project.space point at the VPS; a dedicated deploy key is authorized for deploy.
  • Docker bypasses ufw for published ports, so only Traefik may publish ports on the VPS; no stack in this spec publishes any.
  • The free-tier facts behind the move: Render's free Postgres expires 30 days after creation and its free Key Value does not persist to disk.
> _Imported from GitHub [amaurycoudr/chess-project#303](https://github.com/amaurycoudr/chess-project/issues/303), opened by [@amaurycoudr](https://github.com/amaurycoudr) on 2026-10-09 09:12 UTC._ > _Closed on GitHub on 2026-10-09 12:20 UTC as completed._ <!-- github-issue:303 --> --- ## Problem Statement Production and every PR preview run on free Render web services (ADR 0024). A free instance sleeps after 15 idle minutes and takes about a minute to wake, so the first **Player** to open a **Room** link after a quiet spell waits, and sleeping wipes every in-memory **Room**. Render's free tier also offers nothing for what comes next: the free Postgres expires after 30 days and the free Key Value loses its data on every restart, so accounts (the next increment) have no home there. The owner has a VPS that is already provisioned for this: Ubuntu 26.04, SSH by key only for a `deploy` user, ufw open on 22/80/443 only, Docker, a shared Traefik reverse proxy with Let's Encrypt, and DNS for `chess.core-project.space` and `*.chess.core-project.space`. Nothing of chess-project runs on it yet. ## Solution Production and PR previews move to the VPS, behind the shared Traefik, and Render goes away. - `main` deploys to `https://chess.core-project.space` once CI is green on it. It never sleeps; a **Room** is lost only when production redeploys. - Every PR opened from the repo gets a public preview at `https://pr-<n>.chess.core-project.space`, redeployed on each push, removed when the PR closes, and never indexed by search engines. - At most five previews run at once; deploying a sixth removes the one deployed longest ago. - A nightly job removes any preview whose PR is no longer open, and the images nothing uses any more. - After each preview deploy, the browser suite runs against the deployed preview, so a pass speaks about the image, the proxy and the stream exactly as a **Player** receives them. - The image is built once in GitHub Actions and pushed to GHCR; the VPS only pulls and runs it. ## User Stories 1. As a **Player**, I want `https://chess.core-project.space` to answer immediately at any hour, so that a **Room** link I receive never makes me wait for a server to wake. 2. As a **Player**, I want the site served over HTTPS with a valid certificate, so that my browser shows no warning when I open a **Room** link. 3. As a **Player**, I want plain `http://` links to redirect to HTTPS, so that an old or hand-typed link still works. 4. As a **Player**, I want the front and `/rpc` on one origin, so that nothing about cross-origin requests can break a **Move**. 5. As a **Player** in an **Online game**, I want the **Room** change stream to reach me through the proxy without buffering, so that my opponent's **Move** appears as fast as it does today. 6. As a **Player**, I want my **Seat** to survive a reload on the deployed site, so that the **Seat token** keeps working behind the proxy as it does locally. 7. As a **Spectator**, I want to watch a **Room** on the deployed site, so that sharing a link with a third person works in production. 8. As a **Player**, I want the origin guard to accept the deployed origin, so that my mutations are not refused as cross-origin. 9. As the owner, I want production to deploy only after CI is green on `main`, so that the gate stays the only door to production. 10. As the owner, I want a production deploy to run the exact image CI built for that commit, so that what was tested is what runs. 11. As the owner, I want two production deploys never to run at the same time, so that a fast second merge cannot leave production half-updated. 12. As the owner, I want exactly one production process at any time, so that the in-memory **Room** registry stays the single source of truth. 13. As the owner, I want production to restart on its own after a crash or a VPS reboot, so that it stays up without me. 14. As the owner, I want each PR from the repo to get its own preview URL, so that I can try a change in a real browser before merging. 15. As the owner, I want a preview redeployed on every push to its PR, so that the preview always shows the PR's latest commit. 16. As the owner, I want PRs from forks never to deploy, so that the deploy key and registry credentials never reach code I did not write. 17. As the owner, I want a preview's URL posted where I see the PR, so that I do not have to build it by hand. 18. As the owner, I want previews public but marked `noindex`, so that I can share a preview link without it showing up in search results. 19. As the owner, I want at most five previews at once, with the one deployed longest ago removed first, so that previews never starve production of memory. 20. As the owner, I want a preview removed when its PR is merged or closed, so that the VPS does not fill with dead stacks. 21. As the owner, I want a nightly job to remove any preview whose PR is no longer open, so that a missed close event cannot leak a stack forever. 22. As the owner, I want the nightly job to prune images no container uses, so that the disk does not fill with old builds. 23. As the owner, I want the browser suite to run against each deployed preview, so that a regression only the proxy, the image or HTTPS can cause shows up on the PR. 24. As the owner, I want every deploy and cleanup workflow to verify its own outcome on the VPS, so that a run that "succeeded" without the expected stack, certificate or header fails loudly. 25. As the owner, I want the Traefik setup versioned in the repo, so that the proxy can be rebuilt from source if the VPS is reset again. 26. As the owner, I want the deploy workflows to pin the VPS's SSH host key, so that a deploy never trusts an unknown host. 27. As the owner, I want the VPS never to hold a long-lived registry credential, so that a compromise of the server does not leak access to the repo's packages. 28. As the owner, I want Render and `render.yaml` gone once the VPS serves production and previews, so that there is one hosting setup to maintain. 29. As the owner, I want an ADR recording the move off Render, so that the reasons in ADR 0024 are not silently contradicted. 30. As a contributor, I want the README's deploy section to describe the VPS, so that I know where production lives and how a PR gets its preview. 31. As the owner, I want each deploy stack able to carry its own data services later, so that the auth increment adds Redis and Postgres per stack without reshaping the deploy. ## Seams - **Deployed browser suite (e2e).** The existing Playwright suite in `apps/e2e`, driven against a deployed URL through a new target mode that starts no server and takes its base URL from the environment. Run after each preview deploy against that preview. Covers stories 1–8, 23. The existing local and `built` modes, and the CI guard against falling back to dev servers, stay as they are. - **Workflow post-conditions.** Each deploy and cleanup workflow ends with a verification step against the VPS and the public URL: the expected stack is running (or gone), the preview count is at most five, HTTPS answers with a valid certificate, `http://` redirects, and a preview carries `X-Robots-Tag: noindex` while production does not. A failed check fails the run. Covers stories 9–22, 24–28, 31. - Stories 29 and 30 are documentation and need no seam. ## Implementation Decisions - **One image.** The API serves the built front and `/rpc` from one origin, as today (ADR 0024's one-origin decision survives the move). A multi-stage container build produces one image running the built API with the front's build output, the way the Render start command does now. No health endpoint is added; `GET /` (the served front) is the liveness probe. - **Build in CI, pull on the VPS.** GitHub Actions builds the image and pushes it to GHCR, tagged with the commit SHA, plus a moving tag per target (production, `pr-<n>`). The VPS never builds. The repo is private, so the package is private: each deploy logs the VPS into GHCR with the job's short-lived token for the pull and logs out after, so no registry credential stays on the server. - **One deploy compose for every stack.** Production and each preview run the same compose definition, parameterized by stack name, image tag and host. It joins the shared `proxy` network and declares its Traefik router (host rule, `websecure` entrypoint, the Let's Encrypt resolver). Previews add a response-header middleware for `X-Robots-Tag: noindex`. One replica, restart unless stopped. Stacks are named `chess` for production and `chess-pr-<n>` for previews. - **Data services per stack, later.** The compose shape lets each stack carry its own Redis and Postgres, so the auth increment adds them per stack with empty, ephemeral data in previews. Production never shares a data service with a preview. Nothing data-related is added in this spec. - **Configuration.** Each stack sets `ORIGIN_ALLOWLIST` to its own HTTPS origin, `FRONT_DIST_DIR`, and `PORT`. The origin guard (ADR 0028) is unchanged. - **Production workflow.** Triggered by CI's success on `main`. It deploys the image built for that commit, in a concurrency group that queues rather than cancels. A redeploy drops the in-memory **Rooms**, an accepted cost as under ADR 0024. - **Preview workflow.** Triggered by pushes to PRs whose head is in the repo; fork PRs are skipped. Before deploying a new preview while five are running, it removes the preview deployed longest ago (stacks carry their deploy time as a label). After deploying, it comments or updates one PR comment with the preview URL, then runs the deployed browser suite against it. - **Cleanup workflows.** On PR close, the PR's stack is removed with its volumes. A nightly scheduled run lists the `chess-pr-*` stacks, removes those whose PR is not open, then prunes unused images. - **Access to the VPS.** Workflows SSH as `deploy` with a dedicated deploy key stored as a repo secret, the VPS's host key pinned in a secret rather than trusted on first use. `deploy` runs Docker without sudo. - **Traefik is versioned.** The shared proxy's compose moves into the repo, next to the deploy compose, and is applied by hand: it changes rarely and is shared with whatever else runs on the VPS. - **Render goes away last.** Once production and previews have run on the VPS, `render.yaml` is removed and the Render services deleted. - **ADRs.** A new ADR supersedes ADR 0024: host production and PR previews on the VPS behind Traefik. It lands in a dedicated rules PR, per the agent rules, together with the ADR index row. ADR 0028 (origin guard) and ADR 0022 (e2e binds only to what a **Player** perceives) apply unchanged; the deployed target adds no hook to the front or the API. ## Testing Decisions - Prior art: the browser suite's `built` target, which already drives build output rather than dev servers, and its guard that refuses to fall back to dev servers in CI. The deployed target follows the same pattern with a URL instead of started servers. - The deployed suite runs as-is: every test mints its own **Room** through the UI, so tests stay independent on a shared preview. `presence.spec.ts` moves only the browser's clock and needs nothing from the server. - Workflow post-conditions must cover the unhappy paths: a container that starts then exits (stack listed but not running), a certificate not yet issued (HTTPS check retries within a bounded time, then fails), a sixth preview (count back to five after deploy), a closed PR whose stack is already gone (cleanup succeeds idempotently), and a fork PR (no deploy step runs). - The production post-condition asserts the absence of `noindex`, so a preview middleware leaking into production fails the deploy. ## Out of Scope - Accounts, authentication, and the Redis and Postgres services themselves (the next increment; this spec only leaves room for them per stack). - Persisting **Rooms** across redeploys. - Zero-downtime or blue-green deploys. - Monitoring, alerting, log shipping and backups. - Automating the Traefik deploy, and redeploying core-project on the VPS. - Retention of old images in GHCR. - Provisioning the VPS: already done by hand (see Further Notes). ## Further Notes - Current VPS state, set up by hand: Ubuntu 26.04; `deploy` user with sudo, SSH by key only, root and password logins refused; ufw allows 22, 80 and 443 only; Docker 29 with Compose v5; Traefik v3.6 running as its own compose project on a `proxy` network, HTTP redirected to HTTPS, Let's Encrypt by HTTP challenge, no dashboard, containers exposed only when labelled; DNS A records `chess` and `*.chess` on `core-project.space` point at the VPS; a dedicated deploy key is authorized for `deploy`. - Docker bypasses ufw for published ports, so only Traefik may publish ports on the VPS; no stack in this spec publishes any. - The free-tier facts behind the move: Render's free Postgres expires 30 days after creation and its free Key Value does not persist to disk.
root commented 2026-10-09 09:12:42 +00:00 (Migrated from gitlab.core-project.space)

GitHub issue relations, imported as "related" links (GitLab CE has no sub-issues or blocking links).

> _GitHub issue relations, imported as "related" links (GitLab CE has no sub-issues or blocking links)._ <!-- github-relations --> - Sub-issues: #304, #305, #306, #307, #308, #309, #310
root (Migrated from gitlab.core-project.space) closed this issue 2026-10-09 12:20:42 +00:00
root commented 2026-10-13 08:39:25 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #304

mentioned in issue #304
root commented 2026-10-13 08:39:25 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #305

mentioned in issue #305
root commented 2026-10-13 08:39:26 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #306

mentioned in issue #306
root commented 2026-10-13 08:39:26 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #307

mentioned in issue #307
root commented 2026-10-13 08:39:26 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #308

mentioned in issue #308
root commented 2026-10-13 08:39:26 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #309

mentioned in issue #309
root commented 2026-10-13 08:39:27 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #310

mentioned in issue #310
root commented 2026-10-13 08:39:27 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #311

mentioned in issue #311
root commented 2026-10-13 08:42:06 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #304

marked this issue as related to #304
root commented 2026-10-13 08:42:06 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #305

marked this issue as related to #305
root commented 2026-10-13 08:42:06 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #306

marked this issue as related to #306
root commented 2026-10-13 08:42:07 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #307

marked this issue as related to #307
root commented 2026-10-13 08:42:07 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #308

marked this issue as related to #308
root commented 2026-10-13 08:42:07 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #309

marked this issue as related to #309
root commented 2026-10-13 08:42:08 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #310

marked this issue as related to #310
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amaurycoudr/chess-project#303
No description provided.