feat: allow containers to run with arbitrary non-root UIDs (#6481)

This commit is contained in:
Mir Arif Hasan
2026-07-14 16:04:57 +06:00
committed by GitHub
parent afc2cf181c
commit bfc00f899c
13 changed files with 312 additions and 43 deletions
+10
View File
@@ -44,3 +44,13 @@ VITE_PROXYSCOTCH_ACCESS_TOKEN=
# Set to `true` for subpath based access
ENABLE_SUBPATH_BASED_ACCESS=false
#-----------------------Container Runtime Config---------------------#
# Caddy's in-container HTTP port (default 80). Set a free port (e.g. 8000) to run
# under a non-root UID like OpenShift (ports < 1024 need root or CAP_NET_BIND_SERVICE),
# and update your published mapping too. Avoid internal ports 8080, 3200, 3000, 3100,
# 3170. Non-root runs expect GID 0. On the AIO image this applies only when
# ENABLE_SUBPATH_BASED_ACCESS=true; the legacy HOPP_AIO_ALTERNATE_PORT is honoured
# there as a fallback.
# HOPP_ALTERNATE_PORT=8000
+1 -1
View File
@@ -3,7 +3,7 @@
persist_config off
}
:{$HOPP_AIO_ALTERNATE_PORT:80} {
:{$HOPP_ALTERNATE_PORT:80} {
# Serve the `selfhost-web` SPA by default
root * /site/selfhost-web
file_server
+76 -9
View File
@@ -1,10 +1,70 @@
#!/usr/local/bin/node
// @ts-check
import { execSync, spawn } from "child_process"
import { execFileSync, spawn } from "child_process"
import fs from "fs"
import net from "net"
import os from "os"
import path from "path"
import process from "process"
// Probe the real bind so the kernel decides (CAP_NET_BIND_SERVICE, port sysctls),
// not a UID guess.
async function assertPortBindable(port) {
await new Promise((resolve) => {
const probe = net.createServer()
probe.once("error", (err) => {
if (err.code === "EACCES") {
console.error(`Cannot bind port ${port} as the current user: set HOPP_ALTERNATE_PORT to a free port >= 1024, or run as root / grant CAP_NET_BIND_SERVICE.`)
process.exit(1)
}
if (err.code === "EADDRINUSE") {
console.error(`Port ${port} is already in use inside the container: set HOPP_ALTERNATE_PORT to a free port.`)
process.exit(1)
}
console.warn(`Skipping bind preflight for port ${port} (${err.code})`)
resolve()
})
probe.listen(port, () => probe.close(resolve))
})
}
// Empty means unset (compose passes undefined vars as ""), so the :80 default applies.
if (process.env.HOPP_ALTERNATE_PORT === "") delete process.env.HOPP_ALTERNATE_PORT
if (process.env.HOPP_AIO_ALTERNATE_PORT === "") delete process.env.HOPP_AIO_ALTERNATE_PORT
// Back-compat: fall back to the legacy var when the new one is unset.
const legacyPortApplied = !process.env.HOPP_ALTERNATE_PORT && !!process.env.HOPP_AIO_ALTERNATE_PORT
if (legacyPortApplied) {
process.env.HOPP_ALTERNATE_PORT = process.env.HOPP_AIO_ALTERNATE_PORT
}
const useSubpathAccess = process.env.ENABLE_SUBPATH_BASED_ACCESS === "true"
// Sanity-check the value; real bindability is probed below.
const RESERVED_PORTS = ["8080", "3200"]
const altPort = process.env.HOPP_ALTERNATE_PORT
// Name whichever var the operator actually set.
const altPortVar = legacyPortApplied ? "HOPP_AIO_ALTERNATE_PORT" : "HOPP_ALTERNATE_PORT"
if (altPort !== undefined) {
if (!(/^[0-9]+$/.test(altPort) && +altPort >= 1 && +altPort <= 65535)) {
console.error(`${altPortVar}="${altPort}" is invalid: use an integer in 1-65535 (e.g. 8000).`)
process.exit(1)
}
if (RESERVED_PORTS.includes(String(+altPort))) {
console.error(`${altPortVar}="${altPort}" is already used by this image (${RESERVED_PORTS.join(", ")}); pick another port (e.g. 8000).`)
process.exit(1)
}
if (!useSubpathAccess) {
console.warn(`${altPortVar} has no effect in multiport mode (Caddy binds 3000/3100/3170); it applies only when ENABLE_SUBPATH_BASED_ACCESS=true.`)
}
}
// Only subpath mode binds the configurable port; multiport uses fixed ports.
if (useSubpathAccess) {
await assertPortBindable(+(process.env.HOPP_ALTERNATE_PORT ?? 80))
}
function runChildProcessWithPrefix(command, args, prefix) {
const childProcess = spawn(command, args);
@@ -44,30 +104,37 @@ const envFileContent = Object.entries(process.env)
}`)
.join("\n")
fs.writeFileSync("build.env", envFileContent)
// Write to a temp dir (not cwd) so a non-root UID needn't own the working directory.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hopp-env-"))
const buildEnvPath = path.join(tmpDir, "build.env")
execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`)
try {
fs.writeFileSync(buildEnvPath, envFileContent)
// Call the global binary directly (not npx, which needs a writable $HOME cache).
execFileSync("import-meta-env", ["-x", buildEnvPath, "-e", buildEnvPath, "-p", "/site/**/*"], { stdio: "inherit" })
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true })
}
fs.rmSync("build.env")
const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile'
const caddyFileName = useSubpathAccess ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile'
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
const backendProcess = runChildProcessWithPrefix("node", ["/dist/backend/dist/src/main.js"], "Backend Server")
const webappProcess = runChildProcessWithPrefix("webapp-server", [], "Webapp Server")
caddyProcess.on("exit", (code) => {
console.log(`Exiting process because Caddy Server exited with code ${code}`)
process.exit(code)
// code is null on signal death; report failure, not success.
process.exit(code ?? 1)
})
backendProcess.on("exit", (code) => {
console.log(`Exiting process because Backend Server exited with code ${code}`)
process.exit(code)
process.exit(code ?? 1)
})
webappProcess.on("exit", (code) => {
console.log(`Exiting process because Webapp Server exited with code ${code}`)
process.exit(code)
process.exit(code ?? 1)
})
process.on('SIGINT', () => {
+5 -5
View File
@@ -51,7 +51,7 @@ services:
hoppscotch-db:
condition: service_healthy
ports:
- "3180:80"
- "3180:${HOPP_ALTERNATE_PORT:-80}"
- "3170:3170"
# The main hoppscotch app with integrated webapp server. This will be hosted at port 3000
@@ -70,7 +70,7 @@ services:
depends_on:
- hoppscotch-backend
ports:
- "3080:80"
- "3080:${HOPP_ALTERNATE_PORT:-80}"
- "3000:3000"
- "3200:3200"
@@ -89,7 +89,7 @@ services:
depends_on:
- hoppscotch-backend
ports:
- "3280:80"
- "3280:${HOPP_ALTERNATE_PORT:-80}"
- "3100:3100"
# The service that spins up all services at once in one container
@@ -111,7 +111,7 @@ services:
- "3100:3100"
- "3170:3170"
- "3200:3200"
- "3080:80"
- "3080:${HOPP_ALTERNATE_PORT:-80}"
# Profile with no database dependency (purely developmental)
hoppscotch-aio-no-db:
@@ -129,7 +129,7 @@ services:
- "3100:3100"
- "3170:3170"
- "3200:3200"
- "3080:80"
- "3080:${HOPP_ALTERNATE_PORT:-80}"
# The preset DB service, you can delete/comment the below lines if
# you are using an external postgres instance
+1 -1
View File
@@ -17,7 +17,7 @@ if [ "$UPTIME" -lt 15 ]; then
fi
if [ "$ENABLE_SUBPATH_BASED_ACCESS" = "true" ]; then
curlCheck "http://localhost:${HOPP_AIO_ALTERNATE_PORT:-80}/backend/ping" || exit 1
curlCheck "http://localhost:${HOPP_ALTERNATE_PORT:-${HOPP_AIO_ALTERNATE_PORT:-80}}/backend/ping" || exit 1
else
curlCheck "http://localhost:3000" || exit 1
curlCheck "http://localhost:3100" || exit 1
@@ -3,7 +3,7 @@
persist_config off
}
:80 :3170 {
:{$HOPP_ALTERNATE_PORT:80} :3170 {
@mock {
header_regexp host Host ^[^.]+\.mock\..*$
}
+45 -2
View File
@@ -2,8 +2,50 @@
// @ts-check
import { spawn } from 'child_process';
import net from 'net';
import process from 'process';
// Probe the real bind so the kernel decides (CAP_NET_BIND_SERVICE, port sysctls),
// not a UID guess.
async function assertPortBindable(port) {
await new Promise((resolve) => {
const probe = net.createServer();
probe.once('error', (err) => {
if (err.code === 'EACCES') {
console.error(`Cannot bind port ${port} as the current user: set HOPP_ALTERNATE_PORT to a free port >= 1024, or run as root / grant CAP_NET_BIND_SERVICE.`);
process.exit(1);
}
if (err.code === 'EADDRINUSE') {
console.error(`Port ${port} is already in use inside the container: set HOPP_ALTERNATE_PORT to a free port.`);
process.exit(1);
}
console.warn(`Skipping bind preflight for port ${port} (${err.code})`);
resolve();
});
probe.listen(port, () => probe.close(resolve));
});
}
// Empty means unset (compose passes undefined vars as ""), so the :80 default applies.
if (process.env.HOPP_ALTERNATE_PORT === '') delete process.env.HOPP_ALTERNATE_PORT;
// Sanity-check the value; real bindability is probed below.
const RESERVED_PORTS = ['3170', '8080'];
const altPort = process.env.HOPP_ALTERNATE_PORT;
if (altPort !== undefined) {
if (!(/^[0-9]+$/.test(altPort) && +altPort >= 1 && +altPort <= 65535)) {
console.error(`HOPP_ALTERNATE_PORT="${altPort}" is invalid: use an integer in 1-65535 (e.g. 8000).`);
process.exit(1);
}
if (RESERVED_PORTS.includes(String(+altPort))) {
console.error(`HOPP_ALTERNATE_PORT="${altPort}" is already used by this image (${RESERVED_PORTS.join(', ')}); pick another port (e.g. 8000).`);
process.exit(1);
}
}
// Caddy always binds this port (env value or the :80 default).
await assertPortBindable(+(process.env.HOPP_ALTERNATE_PORT ?? 80));
function runChildProcessWithPrefix(command, args, prefix) {
const childProcess = spawn(command, args);
@@ -46,14 +88,15 @@ const backendProcess = runChildProcessWithPrefix(
caddyProcess.on('exit', (code) => {
console.log(`Exiting process because Caddy Server exited with code ${code}`);
process.exit(code);
// code is null on signal death; report failure, not success.
process.exit(code ?? 1);
});
backendProcess.on('exit', (code) => {
console.log(
`Exiting process because Backend Server exited with code ${code}`,
);
process.exit(code);
process.exit(code ?? 1);
});
process.on('SIGINT', () => {
+56 -5
View File
@@ -1,6 +1,51 @@
#!/usr/local/bin/node
import { execSync } from "child_process"
import { execFileSync } from "child_process"
import fs from "fs"
import net from "net"
import os from "os"
import path from "path"
// Probe the real bind so the kernel decides (CAP_NET_BIND_SERVICE, port sysctls),
// not a UID guess.
async function assertPortBindable(port) {
await new Promise((resolve) => {
const probe = net.createServer()
probe.once("error", (err) => {
if (err.code === "EACCES") {
console.error(`Cannot bind port ${port} as the current user: set HOPP_ALTERNATE_PORT to a free port >= 1024, or run as root / grant CAP_NET_BIND_SERVICE.`)
process.exit(1)
}
if (err.code === "EADDRINUSE") {
console.error(`Port ${port} is already in use inside the container: set HOPP_ALTERNATE_PORT to a free port.`)
process.exit(1)
}
console.warn(`Skipping bind preflight for port ${port} (${err.code})`)
resolve()
})
probe.listen(port, () => probe.close(resolve))
})
}
// Empty means unset (compose passes undefined vars as ""), so the :80 default
// applies. The image CMD also unsets an empty value before launching Caddy.
if (process.env.HOPP_ALTERNATE_PORT === "") delete process.env.HOPP_ALTERNATE_PORT
// Sanity-check the value; real bindability is probed below.
const RESERVED_PORTS = ["3000", "3200"]
const altPort = process.env.HOPP_ALTERNATE_PORT
if (altPort !== undefined) {
if (!(/^[0-9]+$/.test(altPort) && +altPort >= 1 && +altPort <= 65535)) {
console.error(`HOPP_ALTERNATE_PORT="${altPort}" is invalid: use an integer in 1-65535 (e.g. 8000).`)
process.exit(1)
}
if (RESERVED_PORTS.includes(String(+altPort))) {
console.error(`HOPP_ALTERNATE_PORT="${altPort}" is already used by this image (${RESERVED_PORTS.join(", ")}); pick another port (e.g. 8000).`)
process.exit(1)
}
}
// Caddy always binds this port (env value or the :80 default).
await assertPortBindable(+(process.env.HOPP_ALTERNATE_PORT ?? 80))
const envFileContent = Object.entries(process.env)
.filter(([env]) => env.startsWith("VITE_"))
@@ -11,8 +56,14 @@ const envFileContent = Object.entries(process.env)
)
.join("\n")
fs.writeFileSync("build.env", envFileContent)
// Write to a temp dir (not cwd) so a non-root UID needn't own the working directory.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hopp-env-"))
const buildEnvPath = path.join(tmpDir, "build.env")
execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`)
fs.rmSync("build.env")
try {
fs.writeFileSync(buildEnvPath, envFileContent)
// Call the global binary directly (not npx, which needs a writable $HOME cache).
execFileSync("import-meta-env", ["-x", buildEnvPath, "-e", buildEnvPath, "-p", "/site/**/*"], { stdio: "inherit" })
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true })
}
@@ -3,7 +3,7 @@
persist_config off
}
:80 :3000 {
:{$HOPP_ALTERNATE_PORT:80} :3000 {
try_files {path} /
root * /site/selfhost-web
file_server
+57 -6
View File
@@ -1,8 +1,52 @@
#!/usr/local/bin/node
import { execSync, spawn } from 'child_process';
import { execFileSync, spawn } from 'child_process';
import fs from 'fs';
import net from 'net';
import os from 'os';
import path from 'path';
import process from 'process';
// Probe the real bind so the kernel decides (CAP_NET_BIND_SERVICE, port sysctls),
// not a UID guess.
async function assertPortBindable(port) {
await new Promise((resolve) => {
const probe = net.createServer();
probe.once('error', (err) => {
if (err.code === 'EACCES') {
console.error(`Cannot bind port ${port} as the current user: set HOPP_ALTERNATE_PORT to a free port >= 1024, or run as root / grant CAP_NET_BIND_SERVICE.`);
process.exit(1);
}
if (err.code === 'EADDRINUSE') {
console.error(`Port ${port} is already in use inside the container: set HOPP_ALTERNATE_PORT to a free port.`);
process.exit(1);
}
console.warn(`Skipping bind preflight for port ${port} (${err.code})`);
resolve();
});
probe.listen(port, () => probe.close(resolve));
});
}
// Empty means unset (compose passes undefined vars as ""), so the :80 default applies.
if (process.env.HOPP_ALTERNATE_PORT === '') delete process.env.HOPP_ALTERNATE_PORT;
// Sanity-check the value; real bindability is probed below.
const RESERVED_PORTS = ['3100'];
const altPort = process.env.HOPP_ALTERNATE_PORT;
if (altPort !== undefined) {
if (!(/^[0-9]+$/.test(altPort) && +altPort >= 1 && +altPort <= 65535)) {
console.error(`HOPP_ALTERNATE_PORT="${altPort}" is invalid: use an integer in 1-65535 (e.g. 8000).`);
process.exit(1);
}
if (RESERVED_PORTS.includes(String(+altPort))) {
console.error(`HOPP_ALTERNATE_PORT="${altPort}" is already used by this image (${RESERVED_PORTS.join(', ')}); pick another port (e.g. 8000).`);
process.exit(1);
}
}
// Caddy always binds this port (env value or the :80 default).
await assertPortBindable(+(process.env.HOPP_ALTERNATE_PORT ?? 80));
function runChildProcessWithPrefix(command, args, prefix) {
const childProcess = spawn(command, args);
@@ -41,11 +85,17 @@ const envFileContent = Object.entries(process.env)
)
.join('\n');
fs.writeFileSync('build.env', envFileContent);
// Write to a temp dir (not cwd) so a non-root UID needn't own the working directory.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hopp-env-'));
const buildEnvPath = path.join(tmpDir, 'build.env');
execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`);
fs.rmSync('build.env');
try {
fs.writeFileSync(buildEnvPath, envFileContent);
// Call the global binary directly (not npx, which needs a writable $HOME cache).
execFileSync('import-meta-env', ['-x', buildEnvPath, '-e', buildEnvPath, '-p', '/site/**/*'], { stdio: 'inherit' });
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
const caddyFileName =
process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true'
@@ -59,7 +109,8 @@ const caddyProcess = runChildProcessWithPrefix(
caddyProcess.on('exit', (code) => {
console.log(`Exiting process because Caddy Server exited with code ${code}`);
process.exit(code);
// code is null on signal death; report failure, not success.
process.exit(code ?? 1);
});
process.on('SIGINT', () => {
@@ -3,7 +3,7 @@
persist_config off
}
:80 :3100 {
:{$HOPP_ALTERNATE_PORT:80} :3100 {
try_files {path} /
root * /site/sh-admin-multiport-setup
file_server
@@ -3,7 +3,7 @@
persist_config off
}
:80 :3100 {
:{$HOPP_ALTERNATE_PORT:80} :3100 {
handle_path /admin* {
root * /site/sh-admin-subpath-access
file_server
+57 -10
View File
@@ -155,6 +155,10 @@ COPY --from=base_builder /usr/src/app/packages/hoppscotch-backend/prod_run.mjs /
ENV PRODUCTION="true"
ENV PORT=8080
# Writable Caddy storage for non-root UIDs (no writable $HOME needed).
ENV XDG_DATA_HOME=/tmp
ENV XDG_CONFIG_HOME=/tmp
WORKDIR /dist/backend
CMD ["node", "prod_run.mjs"]
@@ -166,6 +170,10 @@ EXPOSE 3170
FROM base_builder AS fe_builder
WORKDIR /usr/src/app/packages/hoppscotch-selfhost-web
RUN pnpm run generate
# Group-writable (GID 0) so a non-root UID (OpenShift runs as GID 0) can rewrite
# these files during env injection. Done here (not in the runtime stage) so the
# perms travel with the COPY instead of duplicating the layer with a chmod there.
RUN chmod -R g=rwX /usr/src/app/packages/hoppscotch-selfhost-web/dist
@@ -178,11 +186,25 @@ COPY --from=webapp_server_builder /usr/src/app/packages/hoppscotch-selfhost-web/
COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/prod_run.mjs /site/prod_run.mjs
COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/selfhost-web.Caddyfile /etc/caddy/selfhost-web.Caddyfile
COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist/ /site/selfhost-web
COPY --chown=root:0 --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist/ /site/selfhost-web
# Writable Caddy storage for non-root UIDs (no writable $HOME needed).
ENV XDG_DATA_HOME=/tmp
ENV XDG_CONFIG_HOME=/tmp
# Files keep g=rwX from the builder; only the COPY-created dirs need it. List the
# dirs (not /site/*) so the layer stays metadata-only and prod_run.mjs stays 755.
RUN chmod g=rwX /site /site/selfhost-web
# Pre-create /data group-writable so webapp-server's signing key persists across
# restarts under a non-root UID (else it's regenerated and logged each start).
RUN mkdir -p /data/webapp-server && chmod g=rwX /data /data/webapp-server
WORKDIR /site
# Run both webapp-server and Caddy after env processing (NOTE: env processing is required by both)
CMD ["/bin/sh", "-c", "node /site/prod_run.mjs && (webapp-server & caddy run --config /etc/caddy/selfhost-web.Caddyfile --adapter caddyfile)"]
# An empty HOPP_ALTERNATE_PORT (compose passthrough of an undefined var) means unset,
# so the Caddyfile default (:80) applies.
CMD ["/bin/sh", "-c", "[ -n \"$HOPP_ALTERNATE_PORT\" ] || unset HOPP_ALTERNATE_PORT; node /site/prod_run.mjs && (webapp-server & caddy run --config /etc/caddy/selfhost-web.Caddyfile --adapter caddyfile)"]
EXPOSE 80
EXPOSE 3000
@@ -195,6 +217,10 @@ WORKDIR /usr/src/app/packages/hoppscotch-sh-admin
# Generate two builds for `sh-admin`, one based on subpath-access and the regular build
RUN pnpm run build --outDir dist-multiport-setup
RUN pnpm run build --outDir dist-subpath-access --base /admin/
# Group-writable (GID 0) so a non-root UID (OpenShift runs as GID 0) can rewrite
# these files during env injection. Done here (not in the runtime stage) so the
# perms travel with the COPY instead of duplicating the layer with a chmod there.
RUN chmod -R g=rwX dist-multiport-setup dist-subpath-access
FROM node_base AS sh_admin
@@ -204,8 +230,16 @@ COPY --from=caddy_builder /tmp/caddy-build/cmd/caddy/caddy /usr/bin/caddy
COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/prod_run.mjs /site/prod_run.mjs
COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/sh-admin-multiport-setup.Caddyfile /etc/caddy/sh-admin-multiport-setup.Caddyfile
COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/sh-admin-subpath-access.Caddyfile /etc/caddy/sh-admin-subpath-access.Caddyfile
COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-multiport-setup /site/sh-admin-multiport-setup
COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-subpath-access /site/sh-admin-subpath-access
COPY --chown=root:0 --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-multiport-setup /site/sh-admin-multiport-setup
COPY --chown=root:0 --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-subpath-access /site/sh-admin-subpath-access
# Writable Caddy storage for non-root UIDs (no writable $HOME needed).
ENV XDG_DATA_HOME=/tmp
ENV XDG_CONFIG_HOME=/tmp
# Files keep g=rwX from the builder; only the COPY-created dirs need it. List the
# dirs (not /site/*) so the layer stays metadata-only and prod_run.mjs stays 755.
RUN chmod g=rwX /site /site/sh-admin-multiport-setup /site/sh-admin-subpath-access
WORKDIR /site
CMD ["node","/site/prod_run.mjs"]
@@ -236,17 +270,27 @@ COPY --from=base_builder /usr/src/app/packages/hoppscotch-backend/prod_run.mjs /
# Static Server
COPY --from=webapp_server_builder /usr/src/app/packages/hoppscotch-selfhost-web/webapp-server/webapp-server /usr/local/bin/
RUN mkdir -p /site/selfhost-web
COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist /site/selfhost-web
COPY --chown=root:0 --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist /site/selfhost-web
# FE Files
COPY --from=base_builder /usr/src/app/aio_run.mjs /usr/src/app/aio_run.mjs
COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist /site/selfhost-web
COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-multiport-setup /site/sh-admin-multiport-setup
COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-subpath-access /site/sh-admin-subpath-access
COPY --chown=root:0 --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-multiport-setup /site/sh-admin-multiport-setup
COPY --chown=root:0 --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-subpath-access /site/sh-admin-subpath-access
COPY aio-multiport-setup.Caddyfile /etc/caddy/aio-multiport-setup.Caddyfile
COPY aio-subpath-access.Caddyfile /etc/caddy/aio-subpath-access.Caddyfile
# Writable Caddy storage for non-root UIDs (no writable $HOME needed).
ENV XDG_DATA_HOME=/tmp
ENV XDG_CONFIG_HOME=/tmp
# Files keep g=rwX from the builders; only the COPY-created dirs need it. List the
# dirs (not /site/*) so the layer stays metadata-only.
RUN chmod g=rwX /site /site/selfhost-web /site/sh-admin-multiport-setup /site/sh-admin-subpath-access
# Pre-create /data group-writable so webapp-server's signing key persists across
# restarts under a non-root UID (else it's regenerated and logged each start).
RUN mkdir -p /data/webapp-server && chmod g=rwX /data /data/webapp-server
ENTRYPOINT [ "tini", "--" ]
COPY --chmod=755 healthcheck.sh /
HEALTHCHECK --interval=2s --start-period=15s CMD /bin/sh /healthcheck.sh
@@ -254,7 +298,10 @@ HEALTHCHECK --interval=2s --start-period=15s CMD /bin/sh /healthcheck.sh
WORKDIR /dist/backend
CMD ["node", "/usr/src/app/aio_run.mjs"]
# NOTE: Although these ports are exposed, the HOPP_ALTERNATE_AIO_PORT variable can be used to assign a user-specified port
# NOTE: In subpath mode (ENABLE_SUBPATH_BASED_ACCESS=true) HOPP_ALTERNATE_PORT sets
# Caddy's HTTP port (default 80). In multiport mode (default) Caddy uses the
# fixed ports 3000/3100/3170 and it has no effect. Legacy
# HOPP_AIO_ALTERNATE_PORT is still honoured.
EXPOSE 3170
EXPOSE 3000
EXPOSE 3100