fix(docker): run runtime as nextjs so self-host boots under cap_drop: ALL (#737)
Self-hosted Docker image now runs fully unprivileged (USER nextjs): the entrypoint populates the .next/public tmpfs mounts and substitutes NEXT_PUBLIC_* placeholders as nextjs, so the container boots under the hardened compose (cap_drop: ALL + read_only: true) with no capabilities. Also: substitute *.json (fixes CSP connect-src in routes-manifest.json), escape sed metacharacters in brand name, healthcheck via 127.0.0.1, and a fully-self-hosted Supabase docs section.
This commit is contained in:
+25
-24
@@ -46,44 +46,45 @@ RUN npm run build
|
||||
FROM node:22-alpine@sha256:968df39aedcea65eeb078fb336ed7191baf48f972b4479711397108be0966920 AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# su-exec drops privileges in the entrypoint after the placeholder-substitution
|
||||
# step. Healthcheck uses BusyBox wget (already present in alpine), so no curl.
|
||||
# `apk upgrade` patches OS packages (libssl3/libcrypto3, …) in the final image
|
||||
# that Trivy scans — the runner uses its own FROM, so it needs the upgrade too.
|
||||
RUN apk upgrade --no-cache && apk add --no-cache su-exec
|
||||
# Patch OS packages (libssl3/libcrypto3, …) with fixes published after the
|
||||
# pinned base digest, so CI's Trivy scan doesn't flag fixable Alpine CVEs. No
|
||||
# su-exec or curl needed: the entrypoint runs unprivileged as nextjs and the
|
||||
# healthcheck uses BusyBox wget.
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
adduser --system --uid 1001 -G nodejs nextjs
|
||||
|
||||
# /app at runtime is split across the read-only image layer and tmpfs mounts:
|
||||
# /app/server.js, /app/node_modules/, /app/package.json — image (read-only)
|
||||
# /app/.next/ — tmpfs (writable)
|
||||
# /app/public/ — tmpfs (writable)
|
||||
# The entrypoint copies templates from /opt/gnubok-template/ into the tmpfs
|
||||
# mounts at startup, runs placeholder substitution, then chmods read-only.
|
||||
# This lets us run with docker-compose `read_only: true`.
|
||||
# The entrypoint runs UNPRIVILEGED as nextjs: it copies the templates from
|
||||
# /opt/gnubok-template/ into the nextjs-owned tmpfs mounts, substitutes the
|
||||
# NEXT_PUBLIC_* placeholders, then drops the write bits. Because it never needs
|
||||
# to chown or setuid, the container runs under docker-compose's `cap_drop: ALL`
|
||||
# + `read_only: true` with no added capabilities.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/server.js ./server.js
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/package.json ./package.json
|
||||
|
||||
COPY --from=builder /app/.next/standalone/server.js ./server.js
|
||||
COPY --from=builder /app/.next/standalone/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next/standalone/package.json ./package.json
|
||||
# Baked-in templates for runtime population of the tmpfs mounts.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/.next /opt/gnubok-template/.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static /opt/gnubok-template/.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public /opt/gnubok-template/public
|
||||
|
||||
# Baked-in templates for runtime population of tmpfs mounts.
|
||||
COPY --from=builder /app/.next/standalone/.next /opt/gnubok-template/.next
|
||||
COPY --from=builder /app/.next/static /opt/gnubok-template/.next/static
|
||||
COPY --from=builder /app/public /opt/gnubok-template/public
|
||||
# Pre-create the tmpfs mount points (empty in the image layer; the entrypoint
|
||||
# fills them at startup). Owned by nextjs so the unprivileged entrypoint can
|
||||
# write into the tmpfs mounted over them.
|
||||
RUN mkdir -p /app/.next/cache /app/public && \
|
||||
chown nextjs:nodejs /app /app/.next /app/.next/cache /app/public
|
||||
|
||||
# Pre-create mount points so tmpfs has somewhere to attach when running with
|
||||
# docker-compose's read_only:true. The directories are empty in the image
|
||||
# layer — content is copied in by the entrypoint.
|
||||
RUN mkdir -p /app/.next /app/.next/cache /app/public
|
||||
COPY --chmod=755 --chown=nextjs:nodejs docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
# No USER directive — entrypoint handles the privilege drop with su-exec
|
||||
# after the root-only setup steps complete.
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
+103
@@ -125,6 +125,12 @@ To build the Docker image locally instead of pulling from GHCR:
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up --build
|
||||
```
|
||||
|
||||
The locally-built image runs **unprivileged** (`USER nextjs`): the entrypoint
|
||||
populates the `.next`/`public` tmpfs mounts and substitutes the `NEXT_PUBLIC_*`
|
||||
placeholders as the `nextjs` user, so the container needs no Linux capabilities
|
||||
and runs as-is under the hardened compose defaults (`cap_drop: ALL`,
|
||||
`read_only: true`).
|
||||
|
||||
### Custom Port
|
||||
|
||||
Set `PORT` in your `.env` or environment to change the host port (the container always listens on 3000 internally):
|
||||
@@ -265,6 +271,103 @@ Check the [release notes](https://github.com/erp-mafia/gnubok/releases) for migr
|
||||
|
||||
The Next.js app is stateless — all data lives in Supabase. The Docker entrypoint injects your `NEXT_PUBLIC_*` environment variables into the pre-built JS bundles at container startup, so a single image works with any Supabase project.
|
||||
|
||||
## Fully Self-Hosted (No Supabase Cloud)
|
||||
|
||||
The setup above relies on a Supabase project at supabase.com. If you also want to host the database, auth, and storage yourself — to keep all data on-premises, avoid the SaaS dependency, or run air-gapped — you can pair Accounted with [Supabase's official Docker self-hosting stack](https://supabase.com/docs/guides/self-hosting/docker) instead.
|
||||
|
||||
This is a more involved path. You take responsibility for backups, TLS certificates, image upgrades, and Postgres operations. It is intended for operators already running Docker services who are comfortable with PostgreSQL.
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
user((User))
|
||||
proxy["Reverse proxy + TLS<br/>(Caddy / Traefik / nginx)"]
|
||||
user -- HTTPS --> proxy
|
||||
|
||||
subgraph dnet["shared Docker network"]
|
||||
subgraph app_stack["Accounted stack (this repo)"]
|
||||
app["app<br/>Next.js · :3000"]
|
||||
cron["cron<br/>supercronic"]
|
||||
cron -. Bearer CRON_SECRET .-> app
|
||||
end
|
||||
|
||||
subgraph supabase_stack["Supabase self-host stack"]
|
||||
kong["kong<br/>API gateway · :8000"]
|
||||
studio["studio<br/>dashboard"]
|
||||
db[("postgres<br/>+ pg_cron")]
|
||||
auth["gotrue"]
|
||||
rest["postgrest"]
|
||||
rt["realtime"]
|
||||
storage["storage-api<br/>(+ imgproxy)"]
|
||||
kong --- auth & rest & rt & storage & studio
|
||||
auth & rest & rt & storage --- db
|
||||
end
|
||||
|
||||
app -- "@supabase/supabase-js" --> kong
|
||||
end
|
||||
|
||||
proxy -- app.example.com --> app
|
||||
proxy -- supabase.example.com --> kong
|
||||
proxy -- studio.example.com --> studio
|
||||
```
|
||||
|
||||
### Setup outline
|
||||
|
||||
1. **Bring up Supabase** following [supabase.com/docs/guides/self-hosting/docker](https://supabase.com/docs/guides/self-hosting/docker). Generate your own `JWT_SECRET`, `ANON_KEY`, and `SERVICE_ROLE_KEY` (Supabase ships `sh utils/generate-keys.sh`). Pick a hostname for the API gateway (e.g. `supabase.example.com`) and point `SUPABASE_PUBLIC_URL` / `API_EXTERNAL_URL` at it.
|
||||
|
||||
2. **Apply the Accounted migrations** directly via `psql` — the Supabase CLI (`db push`) assumes a cloud project, so run the SQL files against the self-hosted database container:
|
||||
|
||||
```bash
|
||||
# From the repo root, stream each migration straight into the supabase-db
|
||||
# container — glob order is already sorted, and nothing is left behind on the
|
||||
# host or in the container.
|
||||
for f in supabase/migrations/*.sql; do
|
||||
echo "Applying $f..."
|
||||
docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$f" || exit 1
|
||||
done
|
||||
```
|
||||
|
||||
3. **Configure `.env`** with your self-hosted endpoints (extract the keys from your Supabase `.env`):
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://supabase.example.com
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<ANON_KEY from supabase .env>
|
||||
SUPABASE_SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY from supabase .env>
|
||||
NEXT_PUBLIC_APP_URL=https://app.example.com
|
||||
CRON_SECRET=<openssl rand -hex 32>
|
||||
NEXT_PUBLIC_SELF_HOSTED=true
|
||||
```
|
||||
|
||||
4. **Allowlist the callback URLs** in GoTrue's redirect list (the Supabase stack's `.env`), then recreate the auth container so it picks up the change:
|
||||
|
||||
```bash
|
||||
ADDITIONAL_REDIRECT_URLS=https://app.example.com/auth/callback,https://app.example.com/api/auth/callback
|
||||
```
|
||||
```bash
|
||||
cd <your-supabase-dir> && docker compose up -d auth
|
||||
```
|
||||
|
||||
5. **Reverse proxy** in front of both hosts. The app container and the Supabase `kong` container must share an external Docker network so the proxy can route to them by name.
|
||||
|
||||
### What you give up vs. cloud Supabase
|
||||
|
||||
- **Backups** are entirely your responsibility — set up `pg_dump` (or a tool like restic) to off-host storage. As a portable, vendor-neutral *logical* backup on top of the raw dump, you can also export each fiscal period as a standard **SIE4** file via the API and archive it — any Swedish bookkeeping system can re-import it:
|
||||
|
||||
```bash
|
||||
curl -fsS -H "Authorization: Bearer <reports:read API key>" \
|
||||
"$NEXT_PUBLIC_APP_URL/api/v1/companies/<companyId>/reports/sie-export?period_id=<periodId>" \
|
||||
-o "export_<periodId>.se"
|
||||
```
|
||||
- **Storage**: the included `storage-api` defaults to the local-filesystem backend. For production durability, use the `docker-compose.s3.yml` overlay and point it at S3 / MinIO.
|
||||
- **SMTP**: no built-in mailer. Either set `ENABLE_EMAIL_AUTOCONFIRM=true` for dev/staging, or wire `SMTP_*` env vars in the Supabase stack to a provider (Resend, Postmark, etc.).
|
||||
- **Upgrades**: you sync the `supabase/postgres` image yourself — your data lives in the DB volume, so a Postgres image bump needs no migration re-run. When you pull a newer Accounted release, apply only the **new** migration files added since your last deploy (the SQL is not idempotent, so re-running already-applied migrations will error). Track which migrations you've applied, e.g. with a checksum/version table.
|
||||
|
||||
### Notes
|
||||
|
||||
- **`pg_cron`** is included in the `supabase/postgres` image, so the `pg_cron` migration succeeds (unlike on the Supabase free tier — see the standard self-hosting flow above).
|
||||
- **MFA**: as on the standard path, `NEXT_PUBLIC_SELF_HOSTED=true` disables enforcement; users may still enable TOTP voluntarily.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Health check fails with "unhealthy":**
|
||||
|
||||
+12
-9
@@ -15,23 +15,26 @@ services:
|
||||
init: true
|
||||
# tmpfs ordering matters — the parent /app/.next must be mounted before
|
||||
# any sub-mounts. The entrypoint cp's image-baked templates into these
|
||||
# mounts at startup, then sed-substitutes, then chmod -R a-w.
|
||||
# mounts at startup, then sed-substitutes the NEXT_PUBLIC_* placeholders,
|
||||
# then drops the write bits.
|
||||
#
|
||||
# mode=750: root-owned, group-readable. The entrypoint runs as root long
|
||||
# enough to copy templates, substitute placeholders, and chown the cache
|
||||
# directory to nextjs:nodejs (gid 1001). nodejs group members can read
|
||||
# the partially-substituted bundle during startup; nothing else on the
|
||||
# host can. After chmod a-w the served files are read-only for everyone.
|
||||
# uid=1001 (nextjs), mode=750: the entrypoint runs UNPRIVILEGED as nextjs
|
||||
# and owns these mounts, so it can populate them with no CAP_CHOWN/CAP_SETUID
|
||||
# — which is what lets the container run under `cap_drop: ALL`. nodejs-group
|
||||
# members can read the served bundle; nothing else on the host can.
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /app/.next:uid=0,gid=1001,mode=750,size=400m
|
||||
- /app/public:uid=0,gid=1001,mode=750,size=200m
|
||||
- /app/.next:uid=1001,gid=1001,mode=750,size=400m
|
||||
- /app/public:uid=1001,gid=1001,mode=750,size=200m
|
||||
mem_limit: 1g
|
||||
cpus: 2
|
||||
pids_limit: 200
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||
# 127.0.0.1, not localhost: the app binds 0.0.0.0 (IPv4) but localhost can
|
||||
# resolve to ::1 (IPv6), where nothing listens → false-unhealthy → the
|
||||
# cron service (depends_on healthy) never starts.
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
|
||||
+76
-36
@@ -27,51 +27,91 @@ if [ -n "$placeholders_found" ]; then
|
||||
printf "WARNING: These variables appear to contain placeholder values:\n%bPlease set them to real values before running in production.\n" "$placeholders_found" >&2
|
||||
fi
|
||||
|
||||
# Populate writable mount points from the baked-in templates. Under
|
||||
# docker-compose's read_only:true, /app/.next and /app/public are tmpfs
|
||||
# mounts; cp populates them in RAM. Without read_only:true the directories
|
||||
# were created empty in the Dockerfile, so cp still works.
|
||||
# ─── Populate the writable tmpfs mounts from the baked-in templates ───
|
||||
# Under docker-compose's `read_only: true`, /app/.next and /app/public are
|
||||
# tmpfs mounts owned by nextjs (uid=1001); this cp fills them in RAM at every
|
||||
# startup. /app/server.js, /app/node_modules and /app/package.json stay on the
|
||||
# read-only image layer. Running as the unprivileged nextjs user means no
|
||||
# CAP_CHOWN / CAP_SETUID is needed, so the container works under `cap_drop: ALL`.
|
||||
# Without read_only:true the mount points were created empty in the Dockerfile,
|
||||
# so the same cp still works.
|
||||
#
|
||||
# On a non-tmpfs restart the target dirs persist with their write bits removed
|
||||
# (see the immutability step below), so restore owner-write first — otherwise the
|
||||
# unprivileged cp -R below fails under `set -e`. Under tmpfs the dirs are empty
|
||||
# each start, so this is a no-op.
|
||||
chmod -R u+w /app/.next /app/public 2>/dev/null || true
|
||||
if [ -d /opt/gnubok-template/.next ]; then
|
||||
cp -R /opt/gnubok-template/.next/. /app/.next/
|
||||
fi
|
||||
if [ -d /opt/gnubok-template/public ]; then
|
||||
cp -R /opt/gnubok-template/public/. /app/public/
|
||||
fi
|
||||
|
||||
# Ensure Next.js's runtime cache directory is writable by the unprivileged user.
|
||||
mkdir -p /app/.next/cache
|
||||
chown -R nextjs:nodejs /app/.next/cache
|
||||
chmod 755 /app/.next/cache
|
||||
|
||||
# Substitute build-time placeholder sentinels with runtime env values.
|
||||
find /app/.next -type f \( -name '*.js' -o -name '*.html' -o -name '*.rsc' -o -name '*.meta' -o -name '*.body' \) -exec sed -i \
|
||||
-e "s|__NEXT_PUBLIC_SUPABASE_URL__|${NEXT_PUBLIC_SUPABASE_URL}|g" \
|
||||
-e "s|__NEXT_PUBLIC_SUPABASE_ANON_KEY__|${NEXT_PUBLIC_SUPABASE_ANON_KEY}|g" \
|
||||
-e "s|__NEXT_PUBLIC_APP_URL__|${NEXT_PUBLIC_APP_URL}|g" \
|
||||
-e "s|__NEXT_PUBLIC_VAPID_PUBLIC_KEY__|${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}|g" \
|
||||
-e "s|__NEXT_PUBLIC_SELF_HOSTED__|${NEXT_PUBLIC_SELF_HOSTED:-true}|g" \
|
||||
-e "s|__NEXT_PUBLIC_REQUIRE_MFA__|${NEXT_PUBLIC_REQUIRE_MFA:-false}|g" \
|
||||
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}|g" \
|
||||
{} +
|
||||
# ─── Replace build-time placeholder sentinels with runtime env vars ───
|
||||
# Substitution covers /app/.next (client static + server bundles + manifests;
|
||||
# the manifests at .next/ root hold the CSP/headers from next.config.ts) and
|
||||
# /app/public (sw.js — the service worker is served raw, so Next's build-time
|
||||
# inlining doesn't reach it). server.js needs no substitution and lives on the
|
||||
# read-only image layer, so it is deliberately excluded.
|
||||
#
|
||||
# `sed -i` rewrites every file it touches, so we prefilter with `grep -l` and
|
||||
# only sed files that actually contain a placeholder. busybox grep has no -Z,
|
||||
# so we rely on Next.js build outputs not having newlines in filenames.
|
||||
SUBST_PATHS=""
|
||||
[ -d /app/.next ] && SUBST_PATHS="$SUBST_PATHS /app/.next"
|
||||
[ -d /app/public ] && SUBST_PATHS="$SUBST_PATHS /app/public"
|
||||
|
||||
# Stamp the service worker fallback notification title with the brand name.
|
||||
# public/sw.js is served as a static file (not bundled by Next), so NEXT_PUBLIC_*
|
||||
# inlining doesn't reach it.
|
||||
if [ -f /app/public/sw.js ]; then
|
||||
sed -i \
|
||||
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}|g" \
|
||||
/app/public/sw.js
|
||||
if [ -n "$SUBST_PATHS" ]; then
|
||||
# Escape sed replacement metacharacters (backslash, & whole-match, and the |
|
||||
# delimiter) so a value like "Acme & Co." (legal in NEXT_PUBLIC_BRANDING_APP_NAME)
|
||||
# or one containing | can't corrupt the output or break the sed command.
|
||||
# busybox-ash-compatible parameter expansion (verified on busybox 1.37).
|
||||
sed_esc() {
|
||||
v=$1
|
||||
v=${v//\\/\\\\}
|
||||
v=${v//&/\\&}
|
||||
v=${v//|/\\|}
|
||||
printf %s "$v"
|
||||
}
|
||||
E_SUPABASE_URL=$(sed_esc "$NEXT_PUBLIC_SUPABASE_URL")
|
||||
E_SUPABASE_ANON_KEY=$(sed_esc "$NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||
E_APP_URL=$(sed_esc "$NEXT_PUBLIC_APP_URL")
|
||||
E_VAPID_PUBLIC_KEY=$(sed_esc "${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}")
|
||||
E_SELF_HOSTED=$(sed_esc "${NEXT_PUBLIC_SELF_HOSTED:-true}")
|
||||
E_REQUIRE_MFA=$(sed_esc "${NEXT_PUBLIC_REQUIRE_MFA:-false}")
|
||||
E_BRANDING_APP_NAME=$(sed_esc "${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}")
|
||||
|
||||
# File-type coverage:
|
||||
# *.js — client + server bundles
|
||||
# *.json — routes-manifest.json (CSP/headers), build-manifest.json, etc.
|
||||
# *.html — prerendered pages (e.g. /login title contains BRANDING_APP_NAME)
|
||||
# *.rsc — RSC payloads with the same inlined values
|
||||
# *.body — metadata-route bodies, e.g. manifest.webmanifest.body (PWA name)
|
||||
# shellcheck disable=SC2086
|
||||
find $SUBST_PATHS -type f \
|
||||
\( -name '*.js' -o -name '*.json' -o -name '*.html' -o -name '*.rsc' -o -name '*.body' \) \
|
||||
-exec grep -l "__NEXT_PUBLIC_" {} + 2>/dev/null \
|
||||
| tr '\n' '\0' \
|
||||
| xargs -0 -r sed -i \
|
||||
-e "s|__NEXT_PUBLIC_SUPABASE_URL__|${E_SUPABASE_URL}|g" \
|
||||
-e "s|__NEXT_PUBLIC_SUPABASE_ANON_KEY__|${E_SUPABASE_ANON_KEY}|g" \
|
||||
-e "s|__NEXT_PUBLIC_APP_URL__|${E_APP_URL}|g" \
|
||||
-e "s|__NEXT_PUBLIC_VAPID_PUBLIC_KEY__|${E_VAPID_PUBLIC_KEY}|g" \
|
||||
-e "s|__NEXT_PUBLIC_SELF_HOSTED__|${E_SELF_HOSTED}|g" \
|
||||
-e "s|__NEXT_PUBLIC_REQUIRE_MFA__|${E_REQUIRE_MFA}|g" \
|
||||
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${E_BRANDING_APP_NAME}|g"
|
||||
fi
|
||||
|
||||
# Make the served JS bundle immutable. A runtime RCE in the Node process
|
||||
# cannot rewrite what other users will receive. Cache stays writable so
|
||||
# Next.js can populate its image-optimization / ISR caches.
|
||||
chmod -R a-w /app/.next/static
|
||||
[ -d /app/.next/server ] && chmod -R a-w /app/.next/server
|
||||
find /app/.next -maxdepth 1 -type f -exec chmod a-w {} +
|
||||
[ -f /app/public/sw.js ] && chmod a-w /app/public/sw.js
|
||||
# ─── Make the served bundle immutable (defense in depth) ───
|
||||
# nextjs owns these tmpfs files, so a compromised Node process could chmod them
|
||||
# back; dropping the write bits still raises the bar against casual tampering.
|
||||
# (Root-owned immutability isn't possible without running the entrypoint as
|
||||
# root, which would reintroduce the CAP_CHOWN/CAP_SETUID requirement.)
|
||||
chmod -R a-w /app/.next/static 2>/dev/null || true
|
||||
[ -d /app/.next/server ] && chmod -R a-w /app/.next/server 2>/dev/null || true
|
||||
find /app/.next -maxdepth 1 -type f -exec chmod a-w {} + 2>/dev/null || true
|
||||
[ -f /app/public/sw.js ] && chmod a-w /app/public/sw.js 2>/dev/null || true
|
||||
|
||||
# Drop privileges. The Node server runs as the unprivileged nextjs:nodejs user;
|
||||
# the now-immutable static dir is root-owned, so even a process compromise
|
||||
# inside Node cannot rewrite the served JS bundle.
|
||||
exec su-exec nextjs:nodejs "$@"
|
||||
exec "$@"
|
||||
|
||||
Reference in New Issue
Block a user