ADR 0027 Run the test environment on one OVH VPS with Docker Compose - AnnabiGihed/RaidManager GitHub Wiki
Generated from
docs/adr/0027-run-the-test-environment-on-one-ovh-vps.md. Edit that file in the repository: changes made in the wiki are overwritten.
- Status: Proposed
- Date: 2026-10-03
- Deciders: Gihed Annabi
- Amended: 2026-10-03, by #387, while still Proposed (owner decisions on #374, #369 and #387); database and memory amended by ADR-0029 (#410); application settings amended by #388; Compose generation detailed by #444; the dev deployment detailed by #389
The server turned out to be in use already: it hosts the pivotsoftwares.com website and Delivery Atlas behind one
shared Caddy. The amendment keeps RaidManager beside them: it joins the shared proxy instead of running its own,
the server isn't reinstalled, the memory budget counts what already runs, and provisioning only adds. A second
amendment the same day adds the sites of the dev and production environments the owner decided on #369, so the shared
Caddyfile is edited once. ADR-0029 then replaces SQL Server with PostgreSQL, one instance per environment, and its
memory budget covers the three environments; the rest of this record still describes the test environment.
Release v0.3 deploys RaidManager to a test environment on an OVH VPS-1 (owner decision on #367). Spike #373 asks how the Aspire application runs there before the server is provisioned (#374), configured (#375) and deployed to automatically (#376).
The application is four resources in RaidManager.AppHost: the website, the API, the Discord bot and the database
(SQL Server, then PostgreSQL by ADR-0029). The
desktop companion uploads to the API from players' machines (ADR-0002), so the API is public as well as the website.
The server, as ordered by the owner (#380, 2026-10-03):
| Property | Value |
|---|---|
| Offer | OVH VPS-1 2027, datacenter in Gravelines, France |
| Processor | 2 vCores |
| Memory | 4 GB |
| Storage | 40 GB NVMe SSD, local |
| Image | Ubuntu 24.04 |
| Backup | OVH Automated Backup Standard |
What already runs on it, as read on 2026-10-03 (#374):
| Property | Value |
|---|---|
| Memory seen by the system | 3.7 GiB, no swap |
| Disk | 38 GB, 34 GB free |
| Docker | Engine 29.8.2, Compose 5.6.0 |
| Applications | the pivotsoftwares.com website (pivot-website) and Delivery Atlas (delivery-atlas), each in its own folder under /opt/apps
|
| Proxy | one caddy:2 container (caddy), the only one publishing ports 80 and 443, with its Caddyfile at /opt/apps/proxy/Caddyfile
|
| Network | the applications reach the proxy through the external Docker network web and publish no port |
| Administrator account |
ubuntu, with sudo and Docker access |
| Firewall |
ufw active, allowing 22, 80, 443 and 8080 |
-
Aspire generates the Compose file. The AppHost adds
Aspire.Hosting.Docker(13.5.4, the pinned Aspire version) and a Docker Compose environment, so publishing writesdocker-compose.yamland an.envtemplate from the same app model that runs locally. No hand-written Compose file can drift from the AppHost, and no override file is needed (#444). Publishing runs the AppHost itself:dotnet run --project src/Containers/Aspire/Hosting/RaidManager.AppHost -- --operation publish --step publish --output-path <folder>
-
One file serves the three environments. Each runs it as its own Compose project, named
raidmanager-dev,raidmanager-testorraidmanager-prodwithdocker compose -p, so its volumes and network are its own, and the workflow fills its.env:.envkeyValue From DEPLOY_ENVIRONMENTdev,testorprod, in the container names Caddy forwards tothe workflow ASPNETCORE_ENVIRONMENTDev,TestorProduction(#388)the workflow API_PORT,WEB_PORT8080, the port Caddy's sites usethe workflow API_IMAGE,WEB_IMAGE,DISCORD_BOT_IMAGEthe images the run built the workflow POSTGRES_MEMORY_LIMIT,POSTGRES_SHARED_BUFFERS192Mand64MBfor dev and test,320Mand128MBfor production (ADR-0029)the workflow DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,DISCORD_BOT_TOKEN,WEBSITE_SERVICE_KEY,POSTGRES_PASSWORDthe environment's values its GitHub environment (ADR-0028) The published template holds no value; a test checks it, so no local secret can reach it.
-
Volumes with fixed names. The database's volume is
postgres-datawhen published, because the generated name hashes the build folder, and a new hash on another build machine would start an empty database. The website'sdata-protectionvolume is mounted on/home/app: the images run as the non-rootappuser, and Docker gives a new volume the owner of its folder only when the image already has that folder (tested on the .NET 10 image). -
No dashboard and no restart by hand: the Compose environment has no Aspire dashboard (ADR-0029), and every service restarts unless stopped.
The deploy-dev workflow deploys main to dev; the review workflow starts it after each merge, and it can be
started by hand from the Actions tab. Its steps, with the server's part in deploy/server/deploy-environment.sh:
- Build the three images on the runner (
dotnet publish -t:PublishContainer), tagged with the commit and the run (<commit>-<run id>-<attempt>), and generate the Compose file. The tag is unique per run because image builds aren't byte for byte identical: reusing the commit alone let a failed run move the running version's tag, and its rollback restarted the containers (#449). - Over SSH, with the environment's deploy key in an
ssh-agent: load the images (docker savepiped todocker load), copy the Compose file, and write.envfrom the environment's secrets, as*.nextfiles. - On the server: keep the current files as
*.previous, switch to the new ones, start PostgreSQL, and run the new API image once with--Database:MigrateAndExit=true, so the migrations apply before any new version serves. - Start the new version and wait until the API answers 200 and the website 200 or 302 on the environment's network.
- From the runner, through Caddy: the website answers 200, sign-in redirects to Discord with the environment's HTTPS address, and the API answers 404 for an unknown companion route (Caddy alone would answer 503).
- If step 3, 4 or 5 fails, the previous files come back and the previous version runs again; a first deployment that fails is stopped. A migration that already ran isn't undone: that is #434's.
- On success, the RaidManager images that no environment's current or previous version uses are removed.
-
Images are built in GitHub Actions, never on the server. Building needs the Pivot.Framework feed credentials
and more memory than the server can spare. The workflow (#376) builds the images, copies them to the server over
SSH (
docker savepiped todocker load), copies the Compose file, and runsdocker compose up -d. The server pulls from no registry, so it holds no registry credentials; how the workflow authenticates over SSH is #103's decision.
-
The server's shared Caddy stays the only container with published ports, 80 and 443. It obtains and renews Let's Encrypt certificates by itself and forwards each hostname to its container. RaidManager doesn't run a proxy of its own, and the AppHost adds none.
-
RaidManager adds six sites to the shared Caddyfile, one website and one API for each environment, kept in the repository as
deploy/server/raidmanager.Caddyfile. Each forwards to its container on port 8080. Until that container exists,handle_errorsanswers 503 with a short message, so every hostname gets its certificate before its first deployment, and a deployment never edits the shared file. -
The public API hostnames serve the desktop companion only (owner decision on #369). Each
api.site forwards the paths under/companion/and answers 404 to everything else, including/internal/...and/openapi, so the routes only the website uses are never reachable from the internet. The website calls its API inside Docker, athttp://raidmanager-<env>-api:8080, never through the public hostname. The API still checks its own authentication behind the proxy. -
Each environment's website and API containers join the external network
webunder the aliases below. The database and the bots stay on each environment's own Compose network. -
Hostnames (owner decisions on #380 and #369):
Environment Hostname Container alias Dev raidmanager-dev.pivotsoftwares.comraidmanager-dev-webDev api.raidmanager-dev.pivotsoftwares.comraidmanager-dev-apiTest raidmanager-test.pivotsoftwares.comraidmanager-test-webTest api.raidmanager-test.pivotsoftwares.comraidmanager-test-apiProduction raidmanager.pivotsoftwares.comraidmanager-prod-webProduction api.raidmanager.pivotsoftwares.comraidmanager-prod-api -
The applications listen on plain HTTP inside the Compose network and trust the proxy's forwarded headers, so they see the original scheme and host. Discord sign-in redirects to
https://raidmanager-test.pivotsoftwares.com/signin-discord. -
The databases and the bots publish no port. The Aspire dashboard doesn't run on the server (ADR-0029).
ADR-0029 replaces the SQL Server Express instance first planned here (2 GB of memory) with PostgreSQL, one instance per environment, and gives the memory budget for the three environments: about 2.8 GB of the 3.7 GiB the system sees, with the 2 GB swap file for peaks. Each database stays in a Docker volume on the local disk. Each environment keeps its current and previous images, and the workflow prunes older ones.
- Ubuntu 24.04 with Docker Engine and the Compose plugin, already installed, and unattended security upgrades.
- A
deployuser that owns/opt/apps/raidmanager, beside the other applications, and belongs to thedockergroup.rootcan't sign in over SSH, and SSH accepts keys only once the owner signs in with a key (#387). -
ufwallows 22, 80 and 443 for RaidManager. Docker bypassesufwfor published ports, which is why only Caddy publishes any. RaidManager doesn't need the 8080 rule; provisioning reports it and leaves it to the owner. - OVH's daily automated backup covers the server and the database volumes. A logical backup of production's database comes with the production work (ADR-0029).
The applications support these settings (#388); the deploy workflow (#389) sets them in each environment's Compose file:
-
Shared network: the Compose file puts the website and the API on the external network
webwith the aliases above, and publishes no port. -
Internal API address: the website reaches its API through Aspire service discovery (
https+http://api), which the generated Compose file resolves to the environment's ownapiservice, never the public hostname, which forwards only/companion/. -
Forwarded headers: the website and the API run with
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true, so they trust Caddy'sX-Forwarded-Proto; Caddy keeps the original host. Without it, Discord sign-in builds anhttpredirect. Trusting any sender is safe here only because the containers publish no port: Caddy is the only way in. -
Data protection keys: the website keeps its keys in the directory set by
DataProtection__KeysPath, on a volume. Otherwise, each deployment signs everyone out and breaks protected community links (CommunityLinkProtector). -
Environment names: the containers run as
Dev,TestorProduction(owner decision on #388). None counts as Development, so Development-only pages such as/scalarand the automatic migrations stay off; the first deployment applies migrations as a step of its own (#389, then #434). - Secrets: the Discord client secret, the bot token, the website key and the database password reach the containers from each environment's GitHub environment, as ADR-0028 decides.
The server is never reinstalled: that would erase the applications already on it. deploy/server/provision.sh
does steps 3 to 5; it only adds what is missing, so it can run again, and it never resets the firewall or stops
another application. The how-to Provision the server has the commands.
-
Owner: at the DNS provider of
pivotsoftwares.com, add an A record for each of the six hostnames above, pointing at the server's IPv4 address, and AAAA records if the server has IPv6. -
Owner: copy
deploy/serverto the server and runsudo bash provision.shfrom theubuntuaccount. - The script turns off
rootsign-in over SSH, allows 22, 80 and 443 inufw, and turns on unattended security upgrades. Password sign-in stays on until the owner runs it again with--keys-only, which it accepts only afterubuntuhas signed in with an SSH key. - It creates the 2 GB swap file and the
deployuser in thedockergroup, with/opt/apps/raidmanager. - Once all six hostnames resolve to the server, it appends the six sites to
/opt/apps/proxy/Caddyfile, validates it and reloads Caddy; if Caddy rejects it, the previous file is put back. - Check from outside: no port other than 22, 80, 443 and the owner's 8080 is open, every hostname answers 503 over
HTTPS with a valid certificate, and, after
--keys-only, password sign-in is refused.
Steps 1 and 2 need the owner's DNS and server access, and the agent never enters credentials or signs in to the server (owner decision on #374).
Positive
- The test environment runs the same app model as the local run, so a new resource in the AppHost reaches the server without a second definition.
- One small server, one Compose file and automatic certificates keep the setup within what one person can maintain.
- Sharing the proxy adds no container and no port, and the other applications keep running untouched.
- No registry or feed credential is stored on the server.
Negative
- Memory stays tight with three environments, even with PostgreSQL: about 2.8 GB of 3.9 GB is planned, and a heavy load can swap. A larger VPS, or moving production off the shared server (owner decision on #369: later), is the way out if that happens.
- One server has no redundancy; a failed deployment or an OVH incident takes the environment down until it's fixed.
- RaidManager shares the server with the
pivotsoftwares.comwebsite and Delivery Atlas: a memory peak in one can slow the others, and a broken edit of the shared Caddyfile would affect all of them, which is why provisioning validates it and restores the previous file on failure. - Copying images over SSH sends every image in full each time, which is slower than pulling only the changed layers from a registry.
- Whether production later runs on the same pattern, on a larger server, is for a later release to decide.
- Docker Compose support in Aspire is recent; if
aspire publishcan't express the Caddy container or the volume for data protection keys, #376 adds a small Compose override file and records why. The same holds for the externalwebnetwork and the aliases.