Self-hosted storefront for a small dairy: product cards, cart, Stripe Checkout, Interac e-Transfer, and a password-protected order book. One Node process, one SQLite file, one Docker container.
No page builder, no plugins, no external SaaS besides Stripe.
- One container, no plugin surface. WooCommerce means WordPress + PHP + a payment plugin + update churn on four moving parts. This is ~900 lines you can read in an afternoon.
- Design is not constrained by a theme. Every pixel is in
public/styles.css. - The catalog matches the cost model. Prices and pack sizes in
lib/catalog.jsare the same numbers used inBusiness/Dairy Shop/The Numbers. - Payments are hosted. Stripe Checkout means card data never touches the server (SAQ A).
- e-Transfer for locals. Zero-fee option for Calgary customers who prefer it.
Trade-off: marketing copy still lives in lib/views.js / env vars — but products, prices, stock and photos are managed in the back office, not in code.
cp .env.example .env # edit the values
docker compose up -d --build # http://localhost:3000Without Docker:
npm install
node --env-file=.env server.jsFirst run creates ./data/store.db and seeds stock from lib/catalog.js.
Add, edit, hide or delete products at /admin/products — no deploy needed. Each product has a name,
size, price, description, tags, stock, max-per-order, sort order and either an uploaded photo (JPG/PNG/WebP,
5 MB) or one of the built-in illustrations.
- New product → fills the form, appears on the storefront immediately.
- Hide (uncheck "Show on the storefront") keeps the row and its stock but removes it from sale.
- Delete removes the product; past orders keep their own copy of name and price, so the order book stays accurate. The uploaded photo is unlinked when no other product uses it.
- Sort order controls the order cards appear in (lower first).
- Stock is per product and is reserved when an order is placed, released when it is cancelled.
The seed catalog in lib/catalog.js is only used once, to populate an empty database.
Two ways to print, both sized for 62 mm continuous DK-22205 tape at 300 dpi (696 px):
- Queue from the back office —
Labelon any order, orQueue labelson a product page with a copy count. The store writes aprint_jobsrow; the agent on the machine with the printer picks it up, renders and prints it over USB. Works with the shop on any server, printer behind a home router, no port forwarding. - Print from the browser —
/admin/labels/order/<number>or/admin/labels/product/<id>renders a 62 mm page and opens the print dialog. Needs Brother's own driver/CUPS on that machine.
Set PRINT_TOKEN to the same value on the store and the agent; leaving it empty disables /api/print/*
(503) and the queue endpoints. BUSINESS_ADDRESS is printed in the footer of every label.
The daemon lives in print-agent/labelprint.py — one script, three sources (shop
queue, spool folder, LAN HTTP) and three backends (Brother USB, CUPS, PNG files). Run the shop path with
STORE_URL=… PRINT_TOKEN=… python3 labelprint.py run --source store --backend brother; --backend file
renders PNGs so you can check layout before wasting tape. Full setup, job format, udev rules and a
systemd unit are in print-agent/README.md.
Products carry their own ingredients, allergens and shelf life (days); best-before is computed from the print date. These labels are operational (name, net weight, ingredients, allergen, packed/best-before, storage) — they are not a substitute for a nutrition facts panel or bilingual retail labelling if you move into grocery.
MADE_TO_ORDER=1 (default) means stock never blocks an order — everything is made after it's placed. The per-product sold-out switch in the back office is the only thing that stops sales, and stock numbers become capacity notes rather than inventory.
Set MADE_TO_ORDER=0 to sell against counted stock: orders are refused when a product hits zero and the storefront shows "N left / sold out" automatically.
Customers get a confirmation with their order link when they check out, and a short note when you change the status to paid, packed, completed or cancelled. Sending goes through Resend — one API call, no extra dependency.
RESEND_API_KEY=re_...
EMAIL_FROM=Rebel Creamery <orders@rebelcreamery.ca>
EMAIL_FROM must be on a domain verified in Resend (add the domain in their dashboard, then copy the DKIM/SPF records into Cloudflare DNS). Until it is verified, the fallback onboarding@resend.dev only delivers to the Resend account owner — customer addresses will be rejected by the API.
Everything else lives in .env:
| Variable | What it does |
|---|---|
SITE_NAME, SITE_TAGLINE |
Wordmark and meta description |
PICKUP_LOCATION, PICKUP_WINDOW, CUTOFF_TEXT |
Pickup copy across the site |
DELIVERY_FEE_CENTS, FREE_DELIVERY_THRESHOLD_CENTS |
Delivery pricing (cents) |
ETRANSFER_EMAIL |
Where Interac e-Transfers land |
STRIPE_SECRET_KEY |
sk_live_… or sk_test_… — enables real card checkout |
STRIPE_WEBHOOK_SECRET |
whsec_… from the webhook endpoint |
DEMO_MODE=1 |
Simulates card payments when no Stripe key is set (for testing) |
ADMIN_USER, ADMIN_PASSWORD |
HTTP Basic auth for /admin |
PUBLIC_BASE_URL |
Public origin, used for Stripe redirect URLs |
Products, prices and stock live in lib/catalog.js. Stock is also editable from the order book.
- Create the account, grab the secret key, set
STRIPE_SECRET_KEY. - Add a webhook endpoint:
https://your-domain/api/stripe/webhook, eventcheckout.session.completed. Put its signing secret inSTRIPE_WEBHOOK_SECRET. - Set
DEMO_MODE=0and redeploy.
Card data never reaches this app. The webhook is what marks an order paid — without it, orders sit in
pending. Test with stripe listen --forward-to localhost:3000/api/stripe/webhook.
No integration needed: the order is recorded as awaiting_etransfer with the order number as the
message, the customer is shown the amount and your ETRANSFER_EMAIL, and you mark it paid in
/admin when it lands. Auto-deposit on your bank account means no security question.
pending (card, awaiting Stripe) → paid → packed → completed
awaiting_etransfer (e-Transfer) → paid …
cancelled returns the reserved stock to the cooler.
Stock is reserved when the order is placed, not when it is paid — otherwise two people can buy the last halloumi while one is still paying.
Use docker-compose.coolify.yml — not the dev docker-compose.yml, which publishes a host port.
-
New resource → Docker Compose, point at
https://github.com/GeorgioCA/RebelCreamery, branchmain, compose location/docker-compose.coolify.yml. -
Domain on the
webservice: enter it with the container port, e.g.https://shop.example.ca:3000. The:3000is what Coolify's proxy routes to; the public site is still 443. Coolify fillsSERVICE_FQDN_WEB_3000from the first domain in that list. -
Environment variables — set these in the panel; the compose substitutes them into the container:
Variable Required Default if unset ADMIN_PASSWORDyes (else /adminreturns 503)empty ADMIN_USERno ownerSTRIPE_SECRET_KEYfor cards empty — card option hidden STRIPE_WEBHOOK_SECRETfor cards empty — orders stay pendingETRANSFER_EMAILyes hello@rebelcreamery.caSITE_NAME,CONTACT_EMAIL,PICKUP_LOCATION,PICKUP_WINDOWno dairy defaults PUBLIC_BASE_URLno derived from the request -
Volume:
creamery-datamounts at/data— that is the entire order book. It is declared in the compose, so it is read-only in the panel's Persistent Storage page; back it up from the host. -
Healthcheck hits
/healthinside the container. The stack declaresexpose:only (noports:), so the app is never reachable except through Coolify's proxy. -
DEMO_MODEdefaults to0here: card checkout stays disabled until a Stripe key exists, rather than silently simulating payments in production.
No database container, no migrations to run, no build step beyond the Dockerfile.
The stack runs a second container, metrics/, that reads the same SQLite file and
shows the numbers: revenue by window, COGS and net profit, AOV, 30-day trend, status
board, profit by product, prepaid plans (tubs still owed, money taken but not earned),
e-Transfers waiting, stale card orders, label queue, low stock and discount usage.
Each product carries a unit cost (ingredients + packaging), editable in
/admin/products. Orders snapshot that cost on the line, so changing a recipe never
rewrites an old order's margin. Profit is revenue − COGS − card fees − prorated overhead; overhead is MONTHLY_OVERHEAD_CENTS (default 0, and the dashboard says
"before overhead" until it is set). The seeded costs and their assumptions are in
metrics/COSTS.md.
- Read-only. The connection is
mode=rowithPRAGMA query_only; the store never notices it, and stopping the container changes nothing about selling. - Its own domain.
SERVICE_FQDN_METRICS_9110in the compose makes Coolify assign a domain to themetricsservice; this stack points it athttps://metrics.rebelcreamery.ca(Cloudflare wildcard covers the hostname). - Password or nothing.
METRICS_PASSWORDempty returns 503, the same fail-closed behaviour as/admin.METRICS_USERdefaults toowner. - Prometheus at
/metrics, JSON at/api/summary.json, liveness at/health. - Reachable from the back office.
/admin/metricsrenders the same numbers inside the admin chrome, behind the admin session — no second login.METRICS_INTERNAL_URL(defaulthttp://metrics:9110on the compose network) is where the store fetches the summary;METRICS_URLonly powers theStandalone dashboard ↗link and hides the nav item when empty. If the sidecar is down the page keeps the last good snapshot and says so.
docker build -t creamery-metrics ./metrics
docker run --rm -p 9110:9110 -v rebel-creamery_creamery-data:/data \
-e METRICS_PASSWORD=… creamery-metricsMoney rules match the store: integer cents, revenue only from paid / packed /
completed, days bucketed in America/Edmonton. Details in metrics/README.md;
cost derivation in metrics/COSTS.md.
The same stack runs Umami (umami + umami-db) at
https://analytics.rebelcreamery.ca — first-party, cookieless, so no consent
banner and no third-party request from the storefront. One async script tag is
injected when UMAMI_SCRIPT_URL and UMAMI_WEBSITE_ID are set; it is never
rendered on /admin or the order page (that URL carries the customer's token).
- Why Umami, not Plausible or Swetrix. Both of those need ClickHouse. On this box that is a 1 GB-RAM floor plus a third container; Umami is one Node app and a small Postgres, and it answers the same questions (visitors, pages, sources, countries, devices).
- Cloudflare is in front, so
CLIENT_IP_HEADER=cf-connecting-ipis set or every visitor would look like a Cloudflare edge IP and the country map would be meaningless. - Admin login is
admin; the password lives inUMAMI_ADMIN_PASSWORD(see the secrets file). Change it there and in Umami together if you rotate it. - Back-office link:
ANALYTICS_URL(defaulthttps://analytics.rebelcreamery.ca) renders aVisitors ↗item next to Metrics. Empty hides it. - Umami's own database is
umami-db— separate fromcreamery-data, and it holds analytics only. Losing it costs history, never orders.
The entire business state is /data/store.db (plus -wal/-shm). Copy it nightly:
docker run --rm -v rebel-creamery_creamery-data:/data -v "$PWD":/backup alpine \
tar czf /backup/creamery-$(date +%F).tgz -C /data .server.js routes, checkout, webhook, admin auth
lib/catalog.js site copy, delivery rules, illustration styles, seed catalog
lib/db.js SQLite schema, products, orders, stock, print queue
lib/labels.js label payloads + 62 mm HTML labels
print-agent/ labelprint.py — background label printer (store/spool/HTTP → USB/CUPS/file)
lib/views.js server-rendered HTML (home, checkout, success, admin)
public/styles.css the whole design system
public/app.js cart, drawer, checkout form
public/fonts/ Fraunces + Inter, self-hosted (no Google request at runtime)
scripts/ restart-preview.sh — local server on :3100
metrics/ separate read-only container: dashboard, /metrics, /api/summary.json
tests/ e2e.py (Playwright checkout flow), shot.py (screenshots), check.py (layout probes)
- No transactional email — the confirmation is the success page. Add Resend/Postmark if wanted.
- No pickup-time slotting; one window for everyone.
- Single admin user; no audit log.
- SQLite is fine to a few thousand orders; move to Postgres beyond that.