Skip to content

The shape of a deployment

There is no hosted Beluga, so every reader deploys before they have a store to build out. This page is platform-agnostic; Deploy your store is the same skeleton filled in for DigitalOcean, Fly.io and Railway.

In production the Express app serves the built client from dist/ and answers /assets/<path> for uploaded images, so a deployment is one Node process on one port. No separate static host, no CDN required, no reverse proxy needed to get started, though a VM usually wants one for TLS.

install all deps → npm run build → prune to production → node dist-server/server/index.js

npm run build typechecks everything including the tests, then compiles the server to plain JavaScript in dist-server/ and the client to dist/. npm start runs the compiled server under node; there is no TypeScript at runtime and nothing in dist-server/ imports a devDependency, so a production image can prune to dependencies after building.

Migrations run automatically at boot, are idempotent, and the data backfills are written to be no-ops on a database that has had them. There is no release-phase step. Roll one instance at a time rather than booting several into an unmigrated database.

Two things, and this is the constraint that rules platforms in and out.

  1. The database. SQLite is a file under data/ by default. Point DATABASE_URL at Postgres and this constraint goes away.
  2. Uploaded images. Written to ASSETS_DIR (public/assets by default), or to an S3-compatible bucket when ASSETS_S3_BUCKET and its group are set. With a bucket, this constraint goes away too.

So the honest options are:

Database Images Needs a persistent disk? Can run two instances?
SQLite disk yes, one volume for both no
SQLite bucket yes, for the database no
Postgres disk yes, for the images no
Postgres bucket no yes

A store on a platform with an ephemeral filesystem, and neither Postgres nor a bucket configured, loses its catalogue’s images and its orders on the next deploy. Nothing warns you. This is the biggest trap in the project and the reason Heroku, Vercel, Netlify and DigitalOcean App Platform are wrong for the default configuration. With Postgres and a bucket, a platform like App Platform, Railway or Render becomes workable; the remaining requirement is a long-lived process, because the webhook delivery and cart-reminder timers run inside it and a serverless function has nowhere to run them.

dist, db/migrations/*, emails/ and a relative ASSETS_DIR are all resolved against the process’s working directory. Start from the repository root, and keep db/migrations/ in the deployed image: a build that prunes source files breaks boot, not just uploads.

better-sqlite3 and sharp compile native code or download prebuilt binaries. On Alpine or musl base images, and on ARM builders, make sure the build stage has a toolchain. The example Dockerfile uses a Debian-based Node image for exactly this reason.

TRUST_PROXY defaults to one hop in production. The login rate limit and the Secure session cookie both depend on it. Trusting more hops than exist lets a caller choose their own IP. On a VM where the proxy shares the box, also set API_HOST=127.0.0.1, or the Node port is reachable from the internet directly.

/setup is public until the store has an administrator. In production the server prints a one-off token at boot and the wizard asks for it, so only someone who can read the log can claim a fresh deploy. Set SETUP_TOKEN to choose the value, or create the admin with ADMIN_EMAIL and ADMIN_PASSWORD through npm run db:seed before the app is exposed.

  • Upload an image, redeploy, confirm it is still there.
  • curl -s https://your-store/product/<slug> | grep '<title>' shows the product name. The SEO head rewriting runs only in the production branch, so this proves the production handler is what is serving.

Then back it up.