Repository navigation
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.
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.
/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'.
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.
From roomcad/server/:
./deploy.sh # default host
./deploy.sh root@your-host # or an explicit oneIt 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.jsbefore deploying a user-facing fix. The footer shows that version, which is how you confirm what is actually live.
Run
./tests/run.shfrom the repository root first. CI runs it on every push, butdeploy.shitself does not — it will happily ship a red tree.
# 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'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/roomcadProtectSystem=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.
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.
- 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 withinstall-caddy.sh. That script — andweb/serve.shon 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. mkdir -p /var/roomcad/web && cp server.py /var/roomcad/ && chmod 755 /var/roomcad/server.py- 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. - Copy both units, the Caddyfile and the route into place, create the
roomcadappaccount andchownthe data (see Accounts), writeroomcad.env, thensystemctl daemon-reload && systemctl enable --now roomcad roomcad-caddy. - Install
roomcad.caddyinto/var/caddy/projects/, validate the master's config, and reload it.
roomcad/server/README.md has the same procedure with the exact commands.
The database is the only irreplaceable thing:
ssh root@host "sqlite3 /var/roomcad/rooms.db '.dump'" > rooms-$(date +%F).sqlUse .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.