From 39d8d920b6da4791ddddb35387f7ecb4e48e6f93 Mon Sep 17 00:00:00 2001 From: dtonon Date: Mon, 24 Aug 2026 14:45:49 +0100 Subject: [PATCH] Update and generalize the deploy workflow --- .gitignore | 5 ++++ README.md | 4 +-- deploy/production-example.service | 25 ++++++++++++++++++ justfile | 43 +++++++++++++++++++------------ 4 files changed, 59 insertions(+), 18 deletions(-) create mode 100644 deploy/production-example.service diff --git a/.gitignore b/.gitignore index 3b462cb..5f67bcb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.local # Output .output @@ -18,6 +19,10 @@ Thumbs.db !.env.example !.env.test +# Deploy: only the example unit is tracked +/deploy/* +!/deploy/production-example.service + # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* diff --git a/README.md b/README.md index 4f02a7f..1bc50a0 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,10 @@ Preview a build locally with `npm run preview` (static) or `node --env-file=.env `just deploy ` rsyncs the static bundle to `~/squalk/` on the host and purges the Cloudflare cache. -`just deploy-ssr ` ships the Node build, `package.json`/`package-lock.json` and `.env.production` (as `~/squalk/.env`, since the server reads the `PUBLIC_*` values at runtime), runs `npm ci --omit=dev` and restarts the `squalk` systemd unit. On the host you need: +`just deploy-ssr ` builds with `--mode ` (so vite bakes `.env.` in), ships the Node build, `package.json`/`package-lock.json` and `.env.` (as `.env` in the app dir, since the server reads the `PUBLIC_*` values at runtime), runs `npm ci --omit=dev` and restarts the instance's systemd unit. Everything instance-specific lives in `.env..local` (gitignored, never shipped): `DEPLOY_HOST`, `DEPLOY_DIR` and `DEPLOY_SERVICE` (all required), plus the Cloudflare credentials (`CF_ZONE_ID`/`CF_API_TOKEN`) for the cache purge. Multiple instances coexist by giving each its own mode, directory, unit and port. On the host you need: - Node 22 or newer (the relay client uses the built-in `WebSocket`). -- The unit from [`deploy/squalk.service`](deploy/squalk.service), with `ORIGIN` set to the public URL — it feeds canonical links, `robots.txt` and the sitemap. +- The unit from [`deploy/production-example.service`](deploy/production-example.service), with `ORIGIN` set to the public URL — it feeds canonical links, `robots.txt` and the sitemap. - A reverse proxy in front of the port in `PORT`, replacing whatever served the static files before. With Caddy: ``` diff --git a/deploy/production-example.service b/deploy/production-example.service new file mode 100644 index 0000000..b62db51 --- /dev/null +++ b/deploy/production-example.service @@ -0,0 +1,25 @@ +# Example systemd unit for the server-rendered build (PUBLIC_SSR=yes). +# Copy to /etc/systemd/system/.service, adjust the paths, the port +# and ORIGIN, then: systemctl daemon-reload && systemctl enable --now +[Unit] +Description=Squalk forum +After=network.target + +[Service] +Type=simple +# Ephemeral unprivileged user, created by systemd for the service's lifetime +# (implies ProtectSystem=strict, ProtectHome=read-only, PrivateTmp, +# NoNewPrivileges). The app dir stays root-owned; the service only reads it. +DynamicUser=yes +WorkingDirectory=/srv/squalk +# PUBLIC_* values read at runtime by the server (deploy-ssr copies .env. here) +EnvironmentFile=/srv/squalk/.env +# ORIGIN is the public URL: it drives canonical links, robots.txt and the sitemap +Environment=NODE_ENV=production PORT=3000 ORIGIN=https://forum.example.com +ExecStart=/usr/bin/node build +Restart=on-failure +TimeoutStopSec=10 +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/justfile b/justfile index d8e02d4..9b886b6 100644 --- a/justfile +++ b/justfile @@ -1,9 +1,7 @@ set dotenv-load -# Cloudflare credentials (set these as environment variables) -CF_ZONE_ID := env_var_or_default("CF_ZONE_ID", "") -CF_API_TOKEN := env_var_or_default("CF_API_TOKEN", "") -CF_HOST := env_var_or_default("CF_HOST", "") +# Cloudflare credentials: per deploy target in .env..local (gitignored, +# never shipped to the server), falling back to the environment / .env dev: npm run dev @@ -12,7 +10,7 @@ dev: build: PUBLIC_SSR=no npm run build -# Server-rendered bundle (needs Node 22+ on the host, see deploy/squalk.service) +# Server-rendered bundle (needs Node 22+ on the host, see deploy/production-example.service) build-ssr: PUBLIC_SSR=yes npm run build @@ -21,18 +19,31 @@ deploy target: build @just purge-web-cache # Ships the Node build plus its runtime deps and env, then restarts the unit. -# The remote step runs in a login shell so the user's PATH (npm, nvm…) applies. -deploy-ssr target: build-ssr - rsync -av --delete --progress --exclude node_modules build/ {{target}}:~/squalk/build/ - rsync -av package.json package-lock.json {{target}}:~/squalk/ - rsync -av .env.production {{target}}:~/squalk/.env - ssh {{target}} '$SHELL -l -c "cd ~/squalk && npm ci --omit=dev && sudo systemctl restart squalk"' - @just purge-web-cache +# `mode` picks the instance: vite bakes .env. into the build, and +# .env..local provides the deployment details (DEPLOY_HOST, DEPLOY_DIR, +# DEPLOY_SERVICE) plus the Cloudflare credentials. The remote step runs in a +# login shell so the user's PATH (npm, nvm…) applies. +deploy-ssr mode: + #!/usr/bin/env bash + set -euo pipefail + [ -f .env.{{mode}}.local ] || { echo "Missing .env.{{mode}}.local"; exit 1; } + set -a; source .env.{{mode}}.local; set +a + : "${DEPLOY_HOST:?DEPLOY_HOST missing in .env.{{mode}}.local}" + : "${DEPLOY_DIR:?DEPLOY_DIR missing in .env.{{mode}}.local}" + : "${DEPLOY_SERVICE:?DEPLOY_SERVICE missing in .env.{{mode}}.local}" + PUBLIC_SSR=yes npm run build -- --mode {{mode}} + rsync -av --delete --progress --exclude node_modules build/ "$DEPLOY_HOST:$DEPLOY_DIR/build/" + rsync -av package.json package-lock.json "$DEPLOY_HOST:$DEPLOY_DIR/" + rsync -av .env.{{mode}} "$DEPLOY_HOST:$DEPLOY_DIR/.env" + ssh "$DEPLOY_HOST" "\$SHELL -l -c 'cd $DEPLOY_DIR && npm ci --omit=dev && sudo systemctl restart $DEPLOY_SERVICE'" + just purge-web-cache {{mode}} -purge-web-cache: - @echo "\nPurging Cloudflare cache... for zone {{CF_ZONE_ID}}" - @curl -s -X POST "https://api.cloudflare.com/client/v4/zones/{{CF_ZONE_ID}}/purge_cache" \ - -H "Authorization: Bearer {{CF_API_TOKEN}}" \ +purge-web-cache mode="production": + #!/usr/bin/env bash + if [ -f .env.{{mode}}.local ]; then set -a; source .env.{{mode}}.local; set +a; fi + echo -e "\nPurging Cloudflare cache... for zone ${CF_ZONE_ID:-}" + curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID:-}/purge_cache" \ + -H "Authorization: Bearer ${CF_API_TOKEN:-}" \ -H "Content-Type: application/json" \ --data '{"purge_everything": true}' \ | jq -r 'if .success then "✅ Cache purged successfully" else "‼️ Error: " + (.errors[0].message // "Unknown error") end'