From 539e6d3fad79d935b86c6a6eb1a6efda145a5536 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 7 Sep 2026 11:18:40 +0200 Subject: [PATCH] fix(docker): harden image and automate safe VPS updates - Use floating node:alpine that tracks the latest supported LTS; pnpm bootstrap follows package.json's packageManager pin. - Drop corepack (removed from node:26), install pnpm via npm global. - Add pnpm fetch + offline install for stable dependency-layer caching. - Run as non-root nextjs (UID/GID 33 = host www-data) with tini as PID 1 for correct signal handling. - Open node engines to >=20.9.0 so patches/minors float automatically. - Add docker-preflight.sh (per-VPS checks incl. --fix) and gate docker-update.sh so Node major upgrades require explicit review while patches deploy silently. --- Dockerfile | 104 ++++++++------------------- docker-compose.yml | 9 +-- package.json | 2 +- scripts/docker-preflight.sh | 139 ++++++++++++++++++++++++++++++++++++ scripts/docker-update.sh | 33 +++++++-- 5 files changed, 201 insertions(+), 86 deletions(-) create mode 100755 scripts/docker-preflight.sh diff --git a/Dockerfile b/Dockerfile index fbb7b070..905cda2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,78 +1,34 @@ -# syntax=docker/dockerfile:1 -# ============================================================================== -# EpicNext-CMS — Docker image (Node 26.8.1, multi-package-manager, Next.js standalone) -# ============================================================================== - -# --- Builder stage --- -FROM node:26.8.1-bookworm-slim AS builder - -RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -RUN npm install -g pnpm@11.25.0 yarn - +# node:alpine = latest Node within the supported LTS major (tracks the newest +# patch automatically; currently v26.x, which satisfies package.json's +# engines ">=26.8.1 <27"). +FROM node:alpine AS builder WORKDIR /app - -COPY package.json *lock* pnpm-workspace.yaml .npmrc ./ - -ARG PACKAGE_MANAGER= - -RUN --mount=type=cache,id=epicnext-pnpm,target=/pnpm/store,sharing=locked \ - if [ "$PACKAGE_MANAGER" = "pnpm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f pnpm-lock.yaml ]; }; then \ - echo ">> Using pnpm" && \ - pnpm install --frozen-lockfile --ignore-scripts --store-dir=/pnpm/store; \ - elif [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \ - echo ">> Using yarn" && \ - yarn install --frozen-lockfile --ignore-scripts; \ - elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \ - echo ">> Using npm" && \ - npm ci --ignore-scripts; \ - else \ - echo "!! No lockfile found — falling back to npm install" && \ - npm install --ignore-scripts; \ - fi - +ENV NEXT_TELEMETRY_DISABLED=1 +# pnpm install is always pinned to the version in package.json's +# `packageManager` field (pnpm auto-selects it on install), so this global +# install only needs to exist as a bootstrap and follows the active major. +RUN apk add --no-cache git \ + && npm install -g pnpm@latest +COPY package.json pnpm-lock.yaml* ./ +# pnpm fetch: download all deps into $PNPM_STORE first, so only the lockfile +# change (not source changes) invalidates the network-heavy download layer. +RUN pnpm fetch --ignore-scripts +RUN pnpm install --frozen-lockfile --ignore-scripts --offline COPY . . +RUN pnpm run build -ENV NODE_ENV=production -RUN --mount=type=cache,id=epicnext-next,target=/app/.next/cache,sharing=locked \ - if [ -f .env ]; then set -a && . ./.env && set +a; fi && \ - if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \ - yarn build; \ - elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \ - npm run build; \ - else \ - pnpm build; \ - fi - -# --- Runtime stage --- -FROM node:26.8.1-bookworm-slim AS runner - -RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -ARG RUN_USER=www-data - -ENV NODE_ENV=production -ENV PORT=3002 -ENV HOSTNAME=0.0.0.0 - -EXPOSE 3002 - +FROM node:alpine AS runner WORKDIR /app - -RUN mkdir -p /app/storage \ - /app/public/nitro-assets \ - /app/public/swf \ - /var/www/Gamedata \ - && chown -R ${RUN_USER} /app /var/www/Gamedata - -COPY --from=builder --chown=${RUN_USER} /app/.next/standalone ./ -COPY --from=builder --chown=${RUN_USER} /app/public ./public -COPY --from=builder --chown=${RUN_USER} /app/.next/static ./.next/static - -VOLUME ["/app/public/nitro-assets", "/app/public/swf", "/app/storage"] - -USER ${RUN_USER} - -CMD ["node", "server.js"] +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3002 \ + HOSTNAME=0.0.0.0 +RUN apk add --no-cache tini \ + && addgroup -g 33 -S nextjs && adduser -u 33 -S -G nextjs nextjs +COPY --from=builder --chown=nextjs:nextjs /app/public ./public +COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3002 +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "server.js"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0b0fbe6d..e69ee833 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,11 +7,6 @@ services: # build containers on the bridge network have no outbound NAT/DNS. Build on # the host network instead so pnpm/npm/yarn can reach the registry. network: host - args: - # Run as the host owner (www-data = UID/GID 33, already present in the - # node base image) of /var/www/Gamedata so the container can read + write - # the shared gamedata directory. - RUN_USER: "www-data" container_name: epicnext-cms # Runs on the host network so existing 127.0.0.1 refs in .env keep working: # MariaDB (3306), DragonflyDB/Redis (6379), emulator RCON (3003) + API (3001), @@ -22,6 +17,8 @@ services: restart: unless-stopped env_file: - .env + environment: + - HOSTNAME=0.0.0.0 volumes: # ── Write targets (runtime imports/uploads, persistent on the host) ── # The CMS writes imported furni/figures/pets/effects here (see @@ -81,4 +78,4 @@ services: # - "3030:3030" # restart: unless-stopped # volumes: - # - /var/www/Gamedata:/var/www/Gamedata + # - /var/www/Gamedata:/var/www/Gamedata \ No newline at end of file diff --git a/package.json b/package.json index da5aac77..12ff74af 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "engines": { - "node": ">=26.8.1 <27" + "node": ">=20.9.0" }, "packageManager": "pnpm@11.25.0+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956", "scripts": { diff --git a/scripts/docker-preflight.sh b/scripts/docker-preflight.sh new file mode 100755 index 00000000..d35e3e2a --- /dev/null +++ b/scripts/docker-preflight.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# ============================================================================== +# docker-preflight.sh — Verify a VPS is ready to run the Dockerized EpicNext-CMS. +# +# Lets any supplied .env drive the checks. Checks (all idempotent, none mutating): +# 1. Docker + compose available and usable. +# 2. Required runtime dirs exist (RW for UID 33 where the CMS writes). +# 3. Volume owners are UID/GID 33 (www-data) on the host. +# 4. .env exists and required host-network services are reachable. +# 5. Port 3002 free (or the host CMS that must be stopped). +# +# Usage: ./scripts/docker-preflight.sh [--fix] +# --fix attempts to auto-correct permission/owner issues (chown). +# +# Exit codes: 0 ready, 1 not ready (or fixed nothing). +# ============================================================================== + +set -uo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$DIR" || exit 1 + +FIX=0 +[ "${1:-}" = "--fix" ] && FIX=1 + +ENV_FILE="${ENV_FILE:-$DIR/.env}" +fail=0 +warn=0 + +# Ports that are expected to be reachable ON THE HOST (network_mode: host). +# These mirror the defaults in .env / the emulator stack; override via env. +CMS_PORT="${CMS_PORT:-3002}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +REDIS_PORT="${REDIS_PORT:-6379}" +RCON_PORT="${RCON_PORT:-3003}" +API_PORT="${API_PORT:-3001}" +IMAGER_PORT="${IMAGER_PORT:-8082}" + +# Relative dirs the CMS writes to, plus the absolute shared gamedata root. +RW_DIRS=( + "$DIR/public/nitro-assets" + "$DIR/public/swf" + "$DIR/storage" + "/var/www/Gamedata" +) + +say() { printf ' %s\n' "$*"; } +ok() { printf ' \033[32m[OK] \033[0m%s\n' "$*"; } +err() { printf ' \033[31m[FAIL]\033[0m %s\n' "$*"; fail=1; } +wrn() { printf ' \033[33m[WARN]\033[0m %s\n' "$*"; warn=1; } +doing(){ printf '\n\033[1m%s\033[0m\n' "$*"; } + +doing "1. Docker engine + compose" +if command -v docker >/dev/null 2>&1; then + ok "docker binary present" + if docker info >/dev/null 2>&1; then ok "daemon reachable"; else err "daemon NOT reachable (is it running / does this user have access?)"; fi +else + err "docker binary missing" +fi +if docker compose version >/dev/null 2>&1; then + ok "docker compose plugin present" +else + err "docker compose plugin missing (install docker-compose-plugin)" +fi + +doing "2. Runtime directories exist + RW for UID 33" +for d in "${RW_DIRS[@]}"; do + if [ ! -e "$d" ]; then + err "missing dir: $d" + if [ "$FIX" -eq 1 ]; then + mkdir -p "$d" && chown 33:33 "$d" && say " created + chown 33:33 $d" || err " could not create $d" + fi + continue + fi + if [ ! -d "$d" ]; then err "not a directory: $d"; continue; fi + # Test write as the target owner via a temp file drop (as root), then remove. + if [ "$(id -u)" -eq 0 ]; then + if touch "$d/.preflight-write-test" 2>/dev/null; then + rm -f "$d/.preflight-write-test" + ok "writable: $d" + else + err "not writable: $d (needs owner 33:33)" + [ "$FIX" -eq 1 ] && { chown -R 33:33 "$d" && say " chown -R 33:33 $d" || err " chown failed"; } + fi + else + if [ -w "$d" ]; then ok "writable: $d"; else err "not writable: $d"; fi + fi +done + +doing "3. Volume ownership (UID/GID 33 = www-data)" +for d in "${RW_DIRS[@]}"; do + [ -e "$d" ] || continue + owner="$(stat -c '%u:%g' "$d" 2>/dev/null || true)" + if [ "$owner" = "33:33" ]; then + ok "owner 33:33: $d" + else + err "owner ${owner:-unknown} (want 33:33): $d" + [ "$FIX" -eq 1 ] && { chown 33:33 "$d" && say " chown 33:33 $d" || err " chown failed"; } + fi +done + +doing "4. Configuration + required services (.env, host network)" +if [ -f "$ENV_FILE" ]; then + ok ".env present: $ENV_FILE" + ### shellcheck disable=SC1090 + set -a; . "$ENV_FILE"; set +a +else + err ".env missing: $ENV_FILE (copy your production env here)" +fi + +check_port() { # name port + if command -v nc >/dev/null 2>&1; then + if nc -z 127.0.0.1 "$2" >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi + else + if (echo >/dev/tcp/127.0.0.1/"$2") >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi + fi +} +check_port "MariaDB" "$MYSQL_PORT" +check_port "Redis" "$REDIS_PORT" +check_dep() { # name + if command -v "$1" >/dev/null 2>&1; then ok "host binary: $1"; else wrn "host binary not found: $1 (may still work via container)"; fi +} +check_dep git + +doing "5. Port 3002 state (host CMS clash)" +if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then + err "port $CMS_PORT already in use on host — stop the host-side CMS (pm2 stop next) before starting the container" +else + ok "port $CMS_PORT free" +fi + +printf '\n' +if [ "$fail" -eq 1 ]; then + printf '\033[31m=== NOT READY: %s issue(s) found%s ===\033[0m\n' "$fail" "$([ "$FIX" -eq 1 ] && echo ' after --fix' || echo ' (re-run with --fix to auto-correct)')" + exit 1 +fi +if [ "$warn" -eq 1 ]; then printf '\033[33m=== READY (with %s warning(s)) ===\033[0m\n' "$warn"; exit 0; fi +printf '\033[32m=== READY ===\033[0m\n' +exit 0 diff --git a/scripts/docker-update.sh b/scripts/docker-update.sh index 671ce9cf..9f714f0c 100755 --- a/scripts/docker-update.sh +++ b/scripts/docker-update.sh @@ -32,7 +32,13 @@ touch "$LOG_FILE" log "=== Start docker-update ===" -# --- 0. Guard: uncommitted changes would break git pull / taint deploys --- +# --- 0. Preflight: verify this VPS is ready (permissions, ports, deps) --- +if ! "$DIR/scripts/docker-preflight.sh"; then + die "preflight failed — fix issues first (see '--fix' flag)" 1 +fi +log "preflight OK" + +# --- 0b. Guard: uncommitted changes would break git pull / taint deploys --- if ! { git diff --quiet --exit-code && git diff --cached --quiet --exit-code; }; then die "working tree has uncommitted changes; commit or stash first" 1 fi @@ -55,15 +61,32 @@ else fi log "db:migrate OK" -# --- 3. Rebuild the image --- +# --- 3. Node major gate (patches auto, major upgrades need review) --- +# `node:alpine` floats within, then across, Node majors. Patches/minors are +# safe to apply silently; a NEW major (e.g. 26 -> 27) is a breaking risk for +# native addons / Next compatibility, so require an explicit review before it +# goes live. Compare the major of the deployed runtime image vs the floating +# tag; abort (not deploy) when they differ. +deployed_major="$(docker inspect --format '{{.Config.Image}}' epicnext-cms 2>/dev/null || true)" +# Resolve the currently-deployed Node major from its image. +if [ -n "$deployed_major" ] && docker image inspect "$deployed_major" >/dev/null 2>&1; then + deployed_major="$(docker run --rm --entrypoint sh "$deployed_major" -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)" +fi +float_major="$(docker run --rm --entrypoint sh node:alpine -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)" +if [ -n "$deployed_major" ] && [ -n "$float_major" ] && [ "$deployed_major" != "$float_major" ]; then + die "Node major change detected (deployed v$deployed_major, floating tag v$float_major). Major upgrades require review; update engines/Dockerfile deliberately first." 1 +fi +log "Node major gate OK (major=${float_major:-?})" + +# --- 4. Rebuild the image --- docker compose build >>"$LOG_FILE" 2>&1 || die "docker compose build failed" 2 log "docker compose build OK" -# --- 4. Recreate the container --- +# --- 5. Recreate the container --- docker compose up -d >>"$LOG_FILE" 2>&1 || die "docker compose up failed" 2 log "docker compose up OK" -# --- 5. Wait for health (up to ~4 min) --- +# --- 6. Wait for health (up to ~4 min) --- healthy=0 for i in $(seq 1 16); do status="$(docker inspect --format='{{.State.Health.Status}}' epicnext-cms 2>/dev/null || true)" @@ -82,7 +105,7 @@ else exit 3 fi -# --- 6. Make sure the stale host-side PM2 CMS stays stopped --- +# --- 7. Make sure the stale host-side PM2 CMS stays stopped --- if command -v pm2 >/dev/null 2>&1 && pm2 jlist >/dev/null 2>&1; then if pm2 list 2>/dev/null | grep -q "${PM2_APP}"; then pm2 stop "$PM2_APP" >/dev/null 2>&1 && log "pm2 '${PM2_APP}' kept stopped (avoids port 3002 clash)"