Deploy your store
Beluga is one Node process on one port, and the only two things that need to survive a redeploy are the database and the uploaded images — see the shape of a deployment for why that rules some platforms out entirely. DigitalOcean, Fly.io and Railway all satisfy it; the difference between them is cost, and how much server administration you’re signing up for.
What it costs
Section titled “What it costs”The sizing assumption is the same everywhere: one Node process, and sharp doing
image re-encodes on upload, which is the only memory spike. 512 MB is tight for
that; 1 GB is the honest minimum and 2 GB is comfortable.
| DigitalOcean Droplet | Fly.io | Railway | |
|---|---|---|---|
| Compute | $6 (1 vCPU, 1 GB) or $12 (1 vCPU, 2 GB) | $5.92 shared-cpu-1x, 1 GB (Amsterdam; US similar) | Metered: $20 per vCPU, $10 per GB RAM, per second of actual use |
| Disk for SQLite and images | Included (25 to 50 GB SSD) | $0.15 per GB volume; 10 GB is $1.50 | $0.15 per GB volume; 10 GB is $1.50 |
| Backups | 20% of Droplet price for weekly, 30% for daily | Snapshots $0.08 per GB, first 10 GB free | Not itemised |
| Bandwidth | 1 to 2 TB included | $0.02 per GB in NA and EU | $0.05 per GB egress |
| Plan fee | None | None | Hobby $5, which is a $5 usage credit, not an extra charge |
| Dedicated IPv4 | Included | $2 (a shared one is free and works for HTTPS) | Not needed |
| SQLite store, realistic total | $7 to $15 | $8 to $12 | $5 to $10 |
| Managed Postgres, if you outgrow one box | $15.15 (1 GB) or $30.45 (2 GB) | $38 Basic (1 GB) plus $0.28 per GB storage | Runs as a metered service like the app |
| With managed Postgres | $22 to $30 | $45 to $55 | $15 to $25 |
Three things worth knowing behind those numbers:
- Railway is cheapest only because it bills what you use. An idle Beluga sits around a tenth of a vCPU and 300 MB, which is a few dollars and mostly inside the Hobby credit. A busy store or a burst of uploads costs more, and you cannot predict the bill the way you can with a Droplet.
- DigitalOcean is the only one with a flat, all-inclusive price. Disk, bandwidth and IP are in the number. It is also the only one where you pay the operations cost in your own time: OS updates, TLS, the process manager.
- Fly’s managed Postgres is the outlier. At $38 minimum it is more than the rest of the deployment combined. On Fly, SQLite on a volume is the sensible default, and Postgres is a reason to consider a Droplet with DO’s $15 database instead.
If you want a predictable, flat bill and don’t mind owning TLS and the process manager yourself, use the Droplet. If you want the platform to own those instead and are staying on SQLite, Fly is the cheapest way to do that. If your traffic is low or bursty and you’d rather pay for what you use than for a box that sits idle, Railway is the cheapest at rest — with a bill that moves.
Deploy to a DigitalOcean Droplet
Section titled “Deploy to a DigitalOcean Droplet”A Droplet is a VM. Both the SQLite file and the image directory live on its disk with no volume to mount. The cost is that you own the process manager, TLS, and OS updates. Some people actively want that.
Use a Droplet, not App Platform. App Platform’s container filesystem is ephemeral and wiped on every deploy, so uploaded product images would vanish with each release. That is the Heroku failure wearing a different logo, and it is the same constraint that rules platforms in and out generally.
1. The box
Section titled “1. The box”A 1 GB Droplet on Ubuntu LTS is enough for a small store. Create it with your SSH key, then:
apt update && apt upgrade -yadduser beluga && usermod -aG sudo belugaufw allow OpenSSH && ufw allow 80 && ufw allow 443 && ufw enableInstall Node 22 as the beluga user with nvm, plus the build tools
better-sqlite3 and sharp need:
sudo apt install -y build-essential python3curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bashnvm install 222. Clone and build
Section titled “2. Clone and build”git clone https://github.com/<you>/<your-fork>.git ~/storecd ~/storenvm usenpm cinpm run buildnpm run build typechecks everything including the tests, then emits the server
to dist-server/ and the client to dist/.
3. Configure
Section titled “3. Configure”Create ~/store/.env:
NODE_ENV=productionAPI_PORT=4000API_HOST=127.0.0.1DATABASE_URL=file:./data/beluga.sqliteASSETS_DIR=public/assetsSESSION_SECRET=<openssl rand -base64 32>PUBLIC_URL=https://shop.example.comSTRIPE_SECRET_KEY=sk_test_...STRIPE_PUBLISHABLE_KEY=pk_test_...API_HOST=127.0.0.1 is the one that matters on a VM. With a reverse proxy on the
same box, binding to all interfaces would leave the Node port reachable from the
internet directly, arriving over plain HTTP with whatever X-Forwarded-* headers
a caller cares to send. TRUST_PROXY defaults to one hop in production, which is
exactly the proxy you are about to add.
4. Run it under systemd
Section titled “4. Run it under systemd”/etc/systemd/system/beluga.service:
[Unit]Description=Beluga storeAfter=network.target
[Service]User=belugaWorkingDirectory=/home/beluga/storeExecStart=/home/beluga/.nvm/versions/node/v22.23.2/bin/node dist-server/server/index.jsRestart=alwaysRestartSec=5Environment=NODE_ENV=production
[Install]WantedBy=multi-user.targetCheck the node path with which node while nvm use is active. Then:
sudo systemctl enable --now belugajournalctl -u beluga -fThe log prints that the store is not set up, and a setup token. Keep this terminal open for step 6.
5. TLS and the proxy
Section titled “5. TLS and the proxy”Caddy is the least work: it obtains and renews the certificate itself.
sudo apt install -y caddy/etc/caddy/Caddyfile:
shop.example.com { reverse_proxy 127.0.0.1:4000}sudo systemctl reload caddyPoint DNS at the Droplet first, or the certificate request fails. An nginx configuration is in the examples if you prefer it.
6. Claim the store
Section titled “6. Claim the store”Open https://shop.example.com/setup, paste the token from the log, and create
the administrator. Or create it from the shell before the proxy is up:
cd ~/store && ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='...' npm run db:seed7. Point Stripe at it
Section titled “7. Point Stripe at it”Add a webhook endpoint at https://shop.example.com/api/webhooks/stripe for
checkout.session.completed, checkout.session.expired and charge.refunded,
put its signing secret in .env as STRIPE_WEBHOOK_SECRET, and
sudo systemctl restart beluga.
8. Verify, then back up
Section titled “8. Verify, then back up”Publish a product and pay for it with the test card. Upload an image. Then read
Backups and restore, because nobody else is backing this
disk up. A cron job that copies data/beluga.sqlite and public/assets/ off the
box nightly is the minimum.
Redeploying:
cd ~/store && git pull && npm ci && npm run build && sudo systemctl restart belugaMigrations run at boot. There is a few seconds of downtime while the process restarts; if that matters, put two processes behind the proxy and move to Postgres, since two processes cannot share a SQLite file safely under load.
Deploy to Fly.io
Section titled “Deploy to Fly.io”Fly.io hands off the OS and process supervision: one machine, one persistent volume holding both the SQLite file and the uploaded images, and a Dockerfile that pins the Node version and builds the native modules. The constraint is one machine, so scale up rather than out, and back the volume up.
If you would rather keep images in a bucket, set the ASSETS_S3_* group instead
of ASSETS_DIR and the volume only has to hold the database. With Postgres as
well, no volume at all — though see the cost table above before reaching for
Fly’s managed Postgres.
1. Add a Dockerfile
Section titled “1. Add a Dockerfile”Beluga’s npm run build compiles the server to plain JavaScript in
dist-server/ and the client to dist/, and npm start runs
node dist-server/server/index.js. The runtime image needs those two
directories, db/migrations/, emails/, and production dependencies, and it
must start from the repository root because paths are resolved from the working
directory. Use the example Dockerfile.
Add a .dockerignore with node_modules, dist, dist-server, data,
public/assets, .env, test-results and legacy.
2. Create the app and the volume
Section titled “2. Create the app and the volume”fly launch --no-deploy # answer no to Postgres and Redisfly volumes create beluga_data --size 3 --region <your region>Then in the generated fly.toml, mount the volume and point both persistent
things at it:
[env] NODE_ENV = "production" API_PORT = "8080" DATABASE_URL = "file:/data/beluga.sqlite" ASSETS_DIR = "/data/assets"
[[mounts]] source = "beluga_data" destination = "/data"
[http_service] internal_port = 8080 force_https = true auto_stop_machines = false auto_start_machines = true min_machines_running = 1auto_stop_machines = false matters: the background timers for webhook delivery
and abandoned-cart reminders run inside the API process, and a machine that
stops between requests stops them too.
3. Secrets
Section titled “3. Secrets”fly secrets set \ SESSION_SECRET=$(openssl rand -base64 32) \ PUBLIC_URL=https://<app>.fly.dev \ STRIPE_SECRET_KEY=sk_test_... \ STRIPE_PUBLISHABLE_KEY=pk_test_...Keep test keys until going live. PUBLIC_URL is what
Stripe sends buyers back to and what every emailed link starts with; a deploy
that leaves it at the default fails silently. Add SMTP_URL and EMAIL_FROM
when you have them, and see the environment reference
for the rest.
4. Deploy and claim it
Section titled “4. Deploy and claim it”fly deployfly logsBoot runs the migrations, creates the assets directory, and prints that the
store is not set up. In production it also prints a setup token, because
/setup is public until an administrator exists and a public address is a race
against every scanner on the internet. Open https://<app>.fly.dev/setup, paste
the token, and create your account. If you would rather not read the log, set
SETUP_TOKEN as a secret first and the wizard asks for that instead.
Alternatively, create the admin before anyone can reach the app:
fly ssh console -C "sh -c 'ADMIN_EMAIL=you@example.com ADMIN_PASSWORD=... npm run db:seed'"5. Point Stripe at it
Section titled “5. Point Stripe at it”In the Stripe dashboard, Developers → Webhooks → Add endpoint:
https://<app>.fly.dev/api/webhooks/stripeSubscribe to checkout.session.completed, checkout.session.expired and
charge.refunded. Copy the signing secret and set it:
fly secrets set STRIPE_WEBHOOK_SECRET=whsec_...This is the step most often skipped, and until it is done orders are never marked paid while everything else looks fine.
6. Verify
Section titled “6. Verify”- Publish a product, complete a test payment, and confirm the order shows
paid. - Upload an image, then
fly deployagain and confirm the image is still there. That proves the volume is mounted whereASSETS_DIRpoints. curl -s https://<app>.fly.dev/product/<slug> | grep '<title>'shows the product name, which proves the production HTML handler is running.
Redeploying: fly deploy builds a new image and swaps the machine.
Migrations run at boot and are idempotent. With one machine there is nothing to
roll one-at-a-time; if you add a second, move to
Postgres first, because a volume attaches to one
machine.
Deploy to Railway
Section titled “Deploy to Railway”Railway is the metered version of the same shape: build from a Dockerfile, attach a volume for the database and images, and pay per second of actual CPU and memory rather than for a reserved machine. That makes it the cheapest option for a store that mostly sits idle, and the least predictable one for a store that doesn’t.
1. Add a Dockerfile
Section titled “1. Add a Dockerfile”The same Dockerfile as the Fly.io section above works unchanged — Railway
builds from it directly. It must use a Debian-based Node image, not Alpine,
because better-sqlite3 and sharp need a toolchain to build their native
code. See the example Dockerfile.
2. Create the project and a volume
Section titled “2. Create the project and a volume”railway loginrailway initrailway volume add --mount-path /datarailway domainrailway domain generates a <project>.up.railway.app address; you’ll need it
for PUBLIC_URL below. The Dockerfile’s EXPOSE 4000 is enough for Railway to
route to the container — there’s no separate port-mapping step like Fly’s
internal_port.
3. Variables
Section titled “3. Variables”railway variables \ --set "NODE_ENV=production" \ --set "API_PORT=4000" \ --set "DATABASE_URL=file:/data/beluga.sqlite" \ --set "ASSETS_DIR=/data/assets" \ --set "SESSION_SECRET=$(openssl rand -base64 32)" \ --set "PUBLIC_URL=https://<project>.up.railway.app" \ --set "STRIPE_SECRET_KEY=sk_test_..." \ --set "STRIPE_PUBLISHABLE_KEY=pk_test_..."Same rule as everywhere else: PUBLIC_URL left at the default fails silently,
because it’s what Stripe redirects buyers back to.
4. Deploy and claim it
Section titled “4. Deploy and claim it”railway uprailway logsBoot runs the migrations and prints a setup token, since /setup is public
until an administrator exists. Open https://<project>.up.railway.app/setup,
paste it, and create your account — or shell in and seed one directly:
railway ssh -- sh -c 'ADMIN_EMAIL=you@example.com ADMIN_PASSWORD=... npm run db:seed'5. Point Stripe at it
Section titled “5. Point Stripe at it”In the Stripe dashboard, Developers → Webhooks → Add endpoint:
https://<project>.up.railway.app/api/webhooks/stripeSubscribe to checkout.session.completed, checkout.session.expired and
charge.refunded, then set the signing secret:
railway variables --set "STRIPE_WEBHOOK_SECRET=whsec_..."Until this is set, orders are never marked paid while everything else looks fine.
6. Verify
Section titled “6. Verify”- Publish a product, complete a test payment, and confirm the order shows
paid. - Upload an image, redeploy with
railway up, and confirm the image is still there — that proves the volume is mounted whereASSETS_DIRpoints. curl -s https://<project>.up.railway.app/product/<slug> | grep '<title>'shows the product name.
Redeploying: railway up builds a new image and replaces the running one.
Migrations run at boot and are idempotent. Watch the metered compute cost if you
scale to more than one instance — like Fly, a single SQLite file on a volume
only tolerates one writer, so move to Postgres first.