-
Fetch SSH key from Vault into a secure temp file:
export VAULT_ADDR=http://127.0.0.1:8200
export PROJECT_SLUG=<project-slug>
TMPKEY=$(mktemp)
trap "rm -f $TMPKEY" EXIT
vault kv get -field=private_key secret/${PROJECT_SLUG}/ssh > "$TMPKEY"
chmod 600 "$TMPKEY"
All subsequent ssh, rsync, and scp commands use -i "$TMPKEY". The file is wiped
automatically when the shell exits or the script completes.
-
Build backend:
cd backend && npm run build
Produces backend/dist/.
-
Build frontend:
cd frontend && npm run build
Produces frontend/dist/. If VITE_DEMO_MODE must be set, export it before building — Vite bakes
this value in at build time and it cannot be changed without a rebuild:
VITE_DEMO_MODE=true npm run build
-
Transfer backend artifacts to VM:
ssh -i "$TMPKEY" ubuntu@<host> "mkdir -p __APP_DIR__/backend/dist"
rsync -avz --delete backend/dist/ ubuntu@<host>:__APP_DIR__/backend/dist/
rsync -avz backend/package.json backend/package-lock.json ubuntu@<host>:__APP_DIR__/backend/
CRITICAL: Always mkdir -p __APP_DIR__/backend/dist before rsyncing. The trailing slash on
backend/dist/ copies the directory's contents — if the target directory does not exist, rsync
creates it one level too shallow and the container fails with
Cannot find module '/app/dist/index.js'.
-
Install node_modules on VM (production only, inside a throwaway container):
ssh -i "$TMPKEY" ubuntu@<host> \
"cd __APP_DIR__/backend && docker run --rm -v \$(pwd):/app -w /app node:22-alpine npm install --production"
-
Transfer frontend build:
rsync -avz --delete frontend/dist/ ubuntu@<host>:__APP_DIR__/frontend/
-
Transfer config files:
scp -i "$TMPKEY" deployment/docker-compose.yml ubuntu@<host>:__APP_DIR__/docker-compose.yml
The nginx conf is written by scripts/write-vhost.sh, not scp'd directly — it decides
default_server vs. name-based on its own by inspecting /etc/nginx/sites-enabled/ on the
target VM (this matters whenever the target is co-hosted, e.g.
provisioning_mode: existing-shared, or any VM already serving another project), rather than
that decision being made in prose here:
scripts/write-vhost.sh "$TMPKEY" <host> deployment/nginx/<project-slug>.conf <project-slug> [<server-name-if-co-hosted>]
- Nothing else enabled (first site on this VM): the script sets
listen 80 default_server;
— this is the catch-all for requests with no matching Host header.
- Another site is already enabled (co-hosted, this is not the first): the script requires
an explicit
<server-name-if-co-hosted> argument and writes a name-based vhost only — listen 80; with that server_name, and no default_server. It refuses to proceed without that
argument rather than guessing one. Two vhosts both claiming default_server on the same port
fails nginx -t outright.
- The script uploads to
/etc/nginx/sites-available/<project-slug>, symlinks into
sites-enabled/, and runs nginx -t itself — a non-zero exit means the previous config is
still active and nothing was reloaded (see Step 9).
-
Write deploy stamp files (enables SHA verification in the health endpoint):
ssh -i "$TMPKEY" ubuntu@<host> \
"printf '%s' '$(git rev-parse HEAD)' > __APP_DIR__/backend/DEPLOY_SHA && \
printf '%s' '$(date -u +%Y-%m-%dT%H:%M:%SZ)' > __APP_DIR__/backend/DEPLOY_TIME"
Write to __APP_DIR__/backend/, not __APP_DIR__/ — the container mounts __APP_DIR__/backend/ as /app;
files one level up are invisible to the container.
-
Restart containers:
ssh -i "$TMPKEY" ubuntu@<host> "cd __APP_DIR__ && docker compose up -d --force-recreate"
Use docker compose (v2, no hyphen). --force-recreate is required because the app is deployed
as files, not as a new image.
-
Reload nginx:
ssh -i "$TMPKEY" ubuntu@<host> "sudo nginx -t && sudo systemctl reload nginx"
-
Verify: Invoke smaqit.infrastructure-deploy-verify with the VM URL.