Become a nox mesh module

The mesh builds all three images from this repository and a commit
(novox/hq ADR 0069) — the Drone pipeline that built outside Docker and
copied its output in retires with the predecessor. One module carries the
API and every client instance: eef and filip are the same built client
under two names, which is a second route contribution, not a second
module (ADR 0015 — an application is one repository).

The clients drop nginx and its bash startup script: the node the API
already needs serves the static build too (client-server/serve.mjs —
files, the SPA fallback, env-config.js from the environment), so the
whole module stands on one declared base.

What the machine serves does not move: the same four public names, the
same four machine ports. MongoDB, the bucket and every credential arrive
as mesh grants instead of hand-set environment; SUPER_ADMIN_KEY stops
being a default written in code and becomes a minted secret.
This commit is contained in:
2026-09-25 20:50:19 +02:00
parent eab35c5f9f
commit 08d5c118d3
5 changed files with 330 additions and 56 deletions
+19 -24
View File
@@ -1,26 +1,21 @@
FROM nginx:1.15.2-alpine
# The photos admin client, built by the mesh from this repository (novox/hq ADR 0069). The same
# shape as client/Dockerfile — see the notes there; only the source directory differs.
#
# The build context is the repository root; every COPY says so.
ARG NODE_BASE
# Nginx config
RUN rm -rf /etc/nginx/conf.d
COPY ./conf /etc/nginx
FROM ${NODE_BASE} AS build
WORKDIR /build
COPY admin-client/package.json admin-client/package-lock.json ./
RUN npm ci
COPY admin-client/public ./public
COPY admin-client/src ./src
COPY admin-client/.env ./.env
RUN npm run build
# Static build
COPY ./build /usr/share/nginx/html/
# Default port exposure
EXPOSE 80
# Copy .env file and shell script to container
WORKDIR /usr/share/nginx/html
COPY scripts/env.sh .
COPY .env .
# Add bash
RUN apk add --no-cache bash
# Make our shell script executable
RUN chmod +x env.sh
# Start Nginx server
CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""]
FROM ${NODE_BASE}
ENV NODE_ENV=production
COPY client-server/serve.mjs /serve.mjs
COPY --from=build /build/build /site/
COPY admin-client/.env /site/.env
CMD ["node", "/serve.mjs"]
+80
View File
@@ -0,0 +1,80 @@
// The client sites' server: static files, an SPA fallback, and env-config.js from the
// environment. Node built-ins only — the runtime base already carries node for the API, so a
// second web server (and the shell its startup script needed) would be two more moving parts to
// serve files the first one serves fine.
//
// What the nginx arrangement this replaces did, it does: every unknown path without an extension
// falls back to index.html (client-side routing), nothing is cached (the site is tiny and a stale
// index.html after a deploy is the only caching bug this app has ever had), and env-config.js is
// regenerated from the container's environment at start — which is how one built image serves
// under any API URL.
import { createServer } from "node:http";
import { readFileSync, writeFileSync, createReadStream, statSync } from "node:fs";
import { join, resolve, extname } from "node:path";
const root = resolve(process.env.HTML_ROOT ?? "/site");
const port = Number(process.env.PORT ?? 80);
// .env names the variables the site reads; the environment overrides their values. Written once
// at start, exactly as the env.sh this replaces did.
const pairs = [];
for (const line of readFileSync(join(root, ".env"), "utf8").split("\n")) {
const at = line.indexOf("=");
if (at < 1) continue;
const name = line.slice(0, at).trim();
pairs.push(` ${name}: ${JSON.stringify(process.env[name] ?? line.slice(at + 1).trim())},`);
}
writeFileSync(join(root, "env-config.js"), `window._env_ = {\n${pairs.join("\n")}\n}\n`);
const types = {
".html": "text/html; charset=utf-8",
".js": "application/javascript",
".mjs": "application/javascript",
".css": "text/css",
".json": "application/json",
".map": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
".txt": "text/plain; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
};
createServer((request, response) => {
const asked = decodeURIComponent(new URL(request.url, "http://x").pathname);
// resolve() collapses any ".." before the prefix check, so a path cannot escape the root.
let path = resolve(join(root, asked));
if (!path.startsWith(root)) {
response.writeHead(403).end();
return;
}
let served;
try {
served = statSync(path);
if (served.isDirectory()) {
path = join(path, "index.html");
served = statSync(path);
}
} catch {
// Not a file: a client-side route, answered by the app itself — unless it asked for a file
// by extension, where index.html would be a wrong answer dressed as a right one.
if (extname(asked) !== "") {
response.writeHead(404, { "Content-Type": "text/plain" }).end("not found\n");
return;
}
path = join(root, "index.html");
served = statSync(path);
}
response.writeHead(200, {
"Content-Type": types[extname(path)] ?? "application/octet-stream",
"Content-Length": served.size,
"Cache-Control": "no-cache",
});
createReadStream(path).pipe(response);
}).listen(port, () => console.log(`serving ${root} on :${port}`));
+25 -24
View File
@@ -1,26 +1,27 @@
FROM nginx:1.15.2-alpine
# The photos client, built by the mesh from this repository (novox/hq ADR 0069): the react build
# happens here rather than in a CI step that copied its output in.
#
# One base, named rather than pinned (novox/hq issue 044), declared in module.json's `build.on`:
# the site is compiled and served by the same node the API already needs. Serving is
# client-server/serve.mjs — static files, the SPA fallback, and env-config.js regenerated from the
# container's environment at start, which is how one built image serves under any API URL. The
# nginx-and-bash arrangement this replaces did the same with two more moving parts.
#
# The build context is the repository root; every COPY says so.
ARG NODE_BASE
# Nginx config
RUN rm -rf /etc/nginx/conf.d
COPY ./conf /etc/nginx
FROM ${NODE_BASE} AS build
WORKDIR /build
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client/public ./public
COPY client/src ./src
COPY client/.env ./.env
RUN npm run build
# Static build
COPY ./build /usr/share/nginx/html/
# Default port exposure
EXPOSE 80
# Copy .env file and shell script to container
WORKDIR /usr/share/nginx/html
COPY scripts/env.sh .
COPY .env .
# Add bash
RUN apk add --no-cache bash
# Make our shell script executable
RUN chmod +x env.sh
# Start Nginx server
CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""]
FROM ${NODE_BASE}
ENV NODE_ENV=production
COPY client-server/serve.mjs /serve.mjs
COPY --from=build /build/build /site/
COPY client/.env /site/.env
CMD ["node", "/serve.mjs"]
+181
View File
@@ -0,0 +1,181 @@
{
"module": "photos",
"version": "1",
"capabilities": [
"container-runtime"
],
"requires": [
"s3-bucket",
"mongodb-database",
"route"
],
"contributes": {
"s3-bucket": {
"bucket": "photos"
},
"mongodb-database": {
"name": "photos"
},
"route": {
"api": {
"label": "photos-api",
"port": 9102
},
"admin": {
"label": "photos",
"port": 8102
},
"eef": {
"label": "eef",
"port": 8104
},
"filip": {
"label": "filip",
"port": 8103
}
}
},
"binds": {
"s3-bucket": "/var/lib/photos/store.json",
"mongodb-database": "/var/lib/photos/database.json",
"route": "/var/lib/photos/route.json"
},
"secrets": {
"s3-bucket": "/var/lib/photos/store.secret",
"mongodb-database": "/var/lib/photos/database.secret"
},
"own-secrets": {
"admin-key": "/var/lib/photos/admin-key.secret"
},
"listens": [
{
"port": 9102,
"protocol": "tcp",
"from": "mesh",
"why": "the photos API over http; photos-api.novox.be is a route grant, and the proxy reaches it here. The machine side of the 9102:9000 mapping, named because three of this module's containers share the container-side port 80 and only the machine side tells them apart"
},
{
"port": 8102,
"protocol": "tcp",
"from": "mesh",
"why": "the admin client site over http; the public name photos.novox.be is a route grant, and the proxy reaches it here"
},
{
"port": 8103,
"protocol": "tcp",
"from": "mesh",
"why": "the filip client site over http; the public name filip.novox.be is a route grant, and the proxy reaches it here"
},
{
"port": 8104,
"protocol": "tcp",
"from": "mesh",
"why": "the eef client site over http; the public name eef.novox.be is a route grant, and the proxy reaches it here"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"path": "/var/lib/photos",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/photos/server.env",
"mode": "0600",
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\nSUPER_ADMIN_KEY=${secret:admin-key}\n"
},
{
"id": "client-env",
"type": "file",
"path": "/var/lib/photos/client.env",
"mode": "0644",
"content": "REACT_APP_API_URL=https://photos-api.novox.be\n"
},
{
"id": "net",
"type": "network",
"name": "photos"
},
{
"id": "server",
"type": "container",
"name": "photos-server",
"artifact": "server",
"network": "photos",
"env-file": [
"/var/lib/photos/server.env"
],
"ports": [
"9102:9000"
],
"secrets-in-environment": "the application's own code reads MONGO_URL, MINIO_SECRET and SUPER_ADMIN_KEY from the environment (server/src/config.js); converting is this repository's change, tracked but not blocking the mesh conversion"
},
{
"id": "admin",
"type": "container",
"name": "photos-admin",
"artifact": "admin-client",
"network": "photos",
"env-file": [
"/var/lib/photos/client.env"
],
"ports": [
"8102:80"
]
},
{
"id": "eef",
"type": "container",
"name": "photos-eef",
"artifact": "client",
"network": "photos",
"env-file": [
"/var/lib/photos/client.env"
],
"ports": [
"8104:80"
]
},
{
"id": "filip",
"type": "container",
"name": "photos-filip",
"artifact": "client",
"network": "photos",
"env-file": [
"/var/lib/photos/client.env"
],
"ports": [
"8103:80"
]
}
],
"build": {
"on": [
{
"arg": "NODE_BASE",
"image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9"
}
],
"artifacts": [
{
"name": "server",
"kind": "image",
"from": "server/Dockerfile"
},
{
"name": "client",
"kind": "image",
"from": "client/Dockerfile"
},
{
"name": "admin-client",
"kind": "image",
"from": "admin-client/Dockerfile"
}
]
}
}
+25 -8
View File
@@ -1,10 +1,27 @@
FROM node:21.2.0
# The photos API, built by the mesh from this repository (novox/hq ADR 0069): the build happens
# here rather than in a CI step that copied its output in — an image the mesh can rebuild from a
# commit is one whose contents that commit fully determines.
#
# The base is named rather than pinned (novox/hq issue 044): declared in module.json's `build.on`,
# so the copy the mesh holds answers it. One base for both stages — the runtime stage installs
# production dependencies itself (sharp is a native module rollup cannot bundle), so it needs npm
# exactly as the build stage does.
#
# The build context is the repository root; every COPY says so.
ARG NODE_BASE
# Create app directory
WORKDIR /usr/src/photos-server.novox.be
FROM ${NODE_BASE} AS build
WORKDIR /build
COPY server/package.json server/package-lock.json ./
RUN npm ci
COPY server/rollup.config.mjs ./
COPY server/src ./src
RUN npm run build
# Copy build output
COPY ./dist ./
COPY ./node_modules ./node_modules
CMD ["node", "server.cjs", "--enable-source-maps"]
FROM ${NODE_BASE}
ENV NODE_ENV=production
WORKDIR /app
COPY server/package.json server/package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /build/dist ./dist
CMD ["node", "dist/server.cjs", "--enable-source-maps"]