Skip to content

Commit f68b258

Browse files
feat: add hardened standalone container build (#624)
Co-authored-by: CharlieHelps <charlie@charlielabs.ai>
1 parent b4a9011 commit f68b258

7 files changed

Lines changed: 62 additions & 49 deletions

File tree

.changeset/calm-taxis-build.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hevy-mcp": patch
3+
---
4+
5+
Package the Docker image as a non-root standalone bundle without application `node_modules`.

.dockerignore

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,15 @@
1-
.git
2-
.github
3-
.changeset
4-
5-
.env
6-
.env.*
7-
!.env.sample
8-
*.pem
9-
*.key
10-
.npmrc
11-
12-
node_modules
13-
dist
14-
coverage
15-
test-results
16-
tests
17-
*.tsbuildinfo
18-
19-
*.log
20-
npm-debug.log*
21-
yarn-debug.log*
22-
yarn-error.log*
23-
pnpm-debug.log*
24-
25-
.DS_Store
26-
Thumbs.db
27-
.idea
28-
.vscode
29-
*.swp
30-
*.swo
31-
*~
1+
# Deny everything by default so local credentials and unrelated repository
2+
# files are never sent to the Docker daemon or retained in its build cache.
3+
**
4+
5+
# Docker must still be able to read the build definition and ignore rules.
6+
!Dockerfile
7+
!.dockerignore
8+
9+
# Standalone build inputs.
10+
!package.json
11+
!package-lock.json
12+
!tsconfig.json
13+
!tsdown.config.ts
14+
!src/
15+
!src/**

.github/workflows/build-and-test.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,14 @@ jobs:
171171
- name: Smoke test help output without an API key
172172
run: docker run --rm hevy-mcp:ci --help
173173

174+
- name: Verify hardened runtime structure
175+
run: |
176+
docker run --rm --entrypoint sh hevy-mcp:ci -c '
177+
test "$(id -u)" -ne 0 &&
178+
test ! -e /app/node_modules &&
179+
test -f /app/standalone.mjs
180+
'
181+
174182
otel-cicd-action:
175183
if: always()
176184
name: OpenTelemetry Export Trace

Dockerfile

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,23 @@
11
# syntax=docker/dockerfile:1
22

3-
FROM node:24-alpine AS build
3+
FROM node:lts-alpine AS build
44

55
WORKDIR /app
66

77
COPY package.json package-lock.json ./
88
RUN npm ci
99

10-
COPY . .
11-
RUN npm run build
10+
COPY tsconfig.json tsdown.config.ts ./
11+
COPY src/ ./src/
12+
RUN npm run build:standalone
1213

13-
FROM node:24-trixie-slim AS production-dependencies
14-
15-
WORKDIR /app
16-
17-
COPY package.json package-lock.json ./
18-
RUN npm ci --omit=dev && npm cache clean --force
19-
20-
FROM gcr.io/distroless/nodejs24-debian13:nonroot AS runtime
14+
FROM node:lts-alpine AS runtime
2115

2216
ENV NODE_ENV=production
2317
WORKDIR /app
2418

25-
COPY --from=production-dependencies --chown=65532:65532 /app/node_modules ./node_modules
26-
COPY --from=build --chown=65532:65532 /app/dist ./dist
19+
COPY --from=build --chown=node:node /app/dist/standalone.mjs ./standalone.mjs
20+
21+
USER node
2722

28-
ENTRYPOINT ["/nodejs/bin/node", "dist/cli.mjs"]
23+
ENTRYPOINT ["node", "/app/standalone.mjs"]

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ The server uses stdio, so `-i` keeps standard input open for the MCP client.
103103
form forwards the variable from the host environment without putting the key
104104
in the command arguments.
105105

106+
The image uses the official Node.js LTS Alpine base, runs as the non-root
107+
`node` user, and ships the application and its third-party runtime dependencies
108+
as a standalone bundle. It does not include an application `/app/node_modules`
109+
directory; the official Node base image may still contain its own globally
110+
packaged npm or Corepack files.
111+
106112
Use `latest` to follow the newest stable release. For reproducible deployments,
107113
pin the exact version shown on the release, using a tag such as
108114
`ghcr.io/chrisdoc/hevy-mcp:X.Y.Z`. Major (`:X`) and major.minor (`:X.Y`) tags

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"test:pr": "npm run test:unit && npm run test:mcp && npm run test:contract && npm run test:stdio && npm run test:pack",
5151
"openapi": "node ./scripts/openapi-spec.js",
5252
"build": "tsdown",
53+
"build:standalone": "cross-env HEVY_MCP_BUILD_MODE=standalone tsdown",
5354
"build:client": "kubb generate",
5455
"prepack": "npm run check:server-manifest && npm run build",
5556
"start": "node --env-file .env dist/cli.mjs",

tsdown.config.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ try {
1919
}
2020

2121
const { name, version } = parsed;
22+
const isStandaloneBuild = process.env.HEVY_MCP_BUILD_MODE === "standalone";
2223

2324
if (process.env.HEVY_MCP_RELEASE === "true") {
2425
const missing: string[] = [];
@@ -49,9 +50,10 @@ if (
4950
);
5051
}
5152
export default defineConfig({
52-
entry: ["src/index.ts", "src/cli.ts"],
53+
entry: isStandaloneBuild ? ["src/cli.ts"] : ["src/index.ts", "src/cli.ts"],
5354
format: ["esm"],
54-
target: "esnext",
55+
platform: isStandaloneBuild ? "node" : undefined,
56+
target: isStandaloneBuild ? "node24" : "esnext",
5557
define: {
5658
__HEVY_MCP_BUILD__: "true",
5759
__HEVY_MCP_NAME__: JSON.stringify(name),
@@ -60,13 +62,25 @@ export default defineConfig({
6062
process.env.OTEL_COLLECTOR_TOKEN ?? "",
6163
),
6264
},
63-
sourcemap: true,
65+
sourcemap: !isStandaloneBuild,
6466
clean: true,
65-
dts: true,
67+
dts: !isStandaloneBuild,
68+
deps: isStandaloneBuild
69+
? {
70+
alwaysBundle: [/.*/],
71+
onlyBundle: false,
72+
}
73+
: undefined,
6674
banner: {
6775
js: "#!/usr/bin/env node\n// Generated with tsdown\n// https://tsdown.dev",
6876
},
6977
outDir: "dist",
78+
outputOptions: isStandaloneBuild
79+
? {
80+
codeSplitting: false,
81+
entryFileNames: "standalone.mjs",
82+
}
83+
: undefined,
7084
inputOptions: {
7185
onLog(level, log, defaultHandler) {
7286
if (

0 commit comments

Comments
 (0)