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.
One process, one port
Section titled “One process, one port”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.jsnpm 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.
What must persist
Section titled “What must persist”Two things, and this is the constraint that rules platforms in and out.
- The database. SQLite is a file under
data/by default. PointDATABASE_URLat Postgres and this constraint goes away. - Uploaded images. Written to
ASSETS_DIR(public/assetsby default), or to an S3-compatible bucket whenASSETS_S3_BUCKETand 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.
Paths resolve from the working directory
Section titled “Paths resolve from the working directory”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.
Native modules
Section titled “Native modules”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.
Behind a proxy
Section titled “Behind a proxy”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.
The setup token
Section titled “The setup token”/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.
The two things to verify after any deploy
Section titled “The two things to verify after any deploy”- 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.