Add production compose stack and stop idle load in containers

The development stack was running permanently on a server: polling file
watchers, a healthcheck that rendered a full page every five seconds and no
memory limit grew next dev to 10 GB and pushed the host into swap.

- add compose.prod.yaml running compiled output in separate api/web services
- make the Dockerfile multi-stage with dev and prod targets, prune
  devDependencies and run the runtime image as node instead of root
- bake API_INTERNAL_URL at build time; next start ignores it at runtime
  because rewrite destinations are resolved into routes-manifest.json
- drop CHOKIDAR_USEPOLLING and WATCHPACK_POLLING
- probe /health instead of /, which redirects to /projects and made every
  healthcheck render the project list
- give every service a memory limit and forbid swap in production
- rename the development compose project to leistungsbilanz-dev so its
  down command cannot target the production stack
- bind development ports to localhost
- close the http server and the SQLite handle on SIGTERM/SIGINT
- match probe user agents in the navigation log filter; Node's fetch sends
  one, so the previous check never matched
- exit docker-start.sh when either supervised process dies
- remove drizzle.config.js, a compiled copy drizzle-kit never reads, and the
  pre-Next index.html/styles.css leftovers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-08-15 00:26:56 +02:00
parent a17e2e3f4b
commit 01fa527b9c
14 changed files with 424 additions and 118 deletions

8
src/db/client.ts Normal file → Executable file
View file

@ -7,3 +7,11 @@ const defaultDatabaseContext = createDatabaseContext(
export const db = defaultDatabaseContext.db;
/**
* Closes the process-wide SQLite handle. Only the composition root calls this,
* during shutdown, so pending writes are flushed before the process exits.
*/
export function closeDefaultDatabase(): void {
defaultDatabaseContext.close();
}

11
src/proxy.ts Normal file → Executable file
View file

@ -4,10 +4,13 @@ import { createLogger } from "./shared/logging/logger";
const logger = createLogger("web:navigation");
// Node's fetch() sends "User-Agent: node", so the previous "no User-Agent"
// check never matched and every healthcheck was logged as page navigation.
const PROBE_USER_AGENT = /^(node|curl|wget|go-http-client|kube-probe)\b/i;
export function proxy(request: NextRequest) {
// The Docker healthcheck hits "/" every few seconds with no User-Agent
// header; skip it so real navigation isn't drowned out in the logs.
if (request.headers.get("user-agent")) {
const userAgent = request.headers.get("user-agent") ?? "";
if (userAgent && !PROBE_USER_AGENT.test(userAgent)) {
logger.info("page request", {
method: request.method,
path: request.nextUrl.pathname,
@ -17,5 +20,5 @@ export function proxy(request: NextRequest) {
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
matcher: ["/((?!_next/static|_next/image|favicon.ico|api|health).*)"],
};

34
src/server/index.ts Normal file → Executable file
View file

@ -4,6 +4,7 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
import { closeDefaultDatabase } from "../db/client.js";
const logger = createLogger("api");
const app = express();
@ -62,8 +63,35 @@ process.on("unhandledRejection", (reason) => {
process.exit(1);
});
process.on("SIGTERM", () => logger.info("received SIGTERM"));
process.on("SIGINT", () => logger.info("received SIGINT"));
let shuttingDown = false;
function shutdown(signal: NodeJS.Signals): void {
if (shuttingDown) return;
shuttingDown = true;
logger.info("shutting down", { signal });
// Without this the process only logged the signal and kept running, so every
// stop waited for Docker's grace period and ended in SIGKILL mid-write.
const forceExit = setTimeout(() => {
logger.warn("shutdown timed out, exiting anyway");
process.exit(1);
}, 15_000);
forceExit.unref();
server.close((error) => {
if (error) logger.error("http server close failed", toErrorMeta(error));
try {
closeDefaultDatabase();
} catch (closeError) {
logger.error("database close failed", toErrorMeta(closeError));
}
logger.info("shutdown complete");
process.exit(error ? 1 : 0);
});
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
setInterval(() => {
const memory = process.memoryUsage();
@ -74,6 +102,6 @@ setInterval(() => {
});
}, heartbeatIntervalMs).unref();
app.listen(port, () => {
const server = app.listen(port, () => {
logger.info("server started", { port });
});