Skip to content

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.

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.

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.

A 1 GB Droplet on Ubuntu LTS is enough for a small store. Create it with your SSH key, then:

Terminal window
apt update && apt upgrade -y
adduser beluga && usermod -aG sudo beluga
ufw allow OpenSSH && ufw allow 80 && ufw allow 443 && ufw enable

Install Node 22 as the beluga user with nvm, plus the build tools better-sqlite3 and sharp need:

Terminal window
sudo apt install -y build-essential python3
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
nvm install 22
Terminal window
git clone https://github.com/<you>/<your-fork>.git ~/store
cd ~/store
nvm use
npm ci
npm run build

npm run build typechecks everything including the tests, then emits the server to dist-server/ and the client to dist/.

Create ~/store/.env:

NODE_ENV=production
API_PORT=4000
API_HOST=127.0.0.1
DATABASE_URL=file:./data/beluga.sqlite
ASSETS_DIR=public/assets
SESSION_SECRET=<openssl rand -base64 32>
PUBLIC_URL=https://shop.example.com
STRIPE_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.

/etc/systemd/system/beluga.service:

[Unit]
Description=Beluga store
After=network.target
[Service]
User=beluga
WorkingDirectory=/home/beluga/store
ExecStart=/home/beluga/.nvm/versions/node/v22.23.2/bin/node dist-server/server/index.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target

Check the node path with which node while nvm use is active. Then:

Terminal window
sudo systemctl enable --now beluga
journalctl -u beluga -f

The log prints that the store is not set up, and a setup token. Keep this terminal open for step 6.

Caddy is the least work: it obtains and renews the certificate itself.

Terminal window
sudo apt install -y caddy

/etc/caddy/Caddyfile:

shop.example.com {
reverse_proxy 127.0.0.1:4000
}
Terminal window
sudo systemctl reload caddy

Point DNS at the Droplet first, or the certificate request fails. An nginx configuration is in the examples if you prefer it.

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:

Terminal window
cd ~/store && ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='...' npm run db:seed

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.

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:

Terminal window
cd ~/store && git pull && npm ci && npm run build && sudo systemctl restart beluga

Migrations 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.

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.

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.

Terminal window
fly launch --no-deploy # answer no to Postgres and Redis
fly 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 = 1

auto_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.

Terminal window
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.

Terminal window
fly deploy
fly logs

Boot 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:

Terminal window
fly ssh console -C "sh -c 'ADMIN_EMAIL=you@example.com ADMIN_PASSWORD=... npm run db:seed'"

In the Stripe dashboard, Developers → Webhooks → Add endpoint:

https://<app>.fly.dev/api/webhooks/stripe

Subscribe to checkout.session.completed, checkout.session.expired and charge.refunded. Copy the signing secret and set it:

Terminal window
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.

  • Publish a product, complete a test payment, and confirm the order shows paid.
  • Upload an image, then fly deploy again and confirm the image is still there. That proves the volume is mounted where ASSETS_DIR points.
  • 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.

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.

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.

Terminal window
railway login
railway init
railway volume add --mount-path /data
railway domain

railway 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.

Terminal window
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.

Terminal window
railway up
railway logs

Boot 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:

Terminal window
railway ssh -- sh -c 'ADMIN_EMAIL=you@example.com ADMIN_PASSWORD=... npm run db:seed'

In the Stripe dashboard, Developers → Webhooks → Add endpoint:

https://<project>.up.railway.app/api/webhooks/stripe

Subscribe to checkout.session.completed, checkout.session.expired and charge.refunded, then set the signing secret:

Terminal window
railway variables --set "STRIPE_WEBHOOK_SECRET=whsec_..."

Until this is set, orders are never marked paid while everything else looks fine.

  • 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 where ASSETS_DIR points.
  • 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.