From 7d3ef6830380b4300359197e54ee351fca8563d7 Mon Sep 17 00:00:00 2001 From: AuthorTom <63931206+authorTom@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:24:08 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Docker=20distribution=20=E2=80=94=20pro?= =?UTF-8?q?duction=20image,=20compose=20recipe,=20GHCR=20publishing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile on node:20-alpine (~60 MB): production deps only, runs as the unprivileged node user, data isolated in a /app/data volume, built-in HEALTHCHECK, node as PID 1 for correct signal handling - docker-compose.yml for departmental deployments (named volume, restart policy, TRUST_PROXY toggle) - GitHub Actions workflow publishes multi-arch (amd64+arm64) images to ghcr.io on pushes to main (latest + sha tags) and v* release tags (semver); PRs build without publishing as a smoke check - New unauthenticated GET /api/health liveness endpoint - Graceful SIGTERM/SIGINT shutdown: flush sessions, drain connections, 5s hard-stop safety net - README: "Deploy with Docker" section (quick start, compose, volume backups, update/rollback and registry guidance); CHANGELOG entry Co-Authored-By: Claude Fable 5 --- .dockerignore | 30 ++++++++++ .github/workflows/docker-publish.yml | 72 ++++++++++++++++++++++ CHANGELOG.md | 9 +++ Dockerfile | 36 +++++++++++ README.md | 89 +++++++++++++++++++++++++++- docker-compose.yml | 30 ++++++++++ server.js | 21 ++++++- 7 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-publish.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f96ceb4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# Never bake local state or secrets into the image +data/ +.env* +node_modules/ + +# VCS, CI and tooling metadata +.git/ +.github/ +.claude/ +.vscode/ +.idea/ + +# Docs, tests and dev-only scripts (not needed at runtime) +*.md +LICENSE +test-qa.js +test-ui-save.js +capture-screenshots.js +scenario_template.md +Clinical-Simulation-Scenario-Master-Template.docx +public/screenshots/ + +# Docker artefacts themselves +Dockerfile +.dockerignore +docker-compose.yml + +# OS noise +.DS_Store +Thumbs.db diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..4290b70 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,72 @@ +# Builds the SimHub container and publishes it to GitHub Container Registry +# (ghcr.io) on every push to main, and with a version tag when a release tag +# (v*) is pushed. Pull requests build the image (a smoke check) but do not +# publish it. +# +# No secrets to configure: GITHUB_TOKEN is provided automatically. + +name: Publish Docker image + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + REGISTRY: ghcr.io + # ghcr requires a lowercase image name + IMAGE_NAME: ghcr.io/authortom/simhub + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Emulation + buildx enable multi-architecture images, so the same tag + # runs on ordinary x86 servers and ARM boxes (Apple Silicon, Raspberry Pi). + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # latest -> every push to main + # v1.2.3 / 1.2 -> release tags + # sha- -> immutable reference for rollbacks + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/CHANGELOG.md b/CHANGELOG.md index 2794087..c3604aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to SimHub are documented in this file, newest first. --- +## [2026-07-06] - Docker Distribution + +* **Official Container Image**: New `Dockerfile` producing a small (~60 MB) production image on `node:20-alpine` — production dependencies only, runs as the unprivileged `node` user, persistent data isolated in a `/app/data` volume, built-in Docker `HEALTHCHECK`, and `node server.js` as PID 1 so shutdown signals reach the app. +* **Docker Compose Recipe**: Ready-made `docker-compose.yml` for departmental deployments (named data volume, restart policy, `TRUST_PROXY` toggle, one-line update flow). +* **Automated Publishing**: GitHub Actions workflow builds and pushes multi-architecture (amd64 + arm64) images to GitHub Container Registry on every push to `main` — tagged `latest`, `sha-` for pinning/rollback, and semver tags on `v*` releases. Pull requests build the image as a smoke check without publishing. +* **Health Endpoint**: New unauthenticated `GET /api/health` liveness probe for containers, orchestrators and uptime monitors. +* **Graceful Shutdown**: On `SIGTERM`/`SIGINT` the server flushes session state, stops accepting connections, and exits once in-flight requests drain (with a 5-second hard-stop safety net) — so container stops and restarts never lose data. +* **Docs**: README gains a "Deploy with Docker" section (quick start, compose, volume backups, update and rollback guidance, registry/tagging reference). + ## [2026-07-05] - Production Hardening * **Forced First-Login Password Rotation**: Provisional passwords — the seeded default accounts, admin-created accounts, admin password resets, and bulk-generated temporary passwords — must now be changed at first sign-in. Enforcement is server-side: every API call except the rotation flow itself is rejected until the owner sets their own password, so a known default credential cannot be used to read or change data. The UI presents a dedicated, non-dismissable "Set a New Password" modal and only loads the app once the rotation completes. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7542fe4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# SimHub — lightweight production image. +# +# The app has no build step and a single runtime dependency (Express), so a +# plain Alpine Node base keeps the final image small (~60 MB). Everything +# mutable lives under /app/data, which should be mounted as a volume. + +FROM node:20-alpine + +ENV NODE_ENV=production +WORKDIR /app + +# Install production dependencies first so this layer only rebuilds when the +# dependency manifest changes, not on every source edit. +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +# Application source (`.dockerignore` excludes tests, docs, git metadata and +# any local data directory). +COPY server.js seed.js ./ +COPY public ./public + +# Persistent flat-file storage. Pre-create it owned by the unprivileged +# `node` user so named volumes inherit sane ownership on first use. +RUN mkdir -p data && chown -R node:node /app +USER node +VOLUME ["/app/data"] + +EXPOSE 3000 + +# Liveness probe against the unauthenticated /api/health endpoint. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.status === 200 ? 0 : 1)).catch(() => process.exit(1))" + +# Run node directly (not via npm) so SIGTERM reaches the server process and +# the graceful-shutdown handler in server.js can flush state. +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 299cf95..fe4a6f3 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,12 @@ Honesty helps you evaluate. SimHub does not currently do: manikin/hardware contr ## 🚀 Getting started (about 10 minutes) -You do not need to be a developer. If you can install a program and copy a few commands into a terminal, you can run SimHub. There is no database server to set up and no build step — one small dependency and it runs. +There are two ways to run SimHub: + +* **Directly with Node.js** (below) — the quickest way to try it out on your own machine or a test environment. +* **As a Docker container** (see [Deploy with Docker](#-deploy-with-docker)) — the recommended way to run it for a department: a small, self-contained image with your data kept safely in a volume. + +You do not need to be a developer for either. If you can install a program and copy a few commands into a terminal, you can run SimHub. There is no database server to set up and no build step — one small dependency and it runs. ### 1. Install Node.js @@ -117,14 +122,91 @@ These initial passwords are **provisional**: each account must set its own passw --- +## 🐳 Deploy with Docker + +The published container image is the easiest way to run SimHub reliably on a departmental server, a NAS, or even a Raspberry Pi (it is built for both x86 and ARM). It is small (~60 MB), runs as an unprivileged user, reports its own health, and shuts down gracefully so no data is lost on restarts. + +### Prerequisite + +[Docker](https://docs.docker.com/get-docker/) (Docker Desktop on Windows/macOS, Docker Engine on Linux). + +### Quick start — one command + +```bash +docker run -d --name simhub \ + -p 3000:3000 \ + -v simhub-data:/app/data \ + --restart unless-stopped \ + ghcr.io/authortom/simhub:latest +``` + +Open **http://localhost:3000**, sign in with the seeded accounts (see [First sign-in](#first-sign-in)), and optionally load the worked example scenario: + +```bash +docker exec simhub node seed.js +``` + +### Recommended — Docker Compose + +The repository ships a ready-made [docker-compose.yml](docker-compose.yml). Copy it (or clone the repo) onto the server, then: + +```bash +docker compose up -d # start +docker compose exec simhub node seed.js # optional: load the example scenario +docker compose logs -f simhub # watch the logs +``` + +Compose gives you a declarative record of your deployment (port, volume, environment) that you can keep in your team's documentation. Uncomment `TRUST_PROXY: "1"` in the file when running behind a reverse proxy. + +### Your data lives in the volume + +All scenarios, programmes, users, and sessions are stored in the `simhub-data` named volume — **the container itself is disposable**. You can delete and recreate the container (or update the image) without losing anything. Back the volume up either with the in-app Admin **Export** button, or at the infrastructure level: + +```bash +docker run --rm -v simhub-data:/data -v "$PWD":/backup alpine \ + tar czf /backup/simhub-data-backup.tar.gz -C /data . +``` + +### Updating to a new version + +```bash +docker compose pull && docker compose up -d +``` + +That's it — the new container starts against the same data volume, and persisted sessions mean your faculty aren't even signed out. To be able to roll back, deploy a pinned tag (`ghcr.io/authortom/simhub:sha-` or a release version) instead of `latest`, and change the tag in `docker-compose.yml` when you upgrade. + +### Building the image yourself + +If you prefer not to pull the published image (e.g. on an air-gapped network), build it from source: + +```bash +git clone https://github.com/authorTom/simhub.git +cd simhub +docker build -t simhub . +docker run -d --name simhub -p 3000:3000 -v simhub-data:/app/data --restart unless-stopped simhub +``` + +### Where images are published + +Every push to `main` automatically builds and publishes a fresh multi-architecture image to **GitHub Container Registry** via the included [workflow](.github/workflows/docker-publish.yml): + +| Tag | Meaning | +| :--- | :--- | +| `ghcr.io/authortom/simhub:latest` | Current state of `main` | +| `ghcr.io/authortom/simhub:sha-` | Immutable build of a specific commit (best for pinning/rollback) | +| `ghcr.io/authortom/simhub:` | Published when a `v*` release tag is pushed | + +--- + ## 🏥 Running SimHub for a department -For a single sim suite, `npm start` on any spare machine (Windows, macOS, Linux — modest hardware is fine) and browsing to its address is genuinely enough. For a shared departmental installation: +For a single sim suite, the Docker quick start above (or `npm start` on any spare machine — modest hardware is fine) is genuinely enough. For a shared departmental installation: * **Put it behind HTTPS**: run a reverse proxy (nginx, Caddy, IIS) in front of SimHub and terminate TLS there. Sign-in uses bearer tokens and assumes an encrypted transport. * **Set `TRUST_PROXY=1`** when behind a proxy so login rate-limiting sees real client addresses. Leave it unset when clients connect directly. * **Protect the `data/` folder**: it holds password hashes and session tokens. It is never served over the web, but restrict filesystem permissions to the service account and include it in backups. * **Restarts are painless**: active sign-ins persist across restarts and redeploys, so updating SimHub does not log your faculty out mid-session. +* **Monitoring**: `GET /api/health` is an unauthenticated liveness endpoint for uptime checks and container orchestrators (the Docker image already uses it for its built-in healthcheck). * **Port**: set the `PORT` environment variable to change from the default `3000`. Security features already built in: salted scrypt password hashing, brute-force login throttling, 8-hour sliding sessions with revocation on password change or account removal, server-side role enforcement, path-traversal and stored-XSS protections, and last-admin lockout guards. @@ -150,6 +232,7 @@ The UI tests and screenshots need Google Chrome installed in a standard location ```text simhub/ ├── .github/ # Issue templates +│ └── workflows/ # CI: builds & publishes the Docker image ├── data/ # Flat-file JSON database (git-ignored) │ ├── scenarios/ # One file per scenario │ ├── programmes/ # Programme tracks @@ -162,6 +245,8 @@ simhub/ │ └── index.html # Application shell ├── CHANGELOG.md # Full change history ├── CONTRIBUTING.md # Contribution guide +├── Dockerfile # Production container image +├── docker-compose.yml # Departmental deployment recipe ├── LICENSE # MIT licence ├── scenario_template.md # ASPiH scenario blueprint (reference) ├── seed.js # Example dataset generator diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1ccbe84 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +# SimHub — departmental deployment. +# +# docker compose up -d start (pulls the published image) +# docker compose pull && docker compose up -d update to latest +# docker compose exec simhub node seed.js load the example scenario +# +# To build from local source instead of pulling the published image, +# comment out `image:` and uncomment `build: .` + +services: + simhub: + image: ghcr.io/authortom/simhub:latest + # build: . + container_name: simhub + restart: unless-stopped + ports: + # host:container — change the left side to serve on a different port + - "3000:3000" + volumes: + # All scenarios, programmes, users and sessions live here. + # A named volume survives image updates; back it up regularly. + - simhub-data:/app/data + environment: + # Uncomment when running behind a reverse proxy (nginx, Caddy, Traefik) + # so login rate-limiting sees real client IPs: + # TRUST_PROXY: "1" + NODE_ENV: production + +volumes: + simhub-data: diff --git a/server.js b/server.js index 00872b4..2096b3f 100644 --- a/server.js +++ b/server.js @@ -466,6 +466,12 @@ function requireScenarioAccess(req, res, next) { return res.status(403).json({ error: 'Forbidden. You do not have permission to modify scenarios.' }); } +// Unauthenticated liveness probe for containers, orchestrators and uptime +// monitors. Deliberately reveals nothing beyond process health. +app.get('/api/health', (req, res) => { + res.json({ status: 'ok' }); +}); + // --- AUTH ENDPOINTS --- app.post('/api/login', async (req, res) => { @@ -1293,6 +1299,19 @@ app.use((err, req, res, next) => { res.status(status).json({ error: message }); }); -app.listen(PORT, () => { +const server = app.listen(PORT, () => { console.log(`SimHub Server running on http://localhost:${PORT}`); }); + +// Containers and process managers stop the app with SIGTERM/SIGINT: flush +// session state (captures sliding-expiry updates since the last sweep) and +// stop accepting connections, then exit once in-flight requests drain. +['SIGTERM', 'SIGINT'].forEach(sig => { + process.on(sig, () => { + console.log(`Received ${sig}, shutting down gracefully...`); + try { saveSessions(); } catch { /* best effort on the way out */ } + server.close(() => process.exit(0)); + // Hard stop if a connection refuses to drain before the runtime kills us. + setTimeout(() => process.exit(0), 5000).unref(); + }); +});