build(docker): keep keys out of the image and run as non-root by default

The build context excluded only .env, so .env.local and the like or
pinokio/ENVIRONMENT would be baked into image layers; exclude every dotenv
file except .env.example, plus local state and QA output. Pin the base to
CI's Node 24.14.0 by digest, default to the image's node user, and correct
the HOST comment. Log the trusted peers as the operator's when set. README
notes that MCP and ChatGPT sign-in voice are unavailable under Docker, and
how to trust no gateway when widening the publish.
This commit is contained in:
Sameh Khamis committed 2026-10-15 13:07:00 -07:00
1 parent a4e2325ded
commit 3f6fe69c57
5 files changed
+49 -17

No files matched your search

+17 -2
View File
@@ -1,9 +1,24 @@
node_modules # Keep keys and local state out of the build context: compose bind-mounts the
# checkout at run time, so the image never needs them.
.git .git
node_modules
dist
.env .env
.env.*
!.env.example
**/ENVIRONMENT
pinokio/ENVIRONMENT
pinokio/.installed
.gev-cache .gev-cache
.gev-logs .gev-logs
dist qa-artifacts
qa-shots
screenshots
output
3d-models
.gstack
*.log
.DS_Store
# AppleDouble sidecars from a macOS `tar` copy; `npm test` would otherwise # AppleDouble sidecars from a macOS `tar` copy; `npm test` would otherwise
# collect `._*.test.mjs` as test files. # collect `._*.test.mjs` as test files.
._* ._*
+4 -3
View File
@@ -235,9 +235,10 @@
[TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling [TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling
from #653. from #653.
- **Docker install path.** `docker compose up` builds the pinned Node image and - **Docker install path.** `docker compose up` builds on CI's Node 24.14.0 image,
serves the app on `http://localhost:4173` with no local Node install. The repo pinned by digest, and serves the app on `http://localhost:4173` with no local
is bind-mounted and the container runs as the host user, so `.env`, Node install. The repo is bind-mounted and the container runs as the host user
(never root), so `.env`,
`.gev-cache`, and `.gev-logs` persist on the host exactly as they do under `.gev-cache`, and `.gev-logs` persist on the host exactly as they do under
`npm run dev` — Provider Settings included. The launcher `npm run dev` — Provider Settings included. The launcher
(`scripts/docker-start.mjs`) trusts the container's gateway address for that (`scripts/docker-start.mjs`) trusts the container's gateway address for that
+16 -11
View File
@@ -3,11 +3,11 @@
# The dev server is the whole app: Vite serves the client AND runs the API # The dev server is the whole app: Vite serves the client AND runs the API
# proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there # proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there
# is nothing else to containerise. See compose.yaml for the usual entry point. # is nothing else to containerise. See compose.yaml for the usual entry point.
FROM node:24-slim # Same Node as CI (package.json engines, .github/workflows), pinned by digest.
FROM node:24.14.0-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8
# Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download. # Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download.
ENV PUPPETEER_SKIP_DOWNLOAD=1 ENV PUPPETEER_SKIP_DOWNLOAD=1
WORKDIR /app
# `npm test` builds fixtures with `git init`, so the suite cannot run in a # `npm test` builds fixtures with `git init`, so the suite cannot run in a
# container without it (71 failures on node:24-slim, all ENOENT on git). # container without it (71 failures on node:24-slim, all ENOENT on git).
@@ -18,19 +18,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends git && rm -
# git. It is the user's own checkout, so trust that one path. # git. It is the user's own checkout, so trust that one path.
RUN git config --system --add safe.directory /app RUN git config --system --add safe.directory /app
COPY package.json package-lock.json ./ # Never run as root. The image's own user is `node` (1000); compose overrides
# it with the host user so bind-mounted files stay yours.
WORKDIR /app
RUN chown node:node /app
USER node
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci RUN npm ci
# compose runs the container as the host user so bind-mounted files stay # node_modules belongs to `node`, but compose may run as another UID. Vite only
# yours, but node_modules is baked in as root. Vite only needs to write its # needs to write its dep-optimizer cache, so open up those two directories
# dep-optimizer cache, so open up those two directories rather than chowning # rather than chowning a few hundred MB to a UID we cannot know at build time.
# a few hundred MB to a UID we cannot know at build time.
RUN mkdir -p node_modules/.vite && chmod 777 node_modules node_modules/.vite RUN mkdir -p node_modules/.vite && chmod 777 node_modules node_modules/.vite
COPY . . COPY --chown=node:node . .
# Bind to all interfaces, otherwise the published port hits nothing. Vite also # Bind to all interfaces, otherwise the published port hits nothing. This does
# relaxes allowedHosts when HOST is 0.0.0.0, which is what lets the browser # not widen the Host allowlist: IP addresses and localhost are accepted, other
# reach it as "localhost". # names only via GEV_ALLOWED_HOSTS (see build/allowedHosts.js).
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=4173 ENV PORT=4173
EXPOSE 4173 EXPOSE 4173
+4
View File
@@ -171,8 +171,12 @@ Open **`http://localhost:4173`** as above. Everything the app saves stays in the
Back up that folder and you've backed up everything; delete `.env` to forget your keys. Source edits still hot-reload. The port is published to `127.0.0.1` only, so the default matches the native one. Back up that folder and you've backed up everything; delete `.env` to forget your keys. Source edits still hot-reload. The port is published to `127.0.0.1` only, so the default matches the native one.
The local MCP server and ChatGPT OAuth voice are not available under Docker; use an OpenAI API key for voice.
Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, and it hands out **Provider Settings** too: the launcher trusts the container gateway as this machine, and a wider publish delivers everyone else from that same address. For an instance you mean to share, put the keys in `.env` by hand instead. [SECURITY.md](SECURITY.md) has the detail. Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, and it hands out **Provider Settings** too: the launcher trusts the container gateway as this machine, and a wider publish delivers everyone else from that same address. For an instance you mean to share, put the keys in `.env` by hand instead. [SECURITY.md](SECURITY.md) has the detail.
When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address.
<details> <details>
<summary>Running on Linux, a NAS, or behind a reverse proxy</summary> <summary>Running on Linux, a NAS, or behind a reverse proxy</summary>
+8 -1
View File
@@ -105,12 +105,19 @@ async function start() {
); );
} }
const fromOperator = Boolean(
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
);
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() }); const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
if (trustedPeers) { if (trustedPeers) {
// Set before Vite is imported: the dev server snapshots process.env during // Set before Vite is imported: the dev server snapshots process.env during
// configuration, and the admission gate reads it per request. // configuration, and the admission gate reads it per request.
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers; process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
console.log(`[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`); console.log(
fromOperator
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
);
} else { } else {
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.'); console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
} }