Skip to content

Development & Deployment Guide

This document describes the setup of the development environment as well as build management and deployment for the Keepalived Status Monitor.

Development Environment

Development is performed inside Docker containers to ensure a consistent, platform-independent environment.

Prerequisites

  • Docker and Docker Compose (or Docker Desktop)
  • A token that can read GitHub Packages, for @stefgo/react-ui-components and @stefgo/js-template-engine, in NPM_TOKEN. The commands below take it from the GitHub CLI (gh auth token); a .env file in the root directory (excluded from git) with NPM_TOKEN=<your-token> works as well.

Starting the Development Environment

The development environment is configured via compose.dev.yaml:

NPM_TOKEN=$(gh auth token) docker compose -f compose.dev.yaml up --build

This starts a server, two keepalived nodes and one agent next to each:

Service Port Dockerfile Description
server-dev 3010 docker/Dockerfile.server.dev Backend in watch mode (npm run dev -w server/backend), restarting on code changes. It serves the frontend from server/dist/public once that has been built; for hot reload run npm run dev:frontend on the host, whose Vite server proxies /api and /ws to port 3010.
keepalived-a — docker/Dockerfile.keepalived.dev keepalived with docker/dev/keepalived-a.conf: MASTER of VRID 51 (VI_WEB, in sync group VG_DEV), BACKUP of VRID 52 (VI_DB). Unicast between the two nodes on a fixed subnet, 172.28.0.0/24.
keepalived-b — same The mirror image: BACKUP of 51, MASTER of 52.
client-a 3011 docker/Dockerfile.client.dev Agent in watch mode, in keepalived-a's PID and network namespace — the view the production agent has with pid: host and network_mode: host.
client-b 3012 same Agent next to keepalived-b.

Register the agents through their web UIs (http://localhost:3011, :3012) with the server URL http://172.28.0.10:3010; the setup PINs are in docker logs kasm-client-a / kasm-client-b. Each agent keeps its config.yaml and data in a volume of its own (KASM_CLIENT_CONFIG, KASM_CLIENT_DATA_DIR), since both run from the same client/.

Without Docker. The three processes also run straight from a checkout: npm install, npm run build -w shared (every other workspace needs its output), then npm run dev:server (http://localhost:3010), npm run dev:frontend for hot reload and npm run dev:client (http://localhost:3011, unless listenPort or KASM_CLIENT_PORT moves it). Without keepalived on the machine the agent reports it as not running, so for something to look at use the containers.

Failover on demand. Both nodes track /tmp/kasm-fault:

docker exec kasm-keepalived-a sh -c 'echo 1 > /tmp/kasm-fault'   # node A → FAULT, B takes over
docker exec kasm-keepalived-a sh -c 'echo 0 > /tmp/kasm-fault'   # A recovers and takes VI_WEB back

Stopping keepalived-a instead would take client-a with it, which shares its namespaces. After recreating a keepalived container, recreate its agent too.

Volume mounts: - server/, shared/ → mounted into server-dev for live code editing. - client/, shared/ → mounted into both agents. - node_modules is isolated as a Docker volume per service to prevent conflicts between host OS (macOS/Windows) and Linux container dependencies. - ../react-ui-components (a sibling checkout of @stefgo/react-ui-components) is mounted into server-dev at /app/react-ui-components, and it sets VITE_USE_LOCAL_UI=true to build against it. The mount is unconditional: without the checkout Docker mounts an empty directory, and a frontend build inside the container fails.

UI library outside the dev containers: a plain npm run build or npm run dev:frontend uses the installed library version. Building against a sibling checkout is opt-in with VITE_USE_LOCAL_UI=true, type-checked with npm run typecheck:local-ui -w server/frontend. See frontend.md.

Documentation Site

The pages in docs/ are served twice: GitHub renders them as plain Markdown, and MkDocs Material publishes them to https://stefgo.github.io/keepalived-status-monitor/. A page has to work in both. Three things follow from that:

  • Links out of docs/ have to be absolute. A relative ../compose.yaml resolves on GitHub and nowhere else. Use the full https://github.com/stefgo/keepalived-status-monitor/blob/main/... URL instead.
  • The hand-written table of contents in api.md uses GitHub's anchors — the emoji is dropped and the leading space becomes a dash, hence #-authentication. mkdocs.yml configures pymdownx.slugs.slugify(case=lower) precisely so that MkDocs produces the same ids. Do not swap the slugify function without checking those links.
  • A new page needs an entry in nav in mkdocs.yml. index.md is the landing page of the site and not a copy of the README.

To preview locally:

python3 -m venv .venv-docs && source .venv-docs/bin/activate
pip install -r requirements-docs.txt
mkdocs serve          # http://localhost:8000, live reload

requirements-docs.txt pins the version, so the preview and the published site render identically.

.github/workflows/docs.yml runs mkdocs build --strict on every branch and pull request that touches the documentation — a dead internal link, a nav entry without a file or a page outside the nav fails it. Only main deploys the result to GitHub Pages (Settings → Pages → Source: GitHub Actions). The workflow is deliberately separate from ci.yml and build.yml: a typo in a page must not be able to block a release, and a documentation-only commit builds no image.

Screenshots

The pictures in docs/assets/screenshots/ are taken by npm run screenshots (scripts/screenshots/capture.mjs), which drives Chromium through Playwright against a running server — no fixtures, the real application with real readings. Playwright is not a dependency of any workspace; the script loads it from the directory in PLAYWRIGHT_DIR:

npm install --prefix /tmp/kasm-pw playwright
npx --prefix /tmp/kasm-pw playwright install chromium

The data comes from the dev stack. Keep it apart from your own dev database with an override that puts the server's data directory and both agents' /data on fresh volumes, for example compose.shots.yaml:

services:
    server-dev:
        volumes: [shots-server-data:/app/server/backend/data]
    client-a:
        volumes: [shots-client-a-data:/data]
    client-b:
        volumes: [shots-client-b-data:/data]
volumes:
    shots-server-data:
    shots-client-a-data:
    shots-client-b-data:
  1. docker compose -f compose.dev.yaml -f compose.shots.yaml up --build -d
  2. Register both agents with a token each, named lb-01 and lb-02 (see the header of compose.dev.yaml), and add a webhook or two.
  3. Fail over once so the activity has a history: docker stop kasm-keepalived-a, wait, then docker start kasm-keepalived-a kasm-client-a — stopping the node stops its agent, which shares its namespaces.
  4. PLAYWRIGHT_DIR=/tmp/kasm-pw npm run screenshots

Every page is captured at 1440×900 with a device scale factor of 2, in the light theme; the dashboard is taken once more in dark. docs/stylesheets/extra.css scales them back down and cuts login-* and agent-*, which are one narrow card each, to their own width.

Both renderers switch the dashboard pair on the same markers: GitHub and Material alike show an image whose src ends in #gh-light-mode-only or #gh-dark-mode-only in that colour scheme only. Other pages show the light picture alone.

The version in the header is whatever the served bundle was built from, and a working tree with changes in it builds as -dirty. Build the frontend with the version set, so the pictures carry the commit they show:

VITE_APP_VERSION="0.0.0+$(git rev-parse --short HEAD)" npm run build -w server/frontend

POST /api/login allows 10 attempts per 15 minutes and the script signs in twice per run; docker restart kasm-server-dev resets the counter.

Build Management

Production images use multi-stage Docker builds:

Component Dockerfile
Server docker/Dockerfile.server
Client docker/Dockerfile.client

Build stages: 1. builder: Installs all dependencies, builds all TypeScript workspaces (shared, client, server/frontend, server/backend). 2. runner: Copies only compiled output and production dependencies into a slim base image (node:22-bookworm-slim or debian:bookworm-slim). The builder removes the dev dependencies with npm prune --omit=dev, which works on the installed tree — no second registry round trip and no rebuild of better-sqlite3.

.dockerignore keeps node_modules, build output, databases, config.yaml, .env, .git and the docs out of the build context; everything an image needs is copied explicitly.

Version Injection

The version string is derived in the same order everywhere — by scripts/generate-version.sh for the agent's dist/VERSION file, and by getVersion() in server/frontend/vite.config.ts for the dashboard:

  1. APP_VERSION / VITE_APP_VERSION. CI passes the released version (1.2.0, without the v of the tag) or <branch>-<short-sha> for a branch build.
  2. The version in the root package.json, which semantic-release maintains. On a commit that carries a release tag it is used as it is; otherwise the commit is appended (1.2.0+abc1234[-dirty]), so a build between releases never looks like the release.
  3. Fallback without a readable manifest: {branch}-{short-hash}[-dirty].

The server ships no VERSION file; nothing on the backend reads one.

Multi-Architecture Support

Both the server and client images are built for multiple platforms:

Component Supported Platforms
Server linux/amd64, linux/arm64
Client linux/amd64, linux/arm64

Each architecture is built on a native GitHub runner (ubuntu-latest and ubuntu-24.04-arm), without QEMU, and pushed by digest only. Once the smoke test has started those digests, a publish job assembles one manifest list per image and attaches the tags, so docker pull picks the right variant on either platform.

Continuous Integration

Type checking, linting and the tests of the pure logic are the quality gates; nothing renders a component or starts the backend. Every job declares its own permissions, and every workflow has a concurrency group; a tag build is never cancelled.

Workflow Trigger What it does
Check Code (ci.yml) Push to any branch except main, every pull request, and workflow_call Job verify: checks that the registry cleanup names every image build.yml publishes, then npm ci, npm run build (type-checks shared, client and server/backend, builds the frontend), npm test (Vitest over every workspace; reads shared from source, so it does not depend on the build before it), npm run typecheck for shared, client and server/backend (their build leaves the tests out of dist, and Vitest does not check types), npm run typecheck -w server/frontend (the Vite build does not type-check), npm run lint -w server/frontend, npm run lint (the root ESLint config, covering shared, client and server/backend).
Build Images (build.yml) Push to main or dev (except documentation-only commits), v*.*.* tags, manual dispatch — which is how a release reaches it See the job graph below.
Create Release (release.yml) Manual, main (release) or dev (beta) See Release.
Prune Registry (cleanup-packages.yml) Nightly, manual See Registry Cleanup.
Merge Dependency Updates (dependabot-auto-merge.yml) Pull requests by Dependabot See Action Updates.
Publish Docs (docs.yml) Push and pull request touching docs/, mkdocs.yml or requirements-docs.txt, manual See Documentation Site.

build.yml runs these jobs:

verify ──► prepare ──► build (server, client × amd64, arm64) ──► smoke (amd64, arm64) ──► publish
(ci.yml)                 native runners, pushed by digest,          starts each digest        manifest lists
                         no tag yet                                                           and every tag
  • verify is ci.yml, reused rather than restated. Nothing is built before it is green.
  • prepare is the single source of the version string and of the two image names.
  • build runs four native jobs that push by digest, with a GHA layer cache per image and architecture. Each hands its digest on as a workflow artefact.
  • smoke starts the digests of its architecture, see below.
  • publish assembles one manifest list per image from both digests and attaches main or dev and sha-<short>, or — for a release — the version tags and latest. It is the first job that makes anything pullable.

A build that fails the smoke test leaves its digests in the registry untagged; the nightly cleanup removes them. paths-ignore (docs/**, mkdocs.yml, requirements-docs.txt, .github/workflows/docs.yml, **.md) applies to branch pushes only — a tag or a manual run always builds.

npm ci authenticates against GitHub Packages for @stefgo/react-ui-components and @stefgo/js-template-engine with the workflow's GITHUB_TOKEN (packages: read). That works because the packages are public; if one ever becomes private, the step needs a personal access token with read:packages instead. The image builds pass the same GITHUB_TOKEN as the npm_token BuildKit secret. The former NPM_TOKEN repository secret, a personal access token with an expiry date, is no longer read.

Smoke test. The smoke job is the only place where the images are executed: everything before it proves that the code compiles, not that the result starts. It is a gate, not a report — nothing is tagged until it has passed, so latest cannot move to an image that never started. It runs on both architectures, each on its native runner, and addresses the images as <image>@sha256:… from the artefacts of this run: there is no tag yet, and a digest leaves nothing for Docker to choose. It starts the server and the agent (which, with no keepalived on the runner, reports it as not running and idles) and waits up to 60 s each for {"status":"ok"} from /api/health — the agent's through docker exec, since its route answers loopback only. A second step checks that the same request from the runner does not answer. On failure it prints the container logs.

To reproduce the gate locally, run the same commands without VITE_USE_LOCAL_UI set:

npm run build
npm test
npm run typecheck -w shared
npm run typecheck -w client
npm run typecheck -w server/backend
npm run typecheck -w server/frontend
npm run lint -w server/frontend
npm run lint

ESLint is configured twice, once per kind of code: eslint.config.mjs at the root lints shared, client and server/backend as Node TypeScript and ignores server/frontend, which has its own config with the React plugins. Two configs matching the same file would be two truths about it; a new Node workspace, on the other hand, is covered by the root config without another file.

Tests

npm test             # once, as CI runs it
npm run test:watch   # re-runs what a change touches

Vitest, configured in vitest.config.mts at the root, with one project per workspace: shared, frontend, client and backend.

  • A test lives next to its module — vrrpCluster.ts and vrrpCluster.test.ts.
  • Logic only. Every project runs in the node environment. There is no DOM, so logic that sits inside a hook or a component is first moved into a module of its own and tested there. The same holds for a class with side effects in client or server/backend: what it decides goes into a pure module, and the test is written against that.
  • The tests read shared from source. @kasm/shared exports src under the development condition, which every other project sets; without it they would read shared/dist, which needs a build first and is stale the moment shared/src changes.
  • shared, client and server/backend build without their tests (tsconfig.build.json), so they end up neither in dist nor in an image. Vitest strips types without checking them, so npm run typecheck -w <workspace> is what checks those files; the frontend's are covered by its own typecheck. That script runs tsc twice: over src, and over vite.config.ts through tsconfig.node.json, so the build configuration is checked too.
  • A backend test never opens the installation's files. core/Database.ts opens server/data/server.db and config/AppConfig.ts reads -- and may write -- config.yaml as soon as they are imported. A test of a module that imports either replaces it with vi.mock. server/backend/src/testing/memoryDatabase.ts returns an in-memory database that the real migrations brought to the current schema; the directory is left out of the build.
  • A special case that so far only a comment guarded gets a test named after it — a host whose agent is offline, an address written without its prefix, an incident whose opening arrives after its update.

Registry Cleanup

cleanup-packages.yml prunes GHCR every night at 02:00. It uses dataaxiom/ghcr-cleanup-action rather than the more obvious actions/delete-package-versions, and the reason is worth keeping: a multi-arch build pushes its per-architecture images and its attestations untagged — only the manifest list carries the tag.

kasm-server:main  ─┬─► sha256:6612…  linux/amd64      ┐
                  ├─► sha256:3695…  linux/arm64      │ each one an untagged
                  ├─► sha256:c2c3…  attestation      │ version of the package
                  └─► sha256:77e4…  attestation      ┘

An action that deletes "untagged versions" therefore hollows out the tagged images from underneath. That is not hypothetical: in the project KASM started from it left latest and several release tags with children that all returned 404 — docker pull failed on them. The previous ignore-versions regex could not prevent it, because it is matched against the version name, which for a container package is the digest.

The cleanup in use knows which children belong to a kept tag, and delete-partial-images removes the manifests that already lost theirs. latest, main, dev and anything shaped like a version are excluded from every rule — a deleted 1.2.0 breaks whoever pinned it, so release images accumulate. The exclusion also keeps the already broken tags above in place; latest becomes pullable again with the next release.

validate: true re-checks the result, but only as a warning. A step of the workflow's own therefore resolves every child digest of latest, main and dev individually and fails on a missing one — a hollowed-out image still lists its platforms in the index, only fetching the child shows that it is gone. A failing scheduled run is what GitHub sends a notification about. Until the next release replaces the broken latest, that step is expected to fail.

A manual run defaults to a dry run:

gh workflow run cleanup-packages.yml               # logs only
gh workflow run cleanup-packages.yml -f dry_run=false

The images are listed by name in the workflow; a new image has to be added there by hand. ci.yml fails when build.yml publishes an image that list does not name. Discovering the packages by wildcard would need a classic personal access token with delete:packages — an unattended delete right over every container of the account, and a credential that expires.

Action Updates

.github/dependabot.yml watches the GitHub Actions, and only those: one grouped pull request a month, prefixed ci:, aimed at dev. npm is left out on purpose — four workspaces produce a stream of version bumps that nobody can assess without a test suite. Dependabot security updates for npm arrive regardless, they need no configuration file.

This repository is maintained without pull requests, so a monthly one would simply wait. dependabot-auto-merge.yml puts a Dependabot pull request into auto-merge, and GitHub merges it once its checks are green; a red run leaves it open. It targets dev because an action bump mostly concerns actions only build.yml exercises, and that runs after a merge — on dev, a bad bump breaks the developer image, and its smoke test says so.

It needs "Allow auto-merge" enabled under Settings ▸ General ▸ Pull Requests. Without it the workflow fails loudly instead of merging unchecked.

SECURITY.md describes how to report a vulnerability privately.

Commit Messages

Commits follow Conventional Commits and are written in English. The commit message is the only input the version number comes from, so it is checked like code: .githooks/commit-msg runs commitlint against commitlint.config.mjs. ci.yml lints the commits of a pull request as well, but this repository is maintained without pull requests, so the hook is the check that actually runs. npm install activates the hooks through the root prepare script:

git config core.hooksPath .githooks   # runs automatically via `npm install`
Type Effect on the version
feat minor — 1.2.0 → 1.3.0
fix, perf, revert patch — 1.2.0 → 1.2.1
build, chore, ci, docs, refactor, style, test none
  • A breaking change is declared with a BREAKING CHANGE: footer. It raises the minor position (breaking: minor in release.yml) and still gets its own section in the changelog. A major version comes only from the release workflow's bump: major.
  • The feat!: spelling is rejected: the Angular preset semantic-release reads commits with has no ! in its header pattern, so such a commit would be read as typeless and release nothing.
  • A body line that begins with a single word and a colon (happened: …) is parsed as the start of the footer. Rephrase it.
  • Release commits (chore(release): x.y.z) are exempt from commitlint: their body is the release notes.
  • [skip release] anywhere in a message removes that commit from the version calculation.
  • .githooks/pre-push allows pushing main and dev only; topic branches stay local.
  • core.hooksPath makes git ignore .git/hooks. A hook of your own belongs in .githooks.

Release

semantic-release owns the version number; nobody tags by hand. A release is started from Actions ▸ Create Release ▸ Run workflow — on main for a release, on dev for a beta (1.3.0-beta.1, published as a prerelease). release.yml offers the inputs and names the checks and the build; what a release is and how its notes are put together is the same for all stefgo projects and lives in release-workflows.

Actions ▸ Create Release ▸ Run workflow   (main or dev)
  └─► preflight ─► ci.yml ─► semantic-release
        ├─ commits CHANGELOG.md + package.json   [skip ci]
        ├─ pushes tag v1.2.0, creates the GitHub release
        ├─ fast-forwards dev to the release commit   (from main only)
        └─ gh workflow run build.yml --ref v1.2.0
              └─► build.yml → images 1.2.0, 1.2, latest
                    └─ gh run watch --exit-status   (the release job waits)
  • dry_run (default on): the next version and the complete notes appear in the run's summary, nothing is written.
  • bump (auto | patch | minor | major): auto reads the commit types. Any other value is the step that is taken whatever the commits say — lower than they call for, or from a state that holds only docs: commits, which is what the dry run is there to catch. major is the only way a major version is created.
  • Every release is described by hand in .release/next.md: what is new, and what an upgrade needs. The text is placed above the generated list of commits. A release without it is refused by the preflight job, before the checks run. A beta keeps the text; the release from main empties the file again and drops the entries of its betas from CHANGELOG.md. .release/footer.md is appended to every release page.
  • dev is merged into main with its history — no squash, no rebase — and main is merged back before the next beta. semantic-release finds a version through the tags a branch contains, so the workflow refuses a release whose beta is not part of main, and a beta while the last release is not part of dev. After a release it fast-forwards dev itself when dev has nothing main lacks.
  • A beta builds the images as 1.3.0-beta.1 and moves neither 1.3 nor latest.
  • A run that was asked for and produces no release fails instead of going green without a result.
  • The tag is pushed over GITHUB_TOKEN, and GitHub starts no workflow for such a push. The release workflow therefore dispatches build.yml on the tag ref and follows it. If that build fails, the last step says that the version exists without images and that re-running Build Images on the tag is the fix — not a second release.
  • The root package.json carries the released version. It starts at 0.0.0; without a release tag semantic-release makes the first release 1.0.0. The workspace manifests keep 1.0.0.

To build an image from another branch, dispatch the build manually. It is tagged with the branch name and the short SHA, never with latest:

gh workflow run build.yml --ref my-branch   # -> :my-branch, :sha-abc1234

Indentation

Four spaces everywhere, no formatter — match the surrounding file. A commit that only reformats should say so in its subject and change nothing else, so git blame can be told to skip it (git config blame.ignoreRevsFile with a file listing its hash).


Deployment

Running in Production

Deploy on the target host using the production Compose file:

docker compose pull
docker compose up -d

Production services (compose.yaml):

Service Port Volumes Description
kasm-server 3010 server-data (SQLite DB), ./server-config.yaml API + web dashboard.
kasm-client 3011 (host network) client-data, ./client-config.yaml Agent, on each keepalived host — with pid: host, KILL, SYS_PTRACE (see Agent Permissions).

Both services use restart: unless-stopped and declare a healthcheck against GET /api/health (see Operations). Docker does not restart an unhealthy container; the state is for monitoring.

Which tag moves when:

Trigger Tags Moves latest
Push to main main, sha-<short> no
Push to dev dev, sha-<short> no
Release v1.2.0 1.2.0, 1.2, latest yes
Beta v1.3.0-beta.1 1.3.0-beta.1 no
Manual dispatch on a branch <branch>, sha-<short> no

npm Scripts Reference

All scripts are defined in the root package.json and target individual workspaces via -w.

Script Description
dev:server Start backend in watch/dev mode.
dev:frontend Start frontend Vite dev server with HMR.
dev:client Start client agent in watch/dev mode.
start:server Start backend in production mode.
start:client Start client agent in production mode.
build Build shared first, then client, server/backend and server/frontend.
clean Remove compiled output from shared, client, and server.
screenshots Capture docs/assets/screenshots/ from a running server, see Screenshots.

There is no start:frontend. The frontend builds into server/dist/public and is served by the backend, so start:server covers it. To serve a production bundle on its own, use npm run preview -w server/frontend.

build lists its workspaces explicitly rather than using --workspaces, which would build shared twice and rely on the order of the workspaces array. A new workspace has to be added to that list.