Skip to content

feat: harden the container image and helm chart - #453

Draft
timothee-bn wants to merge 1 commit into
Portabase:mainfrom
timothee-bn:feat/harden-docker-images
Draft

feat: harden the container image and helm chart#453
timothee-bn wants to merge 1 commit into
Portabase:mainfrom
timothee-bn:feat/harden-docker-images

Conversation

@timothee-bn

Copy link
Copy Markdown

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 restricted and ships far more
packages (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)

  • Replaced the all-in-one image with an app-only image. PostgreSQL, tusd and nginx
    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.
  • Multi-stage build using Next.js output: "standalone". builder installs deps
    and runs pnpm run build; runtime only copies the traced standalone output
    (server.js + pruned node_modules), .next/static, public, and the handful of
    config/source files the app reads from disk at runtime (next.config.ts,
    portabase.config.ts, drizzle.config.ts, src/db for migrations). The final image
    carries neither the full node_modules tree nor pnpm/npm.
  • Rootless by default: USER 1001:1001, no addgroup/adduser needed since
    nothing in this image does getpwuid() lookups (that requirement was specific to
    PostgreSQL, which is no longer embedded).
  • npm install -g pnpm instead of corepack enable. Node 26 no longer bundles
    Corepack by default; installing pnpm directly is simpler than reinstalling Corepack.
  • ENV NEXT_TELEMETRY_DISABLED=1 set in both stages (build time and runtime -
    Next.js checks it in both places).

Entrypoints (docker/entrypoints/)

  • app-prod-entrypoint.sh simplified drastically. No more embedded-Postgres
    fallback, no more su postgres -c ... / /etc/passwd self-registration dance (that
    existed solely to satisfy PostgreSQL's non-root requirements). It now only logs the
    configured TZ, fails fast if DATABASE_URL is unset, and execs node server.js.
  • app-dev-entrypoint.sh stay unchanged.

nginx (docker/nginx/default.conf, was nginx.conf)

  • Runs as a separate container using the standard nginx image (rootful) instead
    of being baked into the app image.
  • Config reduced to a server{} block (renamed to default.conf) meant to be mounted
    at /etc/nginx/conf.d/default.conf inside the stock image, which already provides
    its own top-level nginx.conf. Proxies to app:3000 / tusd:1080 by service name
    instead of 127.0.0.1.

Compose files (renamed for a consistent compose.*.yaml scheme)

  • 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 + db as four independent containers, sharing a portabase-private volume
    between app and tusd for the upload tmp directory, since /api/tus/hooks does a
    direct fs.renameSync on tusd's output).
  • Makefile updated: all references to the old docker-compose.func.yml filename
    fixed to compose.func.yaml.
  • Image versions bumped: tusd:v2.10.0, nginx:1.31-trixie, postgres:18.4-trixie.
  • Postgres volume mount path changed to /var/lib/postgresql (was
    /var/lib/postgresql/data). PostgreSQL 18's official image moved its default
    PGDATA/VOLUME to /var/lib/postgresql; mounting at the old path silently stops
    persisting data.

CI workflows (.github/workflows/{ghcr,docker,e2e}.yml)

  • Removed the target input/build arg. The old image had named build stages
    (dev, prod) selected via --target; the new image's last stage (runtime) is
    what buildx picks by default, so target is no longer needed. Kept as a known
    follow-up: e2e.yml still builds and ships a single app-only image as
    server_image to portabase/e2e-tests, which assumed an all-in-one image - that
    workflow will need to run the split topology instead.

Helm chart (helm/)

  • securityContext/podSecurityContext are 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 a restricted
    namespace.
  • tusd added as a second container in the app's pod (sidecar, not a separate
    Deployment): it shares the app's ReadWriteOnce PVC for the upload tmp directory
    without needing ReadWriteMany storage, and reaches the app over 127.0.0.1 since
    containers in the same pod share a network namespace.
  • wait-for-db init container on the app pod: checks the database is up
    (pg_isready) before the app/tusd containers start.
  • PostgreSQL added as its own optional Deployment + PVC + Service
    (postgres.enabled, default true), for convenience/getting started. Its pod has
    its own, separately-configurable securityContext because the official postgres
    image's initdb needs its built-in postgres user (uid/gid 999) resolvable in
    /etc/passwd - it can't share the app's uid 1001. Anyone who needs every pod on the
    same uid in a restricted namespace should set postgres.enabled: false and bring
    their own database (managed Postgres, CloudNativePG, etc.) instead of forcing the
    official image into a uid it wasn't built for.
  • No secrets in values.yaml anymore. secret.yaml (which previously created a
    Kubernetes Secret from a plaintext project.secret value) was removed entirely.
    project.existingSecretName/existingSecretKey and
    postgres.existingSecretName/existingSecretKey (or
    postgres.externalDatabaseSecretName/Key when postgres.enabled: false) point at
    secrets 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_URL composed in-cluster, never templated in plaintext. The app
    container builds it from POSTGRES_USER/POSTGRES_PASSWORD (the latter from
    secretKeyRef) using Kubernetes' $(VAR) env expansion, so the password never
    passes through Helm's template rendering as a string.
  • PVCs (app data + postgres data) annotated helm.sh/resource-policy: keep so
    helm uninstall doesn't delete the underlying volumes/data.
  • service.targetPort changed from 80 to 3000 to match the new app image's port.
  • Bug fix: tusd.maxSize was a bare YAML integer (21474836480), which Helm's
    YAML-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-size flag. Fixed by quoting it as a string in values.yaml.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 62923527-fd1a-4128-a997-c503647f9494

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant