Server Deployment Report: VPS OVH vps-0836dabe.vps.ovh.ca

Status at 2026-11-06 00:38 UTC: DEPLOYED and VERIFIED, with access by IP. - Active release: 20261106T003554Z. - healthcheck.sh: health=OK warn=[] crit=[]. - Not available yet: a public HTTPS certificate. There is no domain, so the IP access uses an internal-CA certificate (§5).

1. Server

Host vps-0836dabe.vps.ovh.ca · 158.69.190.114 · 2607:5300:205:200::fcff
System (verified) Ubuntu 24.04.4 LTS, kernel 6.8.0-136, 4 vCPU, 7.6 GiB RAM, 72 GB disk + 2 GB swap (added)
SSH host key (after reinstall) ED25519 SHA256:9czS8hdsSH5g8YYhATWqtUQar5dkTZKCyC8/XUPbjl4
Deploy key ~/.ssh/auction_oracle_ovh (ed25519) on the owner's PC only. Server-side fingerprint: SHA256:21m5+r3H…6RBg.

Initial audit (preflight, read-only)

The machine was blank: - no Docker, no existing services; - only our key in authorized_keys; - passwordless sudo for ubuntu; - 0 pending updates.

To fix: - UFW was inactive. - SSH accepted passwords (50-cloud-init.conf) and root by key. - There was no swap.

Report saved at /root/ao-preflight-20261106T001314Z.txt.

2. Architecture as deployed

Internet ─► Caddy 2.8.4 :80 → 308 → :443 (https://158.69.190.114, internal-CA certificate)
              ├─ /status, /api/health, /docs/*, /site/*   public
              └─ everything else ─► api   (HTTP basic auth)
 SSH tunnel ─► 127.0.0.1:8765 ─► api (FastAPI)
                 worker (collectors, hourly) · scheduler (jobs) · monitor (metrics 60 s)
                          └──► PostgreSQL 16.15 (internal network only, no published port)

3. Versions and resources (measured after deployment)

Component Version Memory used / limit
Docker Engine / Compose 29.9.3 / 5.7.0 —
PostgreSQL 16.15 (alpine) 85 MB / 2 GB
Caddy 2.8.4 12 MB / 256 MB
api Python 3.12, app 0.1.0 88 MB / 1.2 GB
worker same 108 MB / 1.5 GB
scheduler same 30 MB / 1.5 GB
monitor same 35 MB / 160 MB

4. Security (verified)

Measure Result
SSH permitrootlogin no, passwordauthentication no, kbdinteractiveauthentication no, allowusers ubuntu, MaxAuthTries 4. Tested from outside: ubuntu and root with a password both return Permission denied (publickey).
Lockout protection lockdown applied with a 5-minute automatic revert, confirmed from a new key session before the revert was cancelled
UFW active, default deny incoming. Open: 22/tcp (rate-limited), 80/tcp, 443/tcp, 443/udp (IPv4 + IPv6).
Public listeners only :22, :80, :443. PostgreSQL and the API (127.0.0.1) are not exposed.
fail2ban sshd + recidive jails. First attacker banned automatically (203.123.32.230) a few minutes after the server came online.
Updates unattended-upgrades for security updates, no automatic reboot (health check warns when one is needed). Reboot done and verified.
Kernel / logs sysctl hardening, persistent journald (500 MB / 3 months), ops logs rotated weekly
Secrets generated on the server, /opt/auction-oracle/shared/* mode 600 root. Never printed, never in Git.
Database roles ao_owner (migrations), ao_app (DML only), ao_readonly. SCRAM-SHA-256. Separate test database.
App access no credentials → 401 on / and /api/*. Correct credentials → 200. Wrong password → 401. Tested on the server without ever displaying the password.
Containers app images run as a non-root user with a read-only filesystem and no-new-privileges. Logs rotate (10 MB × 5).

Earlier hardening had backed up every modified configuration file to /root/ao-config-backup/<ts>/.

5. Access without a domain

What URL
Application (login required) https://158.69.190.114/
Public status https://158.69.190.114/status
Documentation https://158.69.190.114/docs/
JSON health https://158.69.190.114/api/health

When you get a domain: point its A/AAAA records to the VPS and run update.sh --caddy (or deploy again). The auctionoracle.com / app. / api. / docs. / status. blocks switch on automatically, with Let's Encrypt, once DNS resolves to the server.

6. Backups (verified)

Test Result
Backup ao-20261106T002038Z.dump: 464 KB, 24 tables, checked with pg_restore -l and sha256, plus a config/secrets archive
Restore test restored into ao_restore_test: items=10639, observations=8553, recipes=3908, schema v2. Identical to production. Then dropped.
Off-site encrypted with age (age1kct477f2…ea2s49). The private key lives only on the PC (%USERPROFILE%\.auction-oracle\backup-identity.txt).
Pull to PC + decrypt 2 files pulled to %USERPROFILE%\AuctionOracleBackups, 0 checksum errors. The decrypted dump is byte-for-byte identical to the server dump (same SHA-256).
Automation ao-backup.timer daily at 03:30 UTC, ao-restore-test.timer every Sunday at 05:00 UTC, plus a backup before every deploy and every restore (seen in each deploy)

Your action: - Keep a copy of backup-identity.txt in a password manager. Without it, the off-site backups can't be decrypted. - The daily pull to the PC is still manual (.\ao-remote.ps1 pull-backups). Creating a Windows scheduled task needs your approval.

7. Deployments, rollback, restart (verified)

Test Result
First deploy build, migrations (owner role) + grants, 32/32 tests on PostgreSQL, 6 services healthy
Reboot all 6 containers came back on their own, health=OK, collector ran again
Second deploy pre-deploy backup, 32/32 tests, deploy OK
Rollback rollback.sh: API on the previous release image, health OK. Roll-forward also OK.
Third deploy (fixes) backup, 32/32 tests, health=OK warn=[] crit=[]

Deployments run as transient systemd units (run-detached.sh), so an SSH disconnect can't interrupt them.

8. Monitoring (verified)

9. Acceptance checklist

Criterion Status
SSH key login works ✅
Server secured ✅ (UFW, fail2ban, SSH key-only / no root, updates, sysctl)
Docker works ✅ 29.9.3
Services start automatically ✅ checked after a reboot
PostgreSQL operational ✅ 16.15, least-privilege roles
API responds ✅
HTTPS site after DNS ⏳ no domain yet. HTTPS by IP works (internal-CA certificate).
Collectors work ✅ auctionhouse.fyi + Everloot, fresh data
Scheduled jobs work ✅
Backups work ✅
Restore tested ✅ identical counts
Resources monitored ✅
Logs usable ✅ (docker json-file rotated, journald, /var/log/auction-oracle/*.log)
Rollback works ✅

10. Pending / known limits

  1. Domain + Let's Encrypt. Waiting for a domain. Everything is already configured.
  2. Daily automatic pull of the off-site backups to the PC. Needs your approval for a Windows scheduled task, and depends on the PC being on.
  3. Alternative: an S3 bucket (OVH Object Storage) to make it independent of the PC.
  4. IPv6. SSH answers over IPv6, but HTTP/HTTPS is published over IPv4 only. Enable it if you need it (Docker ipv6 setting).
  5. Basic auth until user accounts exist. SaaS authentication is phase 8 (see SAAS_ARCHITECTURE.md).
  6. Third-party API keys. Blizzard, ForeverDB and Anthropic keys go in shared/app.env on the server. These sources are disabled until then.

11. Maintenance commands

From the PC (deploy\windows):

.\ao-remote.ps1 health         # health report + container usage
.\ao-remote.ps1 deploy         # new release (backup, tests on PostgreSQL, rollout, auto-rollback)
.\ao-remote.ps1 rollback       # previous release
.\ao-remote.ps1 backup         # backup now
.\ao-remote.ps1 restore-test   # restore test now
.\ao-remote.ps1 pull-backups   # copy encrypted backups to the PC
.\ao-remote.ps1 tunnel         # app at http://127.0.0.1:8765 over SSH

On the server:

sudo /opt/auction-oracle/bin/healthcheck.sh
sudo /opt/auction-oracle/bin/update.sh --system     # OS updates
sudo /opt/auction-oracle/bin/update.sh --caddy      # after pointing a domain
sudo /opt/auction-oracle/bin/restore.sh --file /opt/auction-oracle/backups/db/<dump> --confirm
sudo tail -f /var/log/auction-oracle/ops.log /var/log/auction-oracle/health.log
sudo cat /opt/auction-oracle/shared/app-access.txt