diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4825070 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.git +config +*.log +.DS_Store +.env* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1179a16 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Compose-level variables, interpolated into docker-compose.yml's Traefik +# labels - separate from config/.env, which is for the app's own config.yml. +TRAEFIK_HOST=pulsenode.example.com +NETWORK_NAME=falcon_network + +# Host's docker group GID, so the non-root container user can read +# /var/run/docker.sock. Find it with: getent group docker | cut -d: -f3 +DOCKER_GID=999 diff --git a/.gitignore b/.gitignore index 856f7d7..c68d778 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!/.env.example !config/.env.example # vercel diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4fc5103 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# output: 'standalone' can't be combined with a custom server (Next traces +# its own minimal server.js and ignores custom server files), so this image +# ships a full production node_modules instead of a standalone bundle. + +FROM node:22-alpine AS base +RUN corepack enable + +FROM base AS deps +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +FROM base AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +FROM base AS prod-deps +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --prod --frozen-lockfile + +FROM node:22-alpine AS runtime +RUN apk add --no-cache tini +WORKDIR /app +ENV NODE_ENV=production + +RUN addgroup -S pulsenode && adduser -S pulsenode -G pulsenode + +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=build /app/.next ./.next +COPY --from=build /app/public ./public +COPY package.json server.ts tsconfig.json next.config.ts healthcheck.mjs ./ +COPY --from=build /app/lib ./lib + +RUN mkdir -p /app/config && chown -R pulsenode:pulsenode /app + +USER pulsenode +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD ["node", "healthcheck.mjs"] + +ENTRYPOINT ["tini", "--"] +CMD ["node_modules/.bin/tsx", "server.ts"] diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..74205ea --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,12 @@ +import { configStore } from "@/lib/config/loader"; + +export const dynamic = "force-dynamic"; + +export async function GET(): Promise { + try { + configStore.get(); + return Response.json({ status: "ok" }); + } catch (err) { + return Response.json({ status: "error", message: (err as Error).message }, { status: 503 }); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9d2bd73 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +services: + pulsenode: + build: . + container_name: pulsenode + restart: unless-stopped + read_only: true + tmpfs: + - /tmp + - /app/.next/cache + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + # The container runs as a non-root user, but /var/run/docker.sock is + # owned by root:docker on the host - without joining that GID the + # container gets EACCES connecting to it. Set DOCKER_GID in .env to + # your host's docker group id (`getent group docker | cut -d: -f3`). + group_add: + - "${DOCKER_GID}" + volumes: + # Read-only: PulseNode only inspects/lists containers and reads stats, + # never execs into or writes to them. + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./config:/app/config:ro + env_file: .env + networks: + - falcon_network + labels: + - traefik.enable=true + - traefik.docker.network=${NETWORK_NAME} + - traefik.http.middlewares.pulsenode-redirect-web-secure.redirectscheme.scheme=https + - traefik.http.routers.pulsenode-web.rule=Host(`${TRAEFIK_HOST}`) + - traefik.http.routers.pulsenode-web.entrypoints=web + - traefik.http.routers.pulsenode-web.middlewares=pulsenode-redirect-web-secure + - traefik.http.routers.pulsenode-web-secure.rule=Host(`${TRAEFIK_HOST}`) + - traefik.http.routers.pulsenode-web-secure.entrypoints=websecure + - traefik.http.routers.pulsenode-web-secure.tls.certresolver=resolver + # Verify these entrypoint/middleware/certresolver names against + # traefik/dynamic/*.yaml in the stacks repo before deploying - written + # to match that repo's own convention, not independently confirmed. + - traefik.http.routers.pulsenode-web-secure.middlewares=security-headers@file,no-index@file + - traefik.http.services.pulsenode-web-secure.loadbalancer.server.port=3000 + healthcheck: + test: ["CMD", "node", "healthcheck.mjs"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + +networks: + falcon_network: + external: true diff --git a/healthcheck.mjs b/healthcheck.mjs new file mode 100644 index 0000000..facd3b2 --- /dev/null +++ b/healthcheck.mjs @@ -0,0 +1,20 @@ +import http from "node:http"; + +const req = http.request( + { + host: "127.0.0.1", + port: process.env.PORT || 3000, + path: "/api/health", + timeout: 3000, + }, + (res) => { + process.exit(res.statusCode === 200 ? 0 : 1); + } +); + +req.on("error", () => process.exit(1)); +req.on("timeout", () => { + req.destroy(); + process.exit(1); +}); +req.end(); diff --git a/next.config.ts b/next.config.ts index e9ffa30..838655d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + // dockerode and systeminformation do dynamic/OS-conditional requires + // (optional native bindings, platform-specific code paths) that don't + // survive Next's bundler well - keep them as plain runtime requires. + serverExternalPackages: ["dockerode", "systeminformation"], }; export default nextConfig; diff --git a/package.json b/package.json index 46d9915..a853d14 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "react": "19.2.8", "react-dom": "19.2.8", "systeminformation": "^5.33.1", + "tsx": "^4.23.12", "ws": "^8.21.3", "yaml": "^2.9.0", "zod": "^4.4.3" @@ -31,7 +32,6 @@ "eslint": "^9", "eslint-config-next": "16.3.1", "tailwindcss": "^4", - "tsx": "^4.23.12", "typescript": "^5" }, "packageManager": "pnpm@11.21.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bb19e2..a30c11e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: systeminformation: specifier: ^5.33.1 version: 5.33.1 + tsx: + specifier: ^4.23.12 + version: 4.23.12 ws: specifier: ^8.21.3 version: 8.21.3 @@ -69,9 +72,6 @@ importers: tailwindcss: specifier: ^4 version: 4.3.3 - tsx: - specifier: ^4.23.12 - version: 4.23.12 typescript: specifier: ^5 version: 5.9.3