Skip to content

Deployment - KCMS admin

KCMS runs on two Hetzner VPS boxes (dev + prod) behind Caddy with Let's Encrypt. The deploy workflow is dev-first, smoke-test, then promote the same artifact to prod.

For the broader two-box topology see rmsnordic-backend/docs/DEPLOYMENT.md.


Topology

Env IP SSH alias KCMS hostname
Dev 135.181.201.185 rmsnordic-dev cms-dev.rmsnordic.se
Prod 204.168.180.77 rmsnordic-prod cms.rmsnordic.se

The container is named rmsnordic-cms-admin on both boxes. It runs on :3002 internally, fronted by Caddy on :443.


How a deploy works

Like the storefront, KCMS ships its source tree to the box and rebuilds the Docker image there. This avoids pinning a CI environment and keeps the dependency on the kavel-cms monorepo's workspace layout explicit.

laptop:
  pnpm --filter cms-admin build        # smoke test
  pnpm --filter cms-admin typecheck    # type check
  tar -czf kavel-cms-src.tar.gz        # source tarball (excl node_modules + .next)

scp tarball → rmsnordic-dev:/root/kavel-cms-src.tar.gz
ssh rmsnordic-dev bash /root/deploy-cmsadmin.sh
  └─ unpacks tarball
  └─ docker build -f apps/cms-admin/Dockerfile
  └─ replaces rmsnordic-cms-admin container

smoke test https://cms-dev.rmsnordic.se/login

scp same tarball → rmsnordic-prod:/root/kavel-cms-src.tar.gz
ssh rmsnordic-prod bash /root/deploy-cmsadmin.sh

Build the tarball

cd D:/Work/Kavel\ Software\ AB/Projects/kavelcms
tar \
  --exclude='*/node_modules' \
  --exclude='*/.next' \
  --exclude='*/dist' \
  -czf /d/tmp/kavel-cms-src.tar.gz \
  kavel-cms

The tarball is small (~180 KB) - it's source only. The node_modules get installed inside the Docker build.


Deploy

# Dev first
scp /d/tmp/kavel-cms-src.tar.gz rmsnordic-dev:/root/kavel-cms-src.tar.gz
ssh rmsnordic-dev 'bash /root/deploy-cmsadmin.sh'
curl -sI https://cms-dev.rmsnordic.se/login | head -1

# Promote
scp /d/tmp/kavel-cms-src.tar.gz rmsnordic-prod:/root/kavel-cms-src.tar.gz
ssh rmsnordic-prod 'bash /root/deploy-cmsadmin.sh'
curl -sI https://cms.rmsnordic.se/login | head -1

The deploy script (on the box)

/root/deploy-cmsadmin.sh on each box. Same filename, slightly different bodies - the dev script points at the dev backend container, prod at prod:

docker run -d --name rmsnordic-cms-admin \
  --network rmsnordic-net \
  -e NODE_ENV=production \
  -e BACKEND_API_URL=http://rmsnordic-backend-dev:3001 \
  -e COOKIE_SECURE=true \
  cms-admin:latest

BACKEND_API_URL always points at the box's own backend container name - rmsnordic-backend-dev on both boxes (the name is historical; both boxes are "the dev backend" of their respective environment).


The Caddyfile

On the dev box (/opt/caddy/Caddyfile):

cms-dev.rmsnordic.se {
    encode zstd gzip
    reverse_proxy rmsnordic-cms-admin:3002
}

On prod:

cms.rmsnordic.se {
    encode zstd gzip
    reverse_proxy rmsnordic-cms-admin:3002
}

Caddy auto-renews the LE certs every ~60 days. Logs at docker logs caddy.


Roll-back

Keep the previous tarball locally. To roll back: scp the older tarball over the new one, re-run the deploy script.

If a roll-back is urgent and the previous tarball isn't around, you can fast-rebuild from the previous git commit:

git checkout <previous-sha>
pnpm --filter cms-admin build
# tar + ship + deploy
git checkout main

Verifying

# Container is up
ssh rmsnordic-dev 'docker ps --filter name=rmsnordic-cms-admin'

# Login page renders
curl -sI https://cms-dev.rmsnordic.se/login | head -1   # HTTP/2 200

# Behind the scenes - admin auth proxy reachable
ssh rmsnordic-dev 'docker logs --tail 30 rmsnordic-cms-admin'

Common failure modes after deploy:

  • 502 from Caddy - the container crashed on boot. Check docker logs rmsnordic-cms-admin. Usual culprit: missing env var (BACKEND_API_URL) or a TypeScript error that slipped past the local typecheck.
  • Login works, then immediately redirects to login again - cookie isn't being set. Check that COOKIE_SECURE=true is set on prod (browsers reject Secure cookies on http://; in dev with Caddy + LE this is always https so we keep Secure on).
  • All admin requests return 401 - the backend rejected our Bearer token. Mostly happens after rotating JWT_SECRET on the backend without redeploying KCMS - KCMS doesn't store the JWT itself, it's in the user's cookie, but every backend call uses it. Have everyone re-login.

Common gotchas

  • Workspace deps: KCMS depends on @kavel/storefront and @kavel/types from the monorepo's packages. The Dockerfile pulls them in via the pnpm workspace - if you change those packages, the KCMS image needs to be rebuilt.
  • Same tarball must go to both boxes: don't rebuild between dev and prod. The whole point of "dev-first" is to validate the exact bits that go to prod.
  • JWT secret rotation invalidates sessions: rotating JWT_SECRET on the backend forces every admin to re-login. Plan the rotation for off-hours.
  • Dev box has masked customer emails: when testing email features (mass mail, order confirmations) on dev, the customers receive nothing because their emails are devnull-...@example.test. Use a @kavelsoftware.se test account if you need real delivery on dev.