From bd2e3906dfeed1d0c9036b8b2c613a66272ab49f Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Wed, 14 Oct 2026 18:53:08 -0400 Subject: [PATCH] chore(deploy): Docker stack for the VPS (db, app, worker; Caddy or existing Nginx) - multi-stage Dockerfile; worker bundled to build/worker.mjs (no tsx at runtime) - docker-compose.yml + docker-compose.nginx.yml (127.0.0.1:3320), Nginx site, encrypted backups - production URLs (datewise.metatronlabz.com) in shopify.app.toml; URL auto-update off - verified locally: healthz/landing/privacy/terms 200, unsigned proxy/webhook 400, worker starts Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 15 ++++++++ .env.example | 26 ++++++++------ Caddyfile | 4 +++ Dockerfile | 29 +++++++++++---- README.md | 2 +- deploy/README.md | 69 ++++++++++++++++++++++++++++++++++++ deploy/nginx-datewise.conf | 19 ++++++++++ docker-compose.nginx.yml | 8 +++++ docker-compose.yml | 72 ++++++++++++++++++++++++++++++++++++++ scripts/backup.sh | 19 ++++++++++ shopify.app.toml | 8 ++--- 11 files changed, 249 insertions(+), 22 deletions(-) create mode 100644 .dockerignore create mode 100644 Caddyfile create mode 100644 deploy/README.md create mode 100644 deploy/nginx-datewise.conf create mode 100644 docker-compose.nginx.yml create mode 100644 docker-compose.yml create mode 100644 scripts/backup.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4213224 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +build +.react-router +.env +.env.* +!.env.example +.shopify +.git +brand +docs +prototype +tests +extensions/*/node_modules +extensions/*/dist +*.log diff --git a/.env.example b/.env.example index 0cd5d3a..45e5723 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,22 @@ -# Filled in automatically by `shopify app dev`; set by hand in production. -SHOPIFY_API_KEY= -SHOPIFY_API_SECRET= -SHOPIFY_APP_URL=https://your-app.example.com -SCOPES=read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations,write_delivery_customizations,write_payment_customizations +# Copy to .env and fill in. Never commit .env. -# PostgreSQL 14+ (row locks are required for capacity holds; SQLite is not supported) -DATABASE_URL=postgresql://user:password@localhost:5432/datewise - -# 32 random bytes, base64: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +# ---------- Production (VPS, read by docker-compose.yml) ---------- +APP_DOMAIN=datewise.metatronlabz.com +SHOPIFY_API_KEY=your_client_id +SHOPIFY_API_SECRET=your_client_secret +POSTGRES_PASSWORD=change-me-to-a-long-random-string +# Encrypts customer contact details and messaging credentials at rest. 32 bytes, base64: +# openssl rand -base64 32 +# Back it up in the password manager; never change it (existing data can't be decrypted without it). APP_ENCRYPTION_KEY= # Optional: radius / driving-distance zones (Geocoding + Distance Matrix APIs) GOOGLE_MAPS_API_KEY= - # Optional: reminder + waitlist emails (resend.com). The domain must be verified in Resend. RESEND_API_KEY= -EMAIL_FROM_DOMAIN=mail.your-app.example.com +EMAIL_FROM_DOMAIN=mail.metatronlabz.com + +# ---------- Local development only (`shopify app dev` fills the Shopify values) ---------- +# SHOPIFY_APP_URL=https://your-tunnel.trycloudflare.com +# SCOPES=read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations,write_delivery_customizations,write_payment_customizations +# DATABASE_URL=postgresql://user:password@localhost:5432/datewise diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..80fbc6c --- /dev/null +++ b/Caddyfile @@ -0,0 +1,4 @@ +{$APP_DOMAIN} { + encode gzip + reverse_proxy app:3000 +} diff --git a/Dockerfile b/Dockerfile index aa95e47..771e204 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,28 @@ -# One image, two processes: the web app (default) and the worker (`npm run worker`). +# Datewise web app (admin, app proxy, webhooks) and background worker (hold sweeps, reminders, +# order reconciliation) — one image, two processes. The Shopify extensions and functions are +# deployed separately with `shopify app deploy`. +FROM node:22-alpine AS build +RUN apk add --no-cache openssl +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +# Build first, prune dev dependencies after (vite, esbuild and the type packages are needed to build). +RUN npx prisma generate \ + && npm run build \ + && npx esbuild app/jobs/worker.ts --bundle --platform=node --format=esm --packages=external --outfile=build/worker.mjs \ + && npm prune --omit=dev + FROM node:22-alpine RUN apk add --no-cache openssl WORKDIR /app ENV NODE_ENV=production -COPY package.json package-lock.json* ./ -RUN npm ci --omit=dev && npm i --no-save tsx && npm cache clean --force -COPY . . -RUN npx prisma generate && npm run build +COPY --from=build /app/package.json /app/package-lock.json ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/build ./build +COPY --from=build /app/public ./public +COPY --from=build /app/prisma ./prisma EXPOSE 3000 -CMD ["sh", "-c", "npx prisma migrate deploy && npm run start"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1 +# Web (default): apply database migrations, then start. The worker service overrides this with `node build/worker.mjs`. +CMD ["sh", "-c", "npx prisma migrate deploy && npx react-router-serve ./build/server/index.js"] diff --git a/README.md b/README.md index a56c5ec..5e60433 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Then, in the dev store: ## Deploying -* Web: `npm run build && npm run start` (or the Dockerfile). Worker: the same image with `npm run worker`. Run at least one worker, or holds only expire lazily and reminders won't send. +* **VPS (Docker): see `deploy/README.md`.** Web: `npm run build && npm run start` (or the Dockerfile). Worker: the same image with `node build/worker.mjs`. Run at least one worker, or holds only expire lazily and reminders won't send. * Any Node host with Postgres works (Fly.io, Render, Railway, a VPS). Shared PHP-style hosting won't run it. * `npm run deploy` publishes the extensions and app config. diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..28110c2 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,69 @@ +# Deploying Datewise to the VPS + +Domain: **https://datewise.metatronlabz.com** · Server path: `/opt/datewise` · App port (Nginx variant): `127.0.0.1:3320` + +The stack is Postgres + the web app + the worker (one image, two containers). The Shopify extensions and functions are released separately from your PC with `shopify app deploy`. + +## 1. DNS +Create an **A record**: `datewise.metatronlabz.com` → the VPS IP. Check with `dig +short datewise.metatronlabz.com`. + +## 2. Get the code on the server +```sh +mkdir -p /opt && cd /opt +git clone datewise && cd datewise +``` + +## 3. Secrets (on the server only, never in Git or chat) +```sh +cp .env.example .env +openssl rand -hex 24 # → POSTGRES_PASSWORD +openssl rand -base64 32 # → APP_ENCRYPTION_KEY (store it in the password manager too; never change it) +nano .env # fill APP_DOMAIN, SHOPIFY_API_KEY, SHOPIFY_API_SECRET, the two values above, + # and optionally GOOGLE_MAPS_API_KEY, RESEND_API_KEY, EMAIL_FROM_DOMAIN +chmod 600 .env +``` +`SHOPIFY_API_KEY` is the client ID; copy `SHOPIFY_API_SECRET` straight from the Dev Dashboard into `.env`. + +## 4a. Start: the VPS already runs Nginx (same server as Offerlyst) +```sh +ss -ltnp | grep 3320 || echo "3320 free" # pick another port in docker-compose.nginx.yml + the Nginx conf if taken +docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d --build db app worker +cp deploy/nginx-datewise.conf /etc/nginx/sites-available/datewise.metatronlabz.com +ln -s /etc/nginx/sites-available/datewise.metatronlabz.com /etc/nginx/sites-enabled/ +nginx -t && systemctl reload nginx +certbot --nginx -d datewise.metatronlabz.com +``` + +## 4b. Start: a fresh VPS with nothing on 80/443 +```sh +docker compose up -d --build # Caddy gets the HTTPS certificate automatically +``` + +## 5. Check it from outside +```sh +docker compose ps # db, app healthy; worker up +docker compose logs -f app worker # worker prints "[worker] started" +curl -s -o /dev/null -w '%{http_code}\n' https://datewise.metatronlabz.com/healthz # 200 +curl -s -o /dev/null -w '%{http_code}\n' https://datewise.metatronlabz.com/privacy # 200 +curl -s -o /dev/null -w '%{http_code}\n' https://datewise.metatronlabz.com/terms # 200 +curl -s -o /dev/null -w '%{http_code}\n' -X POST https://datewise.metatronlabz.com/webhooks/app # 400/401 (unsigned) +curl -s https://datewise.metatronlabz.com/ | grep -c 'name="shop"' # 0 (no shop-domain form) +``` + +## 6. Release the Shopify side (from your PC, in `datewise/`) +```sh +shopify app deploy +``` + +## 7. Backups (once) +```sh +openssl rand -hex 32 > /root/.datewise-backup-key && chmod 600 /root/.datewise-backup-key +chmod +x /opt/datewise/scripts/backup.sh +( crontab -l; echo "0 3 * * * /opt/datewise/scripts/backup.sh >> /var/log/datewise-backup.log 2>&1" ) | crontab - +``` + +## Redeploy after every push +```sh +cd /opt/datewise && git pull && docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d --build app worker +``` +Then confirm the change is live with `curl`. diff --git a/deploy/nginx-datewise.conf b/deploy/nginx-datewise.conf new file mode 100644 index 0000000..f31be8b --- /dev/null +++ b/deploy/nginx-datewise.conf @@ -0,0 +1,19 @@ +# /etc/nginx/sites-available/datewise.metatronlabz.com +# Enable: ln -s /etc/nginx/sites-available/datewise.metatronlabz.com /etc/nginx/sites-enabled/ && nginx -t && systemctl reload nginx +# TLS: certbot --nginx -d datewise.metatronlabz.com (adds the 443 block and the HTTP→HTTPS redirect) +server { + listen 80; + listen [::]:80; + server_name datewise.metatronlabz.com; + + location / { + proxy_pass http://127.0.0.1:3320; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 60s; + client_max_body_size 5m; + } +} diff --git a/docker-compose.nginx.yml b/docker-compose.nginx.yml new file mode 100644 index 0000000..0155ba0 --- /dev/null +++ b/docker-compose.nginx.yml @@ -0,0 +1,8 @@ +# Use this when the VPS already runs Nginx (it owns ports 80/443 and the TLS certificate). +# Runs only Postgres + the app + the worker, with the app published on localhost for Nginx to proxy to: +# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d --build db app worker +# Nginx site: deploy/nginx-datewise.conf (port 3320 — check it's free first: ss -ltnp | grep 3320). +services: + app: + ports: + - "127.0.0.1:3320:3000" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..42318a3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,72 @@ +# Production stack for one VPS: Postgres + the app + the worker + Caddy (automatic HTTPS from Let's Encrypt). +# Usage: cp .env.example .env (fill in the production section) then docker compose up -d --build +# If the VPS already runs Nginx on 80/443, use docker-compose.nginx.yml instead of Caddy (see that file). +x-app-env: &app-env + NODE_ENV: production + PORT: 3000 + DATABASE_URL: postgresql://datewise:${POSTGRES_PASSWORD}@db:5432/datewise?schema=public + SHOPIFY_API_KEY: ${SHOPIFY_API_KEY:?set SHOPIFY_API_KEY in .env} + SHOPIFY_API_SECRET: ${SHOPIFY_API_SECRET:?set SHOPIFY_API_SECRET in .env} + SHOPIFY_APP_URL: https://${APP_DOMAIN:?set APP_DOMAIN in .env} + SCOPES: read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations,write_delivery_customizations,write_payment_customizations + APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:?set APP_ENCRYPTION_KEY in .env (openssl rand -base64 32)} + GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-} + RESEND_API_KEY: ${RESEND_API_KEY:-} + EMAIL_FROM_DOMAIN: ${EMAIL_FROM_DOMAIN:-} + +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: datewise + POSTGRES_USER: datewise + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U datewise -d datewise"] + interval: 5s + retries: 10 + + app: + build: . + image: datewise:latest + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: *app-env + expose: + - "3000" + + # Sweeps expired holds, sends reminders, offers freed places to the waitlist, reconciles missed orders. + worker: + image: datewise:latest + restart: unless-stopped + depends_on: + app: + condition: service_healthy # the app has applied migrations + environment: *app-env + command: ["node", "build/worker.mjs"] + healthcheck: + disable: true + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: [app] + ports: + - "80:80" + - "443:443" + environment: + APP_DOMAIN: ${APP_DOMAIN} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + pgdata: + caddy_data: + caddy_config: diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..98cc820 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Encrypted nightly Postgres backup for Datewise. Run from the app folder on the VPS. +# Setup once: openssl rand -hex 32 > /root/.datewise-backup-key && chmod 600 /root/.datewise-backup-key +# Cron (3 am): 0 3 * * * /opt/datewise/scripts/backup.sh >> /var/log/datewise-backup.log 2>&1 +# Restore: openssl enc -d -aes-256-cbc -pbkdf2 -pass file:/root/.datewise-backup-key -in FILE | gunzip | \ +# docker compose exec -T db psql -U datewise datewise +set -eu +cd "$(dirname "$0")/.." +KEY=/root/.datewise-backup-key +DIR=/root/backups +mkdir -p "$DIR" +chmod 700 "$DIR" +OUT="$DIR/datewise-$(date +%F).sql.gz.enc" +docker compose exec -T db pg_dump -U datewise datewise | gzip | \ + openssl enc -aes-256-cbc -pbkdf2 -salt -pass "file:$KEY" -out "$OUT" +chmod 600 "$OUT" +# keep 14 days +find "$DIR" -name 'datewise-*.sql.gz.enc' -mtime +14 -delete +echo "$(date -Is) backup ok: $OUT" diff --git a/shopify.app.toml b/shopify.app.toml index 91f6beb..31565dd 100644 --- a/shopify.app.toml +++ b/shopify.app.toml @@ -4,11 +4,11 @@ client_id = "REPLACE_WITH_CLIENT_ID" name = "Datewise: Delivery & Pickup" handle = "datewise" -application_url = "https://example.com" +application_url = "https://datewise.metatronlabz.com" embedded = true [build] -automatically_update_urls_on_dev = true +automatically_update_urls_on_dev = false include_config_on_deploy = true [access_scopes] @@ -16,7 +16,7 @@ include_config_on_deploy = true scopes = "read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations,write_delivery_customizations,write_payment_customizations" [auth] -redirect_urls = ["https://example.com/auth/callback"] +redirect_urls = ["https://datewise.metatronlabz.com/auth/callback"] [webhooks] api_version = "2026-01" @@ -44,7 +44,7 @@ api_version = "2026-01" # Storefront widget talks to the app through the shop's own domain: # https://{shop}/apps/datewise/availability → {application_url}/proxy/availability [app_proxy] -url = "https://example.com/proxy" +url = "https://datewise.metatronlabz.com/proxy" subpath = "datewise" prefix = "apps"