feat: harden the container image and helm chart - #453
Draft
timothee-bn wants to merge 1 commit into
Draft
Conversation
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Changes: rootless, split Docker image + hardened Helm chart
Context: the previous production image bundled the Next.js app, PostgreSQL, tusd and
nginx into a single container, running as root by default. That's incompatible with
Kubernetes clusters enforcing Pod Security Admission
restrictedand ships far morepackages (and CVEs) than the app itself needs. These changes split the image into
per-service containers, make the app rootless by default, and bring the Helm chart in
line with the new topology.
Docker image (
docker/dockerfile/Dockerfile)are no longer compiled/installed into the app image; they run as separate containers
(see Compose/Helm sections below). Removes ~300 unrelated packages and their CVEs
from the app image's attack surface.
output: "standalone".builderinstalls depsand runs
pnpm run build;runtimeonly copies the traced standalone output(
server.js+ prunednode_modules),.next/static,public, and the handful ofconfig/source files the app reads from disk at runtime (
next.config.ts,portabase.config.ts,drizzle.config.ts,src/dbfor migrations). The final imagecarries neither the full
node_modulestree nor pnpm/npm.USER 1001:1001, noaddgroup/adduserneeded sincenothing in this image does
getpwuid()lookups (that requirement was specific toPostgreSQL, which is no longer embedded).
npm install -g pnpminstead ofcorepack enable. Node 26 no longer bundlesCorepack by default; installing pnpm directly is simpler than reinstalling Corepack.
ENV NEXT_TELEMETRY_DISABLED=1set in both stages (build time and runtime -Next.js checks it in both places).
Entrypoints (
docker/entrypoints/)app-prod-entrypoint.shsimplified drastically. No more embedded-Postgresfallback, no more
su postgres -c ...//etc/passwdself-registration dance (thatexisted solely to satisfy PostgreSQL's non-root requirements). It now only logs the
configured
TZ, fails fast ifDATABASE_URLis unset, and execsnode server.js.app-dev-entrypoint.shstay unchanged.nginx (
docker/nginx/default.conf, wasnginx.conf)nginximage (rootful) insteadof being baked into the app image.
server{}block (renamed todefault.conf) meant to be mountedat
/etc/nginx/conf.d/default.confinside the stock image, which already providesits own top-level
nginx.conf. Proxies toapp:3000/tusd:1080by service nameinstead of
127.0.0.1.Compose files (renamed for a consistent
compose.*.yamlscheme)docker-compose.yml:compose.dev.yaml(local dev infra: db, tusd, mailpit,storage emulators, etc. - the app itself runs via
pnpm dev, not containerized).docker-compose.func.yml:compose.func.yaml(optional auth backends: keycloak,pocket-id).
docker-compose.prod.yml:compose.yaml(the reference topology:app+tusd+nginx+dbas four independent containers, sharing aportabase-privatevolumebetween
appandtusdfor the upload tmp directory, since/api/tus/hooksdoes adirect
fs.renameSyncon tusd's output).Makefileupdated: all references to the olddocker-compose.func.ymlfilenamefixed to
compose.func.yaml.tusd:v2.10.0,nginx:1.31-trixie,postgres:18.4-trixie./var/lib/postgresql(was/var/lib/postgresql/data). PostgreSQL 18's official image moved its defaultPGDATA/VOLUMEto/var/lib/postgresql; mounting at the old path silently stopspersisting data.
CI workflows (
.github/workflows/{ghcr,docker,e2e}.yml)targetinput/build arg. The old image had named build stages(
dev,prod) selected via--target; the new image's last stage (runtime) iswhat buildx picks by default, so
targetis no longer needed. Kept as a knownfollow-up:
e2e.ymlstill builds and ships a singleapp-only image asserver_imagetoportabase/e2e-tests, which assumed an all-in-one image - thatworkflow will need to run the split topology instead.
Helm chart (
helm/)securityContext/podSecurityContextare now configurable, defaulting to{}(no enforcement) rather than hardcoding Pod Security Standards "restricted" values.
This keeps the chart deployable unmodified on any cluster while documenting (as
commented examples in
values.yaml) the exact values to set for arestrictednamespace.
Deployment): it shares the app's
ReadWriteOncePVC for the upload tmp directorywithout needing ReadWriteMany storage, and reaches the app over
127.0.0.1sincecontainers in the same pod share a network namespace.
wait-for-dbinit container on the app pod: checks the database is up(
pg_isready) before the app/tusd containers start.(
postgres.enabled, defaulttrue), for convenience/getting started. Its pod hasits own, separately-configurable
securityContextbecause the official postgresimage's
initdbneeds its built-inpostgresuser (uid/gid 999) resolvable in/etc/passwd- it can't share the app's uid 1001. Anyone who needs every pod on thesame uid in a
restrictednamespace should setpostgres.enabled: falseand bringtheir own database (managed Postgres, CloudNativePG, etc.) instead of forcing the
official image into a uid it wasn't built for.
values.yamlanymore.secret.yaml(which previously created aKubernetes
Secretfrom a plaintextproject.secretvalue) was removed entirely.project.existingSecretName/existingSecretKeyandpostgres.existingSecretName/existingSecretKey(orpostgres.externalDatabaseSecretName/Keywhenpostgres.enabled: false) point atsecrets created out-of-band (
kubectl create secret generic ... -n <namespace>).Missing values fail the template render explicitly instead of silently deploying a
default/weak secret.
DATABASE_URLcomposed in-cluster, never templated in plaintext. The appcontainer builds it from
POSTGRES_USER/POSTGRES_PASSWORD(the latter fromsecretKeyRef) using Kubernetes'$(VAR)env expansion, so the password neverpasses through Helm's template rendering as a string.
helm.sh/resource-policy: keepsohelm uninstalldoesn't delete the underlying volumes/data.service.targetPortchanged from80to3000to match the new app image's port.tusd.maxSizewas a bare YAML integer (21474836480), which Helm'sYAML-to-JSON-to-Go conversion renders as a float in scientific notation
(
2.147483648e+10) when interpolated into a template - invalid for tusd's--max-sizeflag. Fixed by quoting it as a string invalues.yaml.