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:
parent
f96ab10634
commit
bd2e3906df
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
|
||||
26
.env.example
26
.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
|
||||
|
||||
4
Caddyfile
Normal file
4
Caddyfile
Normal file
@ -0,0 +1,4 @@
|
||||
{$APP_DOMAIN} {
|
||||
encode gzip
|
||||
reverse_proxy app:3000
|
||||
}
|
||||
29
Dockerfile
29
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"]
|
||||
|
||||
@ -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
69
deploy/README.md
Normal 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`.
|
||||
19
deploy/nginx-datewise.conf
Normal file
19
deploy/nginx-datewise.conf
Normal 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
8
docker-compose.nginx.yml
Normal 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
72
docker-compose.yml
Normal 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
19
scripts/backup.sh
Normal 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"
|
||||
@ -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"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user