build: optimize Dockerfiles for production and pin dependencies

Implement multi-stage build and static server for frontend
Run containers as non-root user and set production environment
Pin all package dependencies to exact versions
This commit is contained in:
2026-05-03 15:35:00 +08:00
parent 7aa80430d9
commit 590bd6a0ae
7 changed files with 165 additions and 57 deletions

View File

@@ -1,11 +1,17 @@
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY backend/package.json ./backend/package.json
COPY frontend/package.json ./frontend/package.json
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install --frozen-lockfile --filter @pokopia/backend...
COPY backend ./backend
COPY data ./data
COPY system-wordings.ts ./system-wordings.ts
RUN mkdir -p /app/uploads && chown -R node:node /app
ENV NODE_ENV=production
WORKDIR /app/backend WORKDIR /app/backend
COPY backend/package.json ./ USER node
RUN corepack enable && pnpm install
COPY backend/. .
COPY data /app/data
COPY package.json /app/package.json
COPY system-wordings.ts /app/system-wordings.ts
EXPOSE 3001 EXPOSE 3001
CMD ["pnpm", "run", "start"] CMD ["pnpm", "run", "start"]

View File

@@ -13,17 +13,17 @@
"test": "node --test --import tsx tests/*.test.ts" "test": "node --test --import tsx tests/*.test.ts"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "latest", "@fastify/cors": "11.2.0",
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "10.0.0",
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "10.3.0",
"@fastify/static": "^9.1.3", "@fastify/static": "9.1.3",
"fastify": "latest", "fastify": "5.8.5",
"pg": "latest" "pg": "8.20.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "latest", "@types/node": "25.6.0",
"@types/pg": "latest", "@types/pg": "8.20.0",
"tsx": "latest", "tsx": "4.21.0",
"typescript": "latest" "typescript": "6.0.3"
} }
} }

View File

@@ -41,9 +41,11 @@ services:
build: build:
context: . context: .
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:3001}
VITE_SITE_URL: ${VITE_SITE_URL:-https://pokopiawiki.tootaio.com}
environment: environment:
VITE_API_BASE_URL: http://localhost:3001 PORT: 20015
VITE_SITE_URL: https://pokopiawiki.tootaio.com
ports: ports:
- "20015:20015" - "20015:20015"
depends_on: depends_on:

View File

@@ -1,10 +1,28 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY backend/package.json ./backend/package.json
COPY frontend/package.json ./frontend/package.json
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install --frozen-lockfile --filter @pokopia/frontend...
COPY frontend ./frontend
COPY system-wordings.ts ./system-wordings.ts
ARG VITE_API_BASE_URL=http://localhost:3001
ARG VITE_SITE_URL=https://pokopiawiki.tootaio.com
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_SITE_URL=$VITE_SITE_URL
RUN pnpm --filter @pokopia/frontend build
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app/frontend ENV NODE_ENV=production
COPY frontend/package.json ./ ENV PORT=20015
RUN corepack enable && pnpm install
COPY frontend/. . WORKDIR /app
COPY package.json /app/package.json COPY --from=build /app/frontend/dist ./dist
COPY system-wordings.ts /app/system-wordings.ts COPY frontend/static-server.mjs ./static-server.mjs
USER node
EXPOSE 20015 EXPOSE 20015
CMD ["pnpm", "run", "dev"] CMD ["node", "static-server.mjs"]

View File

@@ -12,18 +12,18 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@iconify/vue": "^5.0.0", "@iconify/vue": "5.0.0",
"@vitejs/plugin-vue": "latest", "@vitejs/plugin-vue": "6.0.6",
"vite": "latest", "vite": "8.0.10",
"vue": "latest", "vue": "3.5.33",
"vue-i18n": "^11.4.0", "vue-i18n": "11.4.0",
"vue-router": "latest" "vue-router": "5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "latest", "@types/node": "25.6.0",
"@vue/tsconfig": "latest", "@vue/tsconfig": "0.9.1",
"typescript": "latest", "typescript": "6.0.3",
"vitest": "latest", "vitest": "4.1.5",
"vue-tsc": "latest" "vue-tsc": "3.2.7"
} }
} }

View File

@@ -0,0 +1,82 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dist');
const indexPath = path.join(root, 'index.html');
const host = process.env.HOST ?? '0.0.0.0';
const port = Number.parseInt(process.env.PORT ?? '20015', 10);
const contentTypes = new Map([
['.css', 'text/css; charset=utf-8'],
['.gif', 'image/gif'],
['.html', 'text/html; charset=utf-8'],
['.ico', 'image/x-icon'],
['.jpg', 'image/jpeg'],
['.js', 'text/javascript; charset=utf-8'],
['.json', 'application/json; charset=utf-8'],
['.png', 'image/png'],
['.svg', 'image/svg+xml'],
['.txt', 'text/plain; charset=utf-8'],
['.wasm', 'application/wasm'],
['.webmanifest', 'application/manifest+json; charset=utf-8'],
['.webp', 'image/webp'],
['.woff', 'font/woff'],
['.woff2', 'font/woff2'],
['.xml', 'application/xml; charset=utf-8']
]);
function isInsideRoot(filePath) {
const relativePath = path.relative(root, filePath);
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
}
function resolvePath(url) {
try {
const pathname = new URL(url ?? '/', 'http://localhost').pathname;
const filePath = path.resolve(root, `.${decodeURIComponent(pathname)}`);
return isInsideRoot(filePath) ? filePath : indexPath;
} catch {
return indexPath;
}
}
async function findStaticFile(url) {
const filePath = resolvePath(url);
try {
const fileStat = await stat(filePath);
if (fileStat.isFile()) {
return { filePath, fileStat };
}
} catch {
return { filePath: indexPath, fileStat: await stat(indexPath) };
}
return { filePath: indexPath, fileStat: await stat(indexPath) };
}
createServer(async (request, response) => {
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405);
response.end();
return;
}
const { filePath, fileStat } = await findStaticFile(request.url);
const contentType = contentTypes.get(path.extname(filePath)) ?? 'application/octet-stream';
response.writeHead(200, {
'Cache-Control': filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable',
'Content-Length': fileStat.size,
'Content-Type': contentType
});
if (request.method === 'HEAD') {
response.end();
return;
}
createReadStream(filePath).pipe(response);
}).listen(port, host);

42
pnpm-lock.yaml generated
View File

@@ -11,72 +11,72 @@ importers:
backend: backend:
dependencies: dependencies:
'@fastify/cors': '@fastify/cors':
specifier: latest specifier: 11.2.0
version: 11.2.0 version: 11.2.0
'@fastify/multipart': '@fastify/multipart':
specifier: ^10.0.0 specifier: 10.0.0
version: 10.0.0 version: 10.0.0
'@fastify/rate-limit': '@fastify/rate-limit':
specifier: ^10.3.0 specifier: 10.3.0
version: 10.3.0 version: 10.3.0
'@fastify/static': '@fastify/static':
specifier: ^9.1.3 specifier: 9.1.3
version: 9.1.3 version: 9.1.3
fastify: fastify:
specifier: latest specifier: 5.8.5
version: 5.8.5 version: 5.8.5
pg: pg:
specifier: latest specifier: 8.20.0
version: 8.20.0 version: 8.20.0
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: latest specifier: 25.6.0
version: 25.6.0 version: 25.6.0
'@types/pg': '@types/pg':
specifier: latest specifier: 8.20.0
version: 8.20.0 version: 8.20.0
tsx: tsx:
specifier: latest specifier: 4.21.0
version: 4.21.0 version: 4.21.0
typescript: typescript:
specifier: latest specifier: 6.0.3
version: 6.0.3 version: 6.0.3
frontend: frontend:
dependencies: dependencies:
'@iconify/vue': '@iconify/vue':
specifier: ^5.0.0 specifier: 5.0.0
version: 5.0.0(vue@3.5.33(typescript@6.0.3)) version: 5.0.0(vue@3.5.33(typescript@6.0.3))
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: latest specifier: 6.0.6
version: 6.0.6(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3)) version: 6.0.6(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3))
vite: vite:
specifier: latest specifier: 8.0.10
version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3) version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3)
vue: vue:
specifier: latest specifier: 3.5.33
version: 3.5.33(typescript@6.0.3) version: 3.5.33(typescript@6.0.3)
vue-i18n: vue-i18n:
specifier: ^11.4.0 specifier: 11.4.0
version: 11.4.0(vue@3.5.33(typescript@6.0.3)) version: 11.4.0(vue@3.5.33(typescript@6.0.3))
vue-router: vue-router:
specifier: latest specifier: 5.0.6
version: 5.0.6(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)) version: 5.0.6(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3))
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: latest specifier: 25.6.0
version: 25.6.0 version: 25.6.0
'@vue/tsconfig': '@vue/tsconfig':
specifier: latest specifier: 0.9.1
version: 0.9.1(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) version: 0.9.1(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))
typescript: typescript:
specifier: latest specifier: 6.0.3
version: 6.0.3 version: 6.0.3
vitest: vitest:
specifier: latest specifier: 4.1.5
version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3)) version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3))
vue-tsc: vue-tsc:
specifier: latest specifier: 3.2.7
version: 3.2.7(typescript@6.0.3) version: 3.2.7(typescript@6.0.3)
packages: packages: