Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Rebel Creamery — storefront

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.

Why this instead of WooCommerce

  • 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.js are the same numbers used in Business/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.

Run it

cp .env.example .env         # edit the values
docker compose up -d --build # http://localhost:3000

Without Docker:

npm install
node --env-file=.env server.js

First run creates ./data/store.db and seeds stock from lib/catalog.js.

Products

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.

Labels (Brother QL-800)

Two ways to print, both sized for 62 mm continuous DK-22205 tape at 300 dpi (696 px):

  1. Queue from the back office — Label on any order, or Queue labels on a product page with a copy count. The store writes a print_jobs row; 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.
  2. 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.

Configuration

Made to order vs counted stock

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.

Order email

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.

Payments

Stripe

  1. Create the account, grab the secret key, set STRIPE_SECRET_KEY.
  2. Add a webhook endpoint: https://your-domain/api/stripe/webhook, event checkout.session.completed. Put its signing secret in STRIPE_WEBHOOK_SECRET.
  3. Set DEMO_MODE=0 and 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.

Interac e-Transfer

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.

Order lifecycle

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.

Deploy on Coolify

Use docker-compose.coolify.yml — not the dev docker-compose.yml, which publishes a host port.

  1. New resource → Docker Compose, point at https://github.com/GeorgioCA/RebelCreamery, branch main, compose location /docker-compose.coolify.yml.

  2. Domain on the web service: enter it with the container port, e.g. https://shop.example.ca:3000. The :3000 is what Coolify's proxy routes to; the public site is still 443. Coolify fills SERVICE_FQDN_WEB_3000 from the first domain in that list.

  3. Environment variables — set these in the panel; the compose substitutes them into the container:

    Variable Required Default if unset
    ADMIN_PASSWORD yes (else /admin returns 503) empty
    ADMIN_USER no owner
    STRIPE_SECRET_KEY for cards empty — card option hidden
    STRIPE_WEBHOOK_SECRET for cards empty — orders stay pending
    ETRANSFER_EMAIL yes hello@rebelcreamery.ca
    SITE_NAME, CONTACT_EMAIL, PICKUP_LOCATION, PICKUP_WINDOW no dairy defaults
    PUBLIC_BASE_URL no derived from the request
  4. Volume: creamery-data mounts 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.

  5. Healthcheck hits /health inside the container. The stack declares expose: only (no ports:), so the app is never reachable except through Coolify's proxy.

  6. DEMO_MODE defaults to 0 here: 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.

Metrics

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=ro with PRAGMA query_only; the store never notices it, and stopping the container changes nothing about selling.
  • Its own domain. SERVICE_FQDN_METRICS_9110 in the compose makes Coolify assign a domain to the metrics service; this stack points it at https://metrics.rebelcreamery.ca (Cloudflare wildcard covers the hostname).
  • Password or nothing. METRICS_PASSWORD empty returns 503, the same fail-closed behaviour as /admin. METRICS_USER defaults to owner.
  • Prometheus at /metrics, JSON at /api/summary.json, liveness at /health.
  • Reachable from the back office. /admin/metrics renders the same numbers inside the admin chrome, behind the admin session — no second login. METRICS_INTERNAL_URL (default http://metrics:9110 on the compose network) is where the store fetches the summary; METRICS_URL only powers the Standalone 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-metrics

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

Visitor analytics

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-ip is 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 in UMAMI_ADMIN_PASSWORD (see the secrets file). Change it there and in Umami together if you rotate it.
  • Back-office link: ANALYTICS_URL (default https://analytics.rebelcreamery.ca) renders a Visitors ↗ item next to Metrics. Empty hides it.
  • Umami's own database is umami-db — separate from creamery-data, and it holds analytics only. Losing it costs history, never orders.

Backups

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 .

Files

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)

Known gaps

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages