Skip to content

Hosting and Deployment

Pummelchen edited this page Sep 16, 2026 · 7 revisions

Hosting and Deployment

RoomCAD is a static site plus a small Python API. The reference deployment is a Debian VPS, and roomcad/server/ in the repository mirrors it so the host can be rebuilt from git.

How a request flows

browser ──HTTPS──► master Caddy (/var/caddy)   owns 80 and 443, terminates TLS,
                     │                         routes each project by hostname
                     └──loopback──► RoomCAD's own Caddy  127.0.0.1:8081
                                      ├── /api/*  ──► server.py 127.0.0.1:8078 ──► rooms.db
                                      └── /*      ──► /var/roomcad/web  (static)

Each project on the host runs its own web server on loopback behind one master. The master owns 80 and 443 for the whole host, so it answers the ACME challenge itself: certificates are obtained and renewed automatically, and there is no certbot, no certificate for RoomCAD to keep and no copy to refresh.

RoomCAD's own Caddy does no TLS at all (auto_https off). It matches any Host and binds 127.0.0.1 deliberately — the master forwards the client's real Host header, so a site block keyed on the address would match nothing and Caddy would answer an empty 200 with no body and no error logged anywhere.

Piece Role
master Caddy (/var/caddy) owns 80/443, terminates TLS, routes hostnames
RoomCAD's Caddy serves /var/roomcad/web, reverse-proxies /api/*; loopback only
server.py the API, under systemd on 127.0.0.1:8078
rooms.db SQLite (WAL) — every room and every version
roomcad.env the shared password, mode 600, never in git

Port 8443 still answers. Links were handed out while RoomCAD terminated TLS on that port, and the master serves it as well so they keep working.

On-disk layout

/var/roomcad/
  server.py                    # the API
  rooms.db                     # SQLite database (WAL)
  roomcad.env                  # ROOMCAD_PASSWORD=…   (chmod 600, host-local)
  web/                         # the static app
  rooms/                       # legacy .rcad files (imported once, then empty)
  bin/caddy                    # its own copy of the Caddy binary
  caddy/Caddyfile              # its own config    (root:roomcadweb, 640)
  caddy/data, caddy/config     # its own storage
  caddy/admin.sock             # its own admin socket — two Caddys cannot share :2019

/etc/systemd/system/roomcad.service         # the Python API
/etc/systemd/system/roomcad-caddy.service   # RoomCAD's own web server
/var/caddy/projects/roomcad.caddy           # its route in the master
/var/log/caddy/roomcad.log                  # JSON, 20 MiB × 5

Each project owns exactly one file in /var/caddy/projects/ and touches no other project's route. That is the whole point of the arrangement: a single shared Caddyfile holding every project's real config is what made each deploy overwrite the others'.

Accounts

Two unprivileged users, one per service:

User Runs Needs
roomcadapp server.py read server.py and the password file; write /var/roomcad (the database and the WAL sidecars SQLite keeps beside it)
roomcadweb RoomCAD's Caddy read the Caddyfile and web/; write /var/roomcad/caddy

deploy.sh creates roomcadapp if it is missing and chowns the database, its -wal/-shm sidecars and the legacy rooms/ directory. The last of those matters: the one-shot migration reads each legacy file and then removes it, so a root-owned directory would be an unhandled PermissionError at boot and the service would not start.

Deploying a change

From roomcad/server/:

./deploy.sh                    # default host
./deploy.sh root@your-host     # or an explicit one

It synchronises web/ with --delete (so removed modules really disappear), uploads server.py, installs both units, RoomCAD's Caddyfile and its route in the master, ensures the service account and file ownership, and restarts the services. It never rewrites the contents of rooms.db or roomcad.env, so live data and the password survive.

It validates the Caddyfile with RoomCAD's own Caddy on the target host before installing it, and aborts before touching anything if it does not parse — directives come and go between Caddy versions, so validating with some other copy proves nothing. It then validates the master's whole config before reloading the master, because an invalid route there would take down every other project on the host.

A reload is not trusted on its own either. Caddy can panic while swapping a config in — a Go runtime bug, not a bad config — and die, which systemctl reload reports as a non-fatal message; a deploy once finished with "Deployed." while the site was down. So it falls back to a hard restart, and refuses to claim success until the real public URL answers.

Bump roomcad/web/version.js before deploying a user-facing fix. The footer shows that version, which is how you confirm what is actually live.

Run ./tests/run.sh from the repository root first. CI runs it on every push, but deploy.sh itself does not — it will happily ship a red tree.

Manual equivalents

# web only
rsync -az --delete --exclude=bin --exclude=photos web/ root@host:/var/roomcad/web/

# API only
scp server.py root@host:/var/roomcad/server.py && ssh root@host 'systemctl restart roomcad'

# RoomCAD's Caddyfile (must end up 640 root:roomcadweb, or it will not start)
scp Caddyfile root@host:/tmp/Caddyfile.new
ssh root@host '/var/roomcad/bin/caddy validate --config /tmp/Caddyfile.new --adapter caddyfile &&
  mv /tmp/Caddyfile.new /var/roomcad/caddy/Caddyfile &&
  chown root:roomcadweb /var/roomcad/caddy/Caddyfile &&
  chmod 640 /var/roomcad/caddy/Caddyfile &&
  systemctl reload roomcad-caddy'

# RoomCAD's route in the master (then validate the master before reloading it)
scp roomcad.caddy root@host:/var/caddy/projects/roomcad.caddy
ssh root@host '/usr/bin/caddy validate --config /var/caddy/Caddyfile --adapter caddyfile &&
  systemctl reload caddy'

Key configuration details

Caddy — the /api/* block needs flush_interval -1, or Server-Sent Events are buffered and live collaboration appears broken. It also has to pass the original scheme through: Caddy sets X-Forwarded-Proto from the connection it received, which from the master is plain HTTP over loopback, so left alone the API would decide the request was insecure and drop the Secure flag from the session cookie.

:8081 {
	bind 127.0.0.1
	handle /api/* {
		reverse_proxy 127.0.0.1:8078 {
			flush_interval -1
			header_up X-Forwarded-Proto {header.X-Forwarded-Proto}
		}
	}
	handle {
		root * /var/roomcad/web
		file_server
		encode gzip
	}
}

encode gzip belongs on the static handle, not at site level: at site level it also wraps the /api/* proxy, and that is the SSE stream — a response deliberately held open and flushed in pieces.

Client address — there are now two proxies in front of the API, so the real client is one entry further from the end of X-Forwarded-For: ROOMCAD_PROXY_HOPS=1 in roomcad.env (the deploy sets it). Left at zero, every request throttles as 127.0.0.1.

systemd — the - on EnvironmentFile means "start even if missing", so a lost password file disables logins rather than taking the service down. systemd reads it as root before dropping privileges, so 600 root:root is fine.

The API unit runs unprivileged and sandboxed, because it is the process that parses untrusted request bodies and holds the database:

[Service]
User=roomcadapp
WorkingDirectory=/var/roomcad
ExecStart=/usr/bin/python3 /var/roomcad/server.py
EnvironmentFile=-/var/roomcad/roomcad.env
Restart=always

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
ReadWritePaths=/var/roomcad

ProtectSystem=strict makes the whole filesystem read-only, so ReadWritePaths is what lets the API write at all — and it must name the tree the database lives in, not just the file, because SQLite creates rooms.db-wal and rooms.db-shm beside it. Check a change with systemd-analyze verify before shipping it.

HTTPS

Nothing to do: the master owns port 80, so it answers the ACME challenge and obtains and renews the certificate for roomcad.91.99.176.243.nip.io itself. nip.io resolves any <ip>.nip.io name to that IP, which gives a real certificate without owning a domain. HTTP/3 is likewise the master's business — note that QUIC is UDP, and a firewall rule that opens TCP does not open UDP.

Rebuilding from nothing

  1. Provision a Linux host with Python 3 (stdlib only — no pip) and the master Caddy (/var/caddy), which owns 80/443 and terminates TLS. RoomCAD installs its own Caddy binary with install-caddy.sh. That script — and web/serve.sh on a developer's machine — verifies the downloaded archive against the SHA-512 Caddy publishes for the release it resolved, before extracting or running it. A mismatch, a missing entry, or a host with no hash tool aborts without executing anything; the check never degrades into a skip. Its default install target is RoomCAD's own directory only — it must not write into another project's tree.
  2. mkdir -p /var/roomcad/web && cp server.py /var/roomcad/ && chmod 755 /var/roomcad/server.py
  3. Restore the database structure: sqlite3 /var/roomcad/rooms.db < rooms.db.sql. It is structure only — it carries no room content, because a public repository is the wrong place for real designs. Restore the rooms from a backup (below); a fresh install simply starts empty.
  4. Copy both units, the Caddyfile and the route into place, create the roomcadapp account and chown the data (see Accounts), write roomcad.env, then systemctl daemon-reload && systemctl enable --now roomcad roomcad-caddy.
  5. Install roomcad.caddy into /var/caddy/projects/, validate the master's config, and reload it.

roomcad/server/README.md has the same procedure with the exact commands.

Backups

The database is the only irreplaceable thing:

ssh root@host "sqlite3 /var/roomcad/rooms.db '.dump'" > rooms-$(date +%F).sql

Use .dump rather than copying rooms.db; in WAL mode the file alone may be missing recent commits. Once a night is plenty for a room planner, and it is worth handing the result to something off the host.

Clone this wiki locally