Validated path for deploying a Python backend with a Next.js frontend to a remote VM via rsync.
Based on the Fashion App — AI Stylist deployment to Cyso Cloud.
-
Fetch SSH key from Vault:
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"
If this fails with "error in libcrypto", see Failure Handling below — that's a Vault-stored-key
trailing-newline issue (see also smaqit.infrastructure-vault-loader gotchas), not something to
routinely work around here.
-
Build frontend (Next.js, production mode):
cd frontend && NEXT_PUBLIC_API_URL= pnpm build
Produces frontend/.next/. NEXT_PUBLIC_API_URL= (empty string) is REQUIRED — it is a
client-side, build-time-baked variable; setting it empty makes the browser bundle call
relative paths (/api/v1/...), which nginx then proxies to the backend. Leaving it unset
bakes in the ?? fallback default instead (see Gotchas), which is wrong for any real
deployment. If this fails with EACCES on .next/, see Gotchas / Failure Handling below —
that's a leftover-permissions issue from a previous Docker build, not something to routinely
work around here.
-
Transfer backend to VM (rsync entire source tree):
ssh -i "$TMPKEY" ubuntu@<host> \
"sudo find __APP_DIR__/backend \( -name '__pycache__' -o -name '*.egg-info' -o -name '.eggs' -o -name 'build' \) -exec rm -rf {} + 2>/dev/null || true"
ssh -i "$TMPKEY" ubuntu@<host> "mkdir -p __APP_DIR__/backend"
rsync -avz --delete backend/ ubuntu@<host>:__APP_DIR__/backend/
CRITICAL: Rsync backend/ recursively — this must include top-level files
(app/__init__.py, app/main.py, app/db.py, pyproject.toml, Dockerfile).
Do NOT rsync only subdirectories.
The pre-rsync cleanup MUST use find recursively, not a flat glob like
backend/*.egg-info backend/__pycache__ — Python creates one __pycache__ per package
directory, and any left root-owned from a past deploy (e.g. alembic/__pycache__,
app/models/__pycache__) will make rsync --delete fail with Permission denied on the
nested ones a flat glob never reaches.
-
Transfer frontend build (production artifacts only — no full source needed):
ssh -i "$TMPKEY" ubuntu@<host> "sudo rm -rf __APP_DIR__/frontend && mkdir -p __APP_DIR__/frontend"
rsync -avz frontend/.next/ ubuntu@<host>:__APP_DIR__/frontend/.next/
rsync -avz frontend/package.json frontend/pnpm-lock.yaml frontend/next.config.js frontend/Dockerfile ubuntu@<host>:__APP_DIR__/frontend/
CRITICAL: Dockerfile MUST be included in this transfer. docker-compose.yml's frontend
service builds from this context on the VM — without the Dockerfile present, docker compose build fails outright. next start (production mode) needs only .next/, package.json,
pnpm-lock.yaml, and next.config.js at runtime — no src/, tsconfig.json, or other
source/config files. If the project has a public/ directory, rsync it too
(rsync -avz frontend/public/ ubuntu@<host>:__APP_DIR__/frontend/public/).
CRITICAL: Wipe and recreate __APP_DIR__/frontend first, don't rely on per-subpath
--delete. rsync --delete on just the .next/ subfolder leaves anything outside it
untouched — a stray node_modules/ left over from an earlier pipeline iteration (e.g. one
that used to install deps directly on the VM) gets picked up by the Dockerfile's COPY . .
and shadows the fresh RUN pnpm install layer, corrupting pnpm's internal state. Symptom:
the container logs ERROR packages field missing or empty and the frontend never starts,
even though the image builds successfully — nginx then serves 502 for every page.
-
Transfer config files:
scp -i "$TMPKEY" deployment/docker-compose.yml ubuntu@<host>:__APP_DIR__/docker-compose.yml
ssh -i "$TMPKEY" ubuntu@<host> "bash -s" < [SMAQIT_SKILLS_DIR]/smaqit.infrastructure-vm-bootstrap/scripts/remove-default-nginx-site.sh
[SMAQIT_SKILLS_DIR]/smaqit.infrastructure-deploy-rsync/scripts/write-vhost.sh "$TMPKEY" <host> deployment/nginx/<project-slug>.conf <project-slug> [<server-name-if-co-hosted>]
The stock distro default site must be removed before the first reload — see
smaqit.infrastructure-vm-bootstrap's nginx Gotcha. That script is idempotent; safe to run on
every deploy.
The nginx vhost itself is written by the same write-vhost.sh that
smaqit.infrastructure-deploy-rsync (the Node.js sibling) uses — vhost-writing is not
stack-specific, so there is exactly one implementation of the default_server-vs-name-based
decision shared by both deploy skills, not two. It inspects /etc/nginx/sites-enabled/ on the
target VM itself: default_server if this is the first site on the VM, name-based only (using
the supplied <server-name-if-co-hosted>) otherwise — refusing to proceed without that
argument on a co-hosted VM rather than guessing. It 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 (see Step 8).
-
Build and restart containers:
ssh -i "$TMPKEY" ubuntu@<host> "cd __APP_DIR__ && docker compose build && docker compose up -d --force-recreate"
CRITICAL: docker compose build is REQUIRED before up. Compose does not rebuild images on
up by default — without an explicit build, a redeploy silently keeps running the previous
image, and rsynced code changes never take effect. Use up -d --force-recreate, NOT restart
— restart reuses stale container config.
-
Run database migrations (only after containers are up and db is healthy):
ssh -i "$TMPKEY" ubuntu@<host> "bash -s" -- __APP_DIR__ db api -- alembic upgrade head \
< [SMAQIT_SKILLS_DIR]/smaqit.infrastructure-deploy-rsync-python-nextjs/scripts/run-migrations.sh
REQUIRED, and REQUIRED to run after step 6, not before. The script waits for db's
healthcheck, then runs the migration inside the already-running api container via
docker compose exec — never a standalone throwaway container, which cannot reach the
compose network's db hostname. Skipping this causes 500 errors: relation "users" does not exist.
-
Reload nginx:
ssh -i "$TMPKEY" ubuntu@<host> "sudo nginx -t && sudo systemctl reload-or-restart nginx"
Use reload-or-restart, not a bare reload — cloud-init's systemctl enable nginx only
arranges for nginx to start on the next boot, it does not start it immediately. On a VM
that's never been rebooted since creation, nginx.service may still be inactive, and a plain
reload fails with "nginx.service is not active, cannot reload." reload-or-restart starts
it if inactive and reloads it if already running, so it works in both cases.
-
Write deploy stamps:
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"
-
Verify: Invoke smaqit.infrastructure-deploy-verify with the VM URL.