Extract API, migrations and portable NAS deployment

This commit is contained in:
tomchukk committed 2026-09-07 19:34:49 +03:00
commit 673255fa4f
57 files changed
+3799

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
# Copy this file to the project root as .env.production on the NAS.
# Keep the copied file private and never commit it.
# Synology MariaDB 10 package. Confirm its TCP port in the package settings;
# 3307 is the common Synology default.
# NAS end of the dedicated Compose bridge. Container localhost is not the NAS.
# If you change the Compose subnet, update this address and the SQL host grant.
DB_HOST=172.29.240.1
DB_PORT=3307
DB_NAME=model_delivery
DB_USER=model_app
DB_PASSWORD=REPLACE_WITH_A_UNIQUE_DATABASE_PASSWORD
# API loopback is shared with the gateway via network_mode: service:gateway.
# Keep these values; Web Station selects gateway container port 38080.
API_HOST=127.0.0.1
API_PORT=3001
LOG_LEVEL=info
# Replace both origins with the exact HTTPS addresses configured in DSM.
# The second origin is the LAN-only administration portal.
COOKIE_SECURE=true
APP_ORIGINS=https://deliveries.example.com,https://deliveries-admin.example.com
# Generate once and preserve for the life of this database. Losing or changing
# it makes saved SSO, directory credentials and provider tokens unreadable.
TOKEN_ENCRYPTION_KEY=REPLACE_WITH_32_RANDOM_BYTES_IN_BASE64
# One-time code used to create the single recovery administrator account.
LOCAL_ADMIN_SETUP_TOKEN=REPLACE_WITH_A_LONG_RANDOM_SETUP_CODE
# These can remain blank and be entered through the LAN-only /local-admin UI.
OIDC_ISSUER=
OIDC_DISCOVERY_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=https://deliveries.example.com/api/auth/callback
OIDC_SCOPES=openid groups
OIDC_ALLOW_HTTP=false
+18
View File
@@ -0,0 +1,18 @@
# Build and stage on a trusted workstation first: pnpm image:stage ../delivery-app-web
# The context contains production artifacts only; Git credentials never enter the image.
FROM node:22.16.0-bookworm-slim AS standalone
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx-light tini \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production COOKIE_SECURE=true
WORKDIR /app
COPY --chown=node:node .image/api/ /app/
COPY .image/web/ /usr/share/nginx/html/
COPY infra/synology/nginx.conf /etc/nginx/model-delivery.conf
COPY infra/synology/nginx-standalone.conf /etc/nginx/nginx.conf
COPY infra/synology/start-standalone.mjs /app/start-standalone.mjs
USER node
EXPOSE 38080 38081
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:38080/api/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
ENTRYPOINT ["/usr/bin/tini", "--", "node", "/app/start-standalone.mjs"]
@@ -0,0 +1,14 @@
-- Run as the MariaDB administrator after replacing the password placeholders.
-- The application account owns only this database and receives no global grants.
-- 172.29.240.2 is the static container address from both Compose files.
-- Update this host if you change the bridge subnet. Do not use a wildcard host.
CREATE DATABASE IF NOT EXISTS model_delivery
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'model_app'@'172.29.240.2'
IDENTIFIED BY 'REPLACE_WITH_A_UNIQUE_DATABASE_PASSWORD';
ALTER USER 'model_app'@'172.29.240.2'
IDENTIFIED BY 'REPLACE_WITH_A_UNIQUE_DATABASE_PASSWORD';
GRANT ALL PRIVILEGES ON model_delivery.* TO 'model_app'@'172.29.240.2';
+17
View File
@@ -0,0 +1,17 @@
worker_processes auto;
pid /tmp/model-nginx.pid;
error_log /dev/stderr warn;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# OIDC callback queries must not appear in access logs.
access_log off;
sendfile on;
client_body_temp_path /tmp/model-client;
proxy_temp_path /tmp/model-proxy;
fastcgi_temp_path /tmp/model-fastcgi;
uwsgi_temp_path /tmp/model-uwsgi;
scgi_temp_path /tmp/model-scgi;
include /etc/nginx/model-delivery.conf;
}
+111
View File
@@ -0,0 +1,111 @@
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types application/javascript application/json application/manifest+json image/svg+xml text/css text/plain;
upstream model_delivery_api {
server 127.0.0.1:3001;
keepalive 16;
}
# Public application listener. Local administration is deliberately unavailable.
server {
listen 38080;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 64k;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy same-origin always;
add_header X-Frame-Options DENY always;
location = /local-admin { return 404; }
location ^~ /local-admin/ { return 404; }
location = /api/local-admin { return 404; }
location ^~ /api/local-admin/ { return 404; }
location /api/ {
proxy_pass http://model_delivery_api/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_connect_timeout 5s;
proxy_read_timeout 20s;
add_header Cache-Control "no-store" always;
}
location /assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /sw.js {
try_files $uri =404;
add_header Cache-Control "no-cache";
}
location = /manifest.webmanifest {
try_files $uri =404;
default_type application/manifest+json;
add_header Cache-Control "no-cache";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
# Administration listener. Publish this through a separate DSM reverse-proxy
# rule restricted to the LAN; never expose or port-forward it to the internet.
server {
listen 38081;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 64k;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy same-origin always;
add_header X-Frame-Options DENY always;
location /api/ {
proxy_pass http://model_delivery_api/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_connect_timeout 5s;
proxy_read_timeout 20s;
add_header Cache-Control "no-store" always;
}
location /assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /sw.js {
try_files $uri =404;
add_header Cache-Control "no-cache";
}
location = /manifest.webmanifest {
try_files $uri =404;
default_type application/manifest+json;
add_header Cache-Control "no-cache";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
+27
View File
@@ -0,0 +1,27 @@
# Enter these variables in Container Manager when creating the container.
# Use the NAS LAN IP (or a reachable bridge gateway), never container localhost.
DB_HOST=10.0.0.200
DB_PORT=3306
DB_NAME=model_delivery
DB_USER=model_app
DB_PASSWORD=REPLACE_WITH_DATABASE_PASSWORD
# Your final Web Station HTTPS origin; no subdirectory.
APP_URL=https://deliveries.example.com
# Separate HTTPS admin origin, restricted to your LAN, targeting port 38081.
ADMIN_URL=https://deliveries-admin.example.com
COOKIE_SECURE=true
# Generate two independent secrets. Preserve the encryption key with DB backups.
TOKEN_ENCRYPTION_KEY=REPLACE_WITH_32_RANDOM_BYTES_IN_BASE64
LOCAL_ADMIN_SETUP_TOKEN=REPLACE_WITH_RANDOM_SETUP_CODE
# Optional: configure SSO here, or through the guarded administration page.
OIDC_ISSUER=
OIDC_DISCOVERY_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_SCOPES=openid groups
# OIDC_REDIRECT_URI defaults to APP_URL + /api/auth/callback.
# APP_ORIGINS defaults to APP_URL and ADMIN_URL. Both can be overridden explicitly.
# Stored SSO settings in MariaDB take precedence over initial OIDC_* variables.
+71
View File
@@ -0,0 +1,71 @@
import { spawn } from 'node:child_process';
const env = { ...process.env, API_HOST: '127.0.0.1', API_PORT: '3001' };
function origin(name) {
const value = env[name];
if (!value) return undefined;
const url = new URL(value);
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
throw new Error(`${name} must be an HTTP(S) origin without a path`);
}
if (env.COOKIE_SECURE !== 'false' && url.protocol !== 'https:') throw new Error(`${name} requires HTTPS with secure cookies`);
return url.origin;
}
const children = new Set();
let stopping = false;
let exitCode = 0;
let killTimer;
function stop(code) {
if (stopping) return;
stopping = true;
exitCode = code;
process.exitCode = code;
for (const child of children) child.kill('SIGTERM');
if (children.size) {
killTimer = setTimeout(() => { for (const child of children) child.kill('SIGKILL'); }, 10000);
killTimer.unref();
}
}
process.on('SIGTERM', () => stop(0));
process.on('SIGINT', () => stop(0));
function run(command, args, service = false) {
return new Promise((resolve, reject) => {
if (stopping) { resolve(); return; }
const child = spawn(command, args, { env, stdio: 'inherit' });
children.add(child);
child.once('error', () => { stop(1); reject(new Error(`Could not start ${command}`)); });
child.once('close', code => {
children.delete(child);
if (service && !stopping) {
console.error(`${command} exited unexpectedly; stopping the container`);
stop(1);
}
if (!children.size && killTimer) clearTimeout(killTimer);
if (!stopping && code !== 0) reject(new Error(`${command} failed with exit code ${code}`));
else resolve();
});
});
}
try {
const appUrl = origin('APP_URL');
const adminUrl = origin('ADMIN_URL');
if (!env.APP_ORIGINS && !appUrl) throw new Error('APP_URL (or APP_ORIGINS) is required');
env.APP_ORIGINS ||= [...new Set([appUrl, adminUrl].filter(Boolean))].join(',');
if (appUrl) env.OIDC_REDIRECT_URI ||= `${appUrl}/api/auth/callback`;
if (!env.DB_HOST || !env.DB_PASSWORD) throw new Error('DB_HOST and DB_PASSWORD are required');
if (!env.TOKEN_ENCRYPTION_KEY || Buffer.from(env.TOKEN_ENCRYPTION_KEY, 'base64').length !== 32) throw new Error('TOKEN_ENCRYPTION_KEY must contain 32 random bytes encoded as base64');
await run('node', ['dist/migrate.js']);
await run('node', ['dist/seed.js']);
if (!stopping) await Promise.all([
run('node', ['dist/server.js'], true),
run('nginx', ['-g', 'daemon off;'], true),
]);
} catch (error) {
// Configuration values, passwords, and tokens are never printed here.
console.error(error instanceof Error ? error.message : 'Container startup failed');
stop(1);
}
process.exitCode = exitCode;