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 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-14 18:53:08 -04:00
parent f96ab10634
commit bd2e3906df
11 changed files with 249 additions and 22 deletions

15
.dockerignore Normal file
View File

@ -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

View File

@ -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

4
Caddyfile Normal file
View File

@ -0,0 +1,4 @@
{$APP_DOMAIN} {
encode gzip
reverse_proxy app:3000
}

View File

@ -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"]

View File

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

69
deploy/README.md Normal file
View File

@ -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 repo url> 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`.

View File

@ -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;
}
}

8
docker-compose.nginx.yml Normal file
View File

@ -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"

72
docker-compose.yml Normal file
View File

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

19
scripts/backup.sh Normal file
View File

@ -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"

View File

@ -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"