> Auth update: production now requires Supabase Auth configuration; see SUPABASE_AUTH.md. The original local password implementation is restricted to isolated test fixtures. Set SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY before deploying.
Digital Walls backend operations
Production incident notes and “why we did X” live in the running log [PRODUCTION_LEARNINGS.md](./PRODUCTION_LEARNINGS.md) (start there after outages).
Run
Use Node 24 LTS or later. npm ci, then npm start. Open http://127.0.0.1:4173. The server applies versioned SQL migrations and creates data/walls.sqlite. This is a real persistent database, not a fixture or browser-storage substitute. Sign up with an email and a password of at least 12 characters. New accounts start with an empty wall list. Create a wall to start editing.
npm test exercises the real HTTP server and a disposable database. npm run test:e2e exercises Chrome against a fresh temporary database, removed after each run. npm run dev:e2e is a manual test server using data/e2e.sqlite; do not use that database as production data.
Deployment contract
Local and Docker deployments use one long-running Node server with a durable local disk. SQLite WAL supports concurrent requests within that server; transaction/revision checks prevent lost updates. Vercel does not have a durable disk, so account walls there live in Supabase Postgres. Connecting the Supabase integration provides POSTGRES_URL; the app uses that as the session pooler. DATABASE_URL overrides it. The server creates the tables on boot. Signup, sign-in, and share links then use that database. Without either URL, Vercel still serves guest mode, and account routes return 503 instead of writing accounts into /tmp.
Vercel
The repo root server.js is the Node entry Vercel should run. Browser assets live in public/ (including wall.js) so Vercel can CDN them and does not mistake client code for the backend. The self-host Docker image lives at docker/Dockerfile on purpose: a root-level Dockerfile can make Vercel try to build and push a Container Registry image named dockerfile, which fails when VCR push is not enabled for the project.
If a deploy fails with vcr.vercel.com/.../dockerfile:<sha> was denied, either fix registry access in the Vercel project (Sandboxes → Container Registry) or confirm the project is not configured to deploy a container image from the repo. Standard production deploys for this app use server.js plus Supabase Postgres, not VCR.
In Project Settings → Build & Development, leave Output Directory empty. A static output path (for example dist or public) makes Vercel skip the Node entry and every route returns platform 404 NOT_FOUND. The repo vercel.json serves public/ from the CDN first (filesystem), then falls back to server.js for /api/* and HTML that needs routing. The Node handler also serves public/` locally (Docker) without touching Postgres.
On first deploy to Supabase Postgres, many serverless instances can start at once. The app uses a try advisory lock + poll so only one instance applies SQL migrations; the rest wait for schema_migrations instead of blocking until statement timeout. Static .js / .css responses are served before session lookup so a slow database cannot turn asset requests into 500s.
On Vercel the app:
- derives
APP_ORIGINfromAPP_ORIGIN, elseVERCEL_PROJECT_PRODUCTION_URL, elseVERCEL_URL - stores accounts, walls, share tokens, sessions, and image bytes in Postgres when
POSTGRES_URL,POSTGRES_URL_NON_POOLING, orDATABASE_URLis set - on Vercel, prefers
DATABASE_URL/POSTGRES_URLfor runtime queries on the Supabase transaction pooler (:6543) so many serverless instances do not exhaust session pool slots (pool_size, often 15) - uses a separate session pooler URL (
:5432, orPOSTGRES_URL_NON_POOLINGwhen set) only for short-lived schema migrations at boot — not for every API request - off Vercel, may use direct / session URLs; do not point serverless runtime at session mode
- can boot without Supabase so guest mode remains reachable; the integration's
SUPABASE_URLandSUPABASE_PUBLISHABLE_KEYenable signup/login
Set APP_ORIGIN to the HTTPS origin people open (for example https://digitalwalls.vercel.app). Signup also accepts the host the browser actually called, so a Vercel deployment URL still works when APP_ORIGIN is the production domain. Share links keep using APP_ORIGIN. In Supabase Auth → URL Configuration, allow https://YOUR_DOMAIN/?auth=recovery and https://YOUR_DOMAIN/?auth=confirm. Guest drafts stay in the browser until an account claims them. Postgres backups are Supabase's; the SQLite backup command below is only for the file database.
Build with docker build -f docker/Dockerfile ., mount a persistent volume at /data, and set APP_ORIGIN to the final HTTPS origin (no trailing slash). Terminate TLS with the host's reverse proxy and proxy all paths to port 4173. Keep NODE_ENV=production; startup refuses production without an HTTPS origin. Only expose the proxy publicly. Production cookies are HttpOnly, Secure, SameSite=Lax, and host-bound; development cookies work over localhost HTTP. Sessions expire after 30 days and are revoked on logout. Passwords use salted scrypt; session tokens are stored only as SHA-256 hashes. The database contains sensitive account and wall data: limit volume access and enable encrypted disk/backups on the host.
Set the final domain before distributing NFC stickers. Share tokens stay stable through editing, renaming, privacy changes, and sessions; a domain change still requires redirecting the previous domain. NFC uses an ordinary NDEF URL record containing the share URL. Physical NFC writing is outside the browser flow.
Example environment is in .env.example; inject it through your host (it is not automatically loaded). Restrict inbound request sizes to 10 MB at the proxy. Preserve the Origin header. App-level authentication rate limits use the socket IP and hashed email; add per-client edge rate limiting at the reverse proxy, because the app intentionally does not trust arbitrary forwarded-IP headers.
Permissions and data
usersownsboards;sessionsreferences users and expires server-side.- Every wall has an opaque permanent share token and owner. Private walls and their images require the owner's session. Shared/public walls allow read-only access through their URL. Both modes currently have the same link-viewing behavior; there is no public discovery index.
- Only the owner can update/delete a wall or upload an image. There are no editor invitations.
board_membersis reserved for a future authorization feature and confers no access in this release. board_itemshas a composite primary key, canonical coordinates, dimensions, layer, validated type, and JSON content/configuration. Folder pages and playlist tracks are validated nested content; they are saved in the same transaction as the wall. Settings persist separately on the board.- Layout uses a 1200 × 800 canvas scaled uniformly on every screen. Device dimensions never rewrite coordinates. Time of day is the user's persisted scene setting. Lamp behavior defaults to automatic: on at night, off in other scenes. Explicit always-on/off overrides survive time changes.
- Each save atomically validates the entire state and increments a revision. A stale device receives 409, retains its edits onscreen, and offers an export; it cannot overwrite the newer state. Refresh after exporting to load the latest revision. Autosave failures remain visible and retryable. Logout and wall switching wait for pending saves. Closing before a save finishes triggers an unsaved-changes warning; unsaved offline edits are not guaranteed after forcibly closing the browser.
- Images are limited to 10 MB, static JPEG/PNG/WebP, and 40 megapixels. Sharp decodes, applies orientation, removes metadata, downsizes to 2400px, and stores WebP bytes in the database. No user-supplied file paths or public upload directory. Private image responses are not cached. Per-wall image quota is 100 MB; uploads are rate limited.
- Image replacement/removal unlinks the asset. Unreferenced images older than one hour are collected on saves (the grace period supports undo and in-flight upload retries). Deleting a wall immediately deletes its items and all its image bytes transactionally. Account deletion is not exposed by this UI; database user deletion cascades through owned data.
Backups and recovery
Run DATABASE_PATH=/data/walls.sqlite node server/backup.js /secure/backup/walls-YYYY-MM-DD.sqlite daily via the host scheduler. The SQLite online backup API makes a consistent snapshot while the app runs, including images. Transfer encrypted backups off-host and retain a suitable recovery window. Never simply copy the open main SQLite file without its WAL.
To restore: stop the server; back up the current data directory; restore the snapshot as walls.sqlite into a clean data directory owned by the server user; start the server; verify /api/health, account login, a private wall, and a shared image. Restoring a backup also restores sessions from that point; deleting all rows from sessions invalidates them if needed. Rehearse restoration before accepting production traffic.
Release status / remaining operational decisions
A production host, HTTPS domain, durable volume, and scheduled off-host backups must be provisioned before live use. This checkout does not have hosting credentials, and no production deployment has been claimed. The app includes no email delivery integration, email verification or password recovery flow yet; select a transactional email provider before a broader public launch. The requested signup/login/logout/session flows work without email delivery. Do not describe this as a managed-auth service.