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=trueis 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_SECRETon 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/storefrontand@kavel/typesfrom 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_SECRETon 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.setest account if you need real delivery on dev.