From 32159a404d44657844b2c58dc5ae7a084e25630a Mon Sep 17 00:00:00 2001 From: kawakode Date: Wed, 26 Aug 2026 15:03:19 +0200 Subject: [PATCH] feat(docker): add containerized nginx deployment with runtime base path Two-stage build: node builds the static bundle, nginx serves it. The bundle is built against a placeholder base token that the entrypoint rewrites to $BASE_URL at start, so one image serves from any path without a rebuild. nginx config caches hashed assets forever, never caches index.html, and applies security headers (CSP, no framing, no referrer) suited to an app that handles cloud credentials client-side. .gitattributes pins LF on the container-consumed files, since core.autocrlf would otherwise give the entrypoint a CRLF shebang. Co-Authored-By: Claude Opus 5 --- .dockerignore | 14 +++++++++++++ .gitattributes | 6 ++++++ Dockerfile | 36 ++++++++++++++++++++++++++++++++ docker-compose.yml | 11 ++++++++++ docker/40-ready2blob-base-url.sh | 34 ++++++++++++++++++++++++++++++ docker/nginx.conf.template | 33 +++++++++++++++++++++++++++++ docker/security-headers.conf | 15 +++++++++++++ 7 files changed, 149 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/40-ready2blob-base-url.sh create mode 100644 docker/nginx.conf.template create mode 100644 docker/security-headers.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5f5790d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +dist +dist-ssr +.git +.gitignore +.planning +.claude +.vscode +.idea +*.log +*.local +Dockerfile +.dockerignore +docker-compose.yml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0628b86 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# core.autocrlf is on for this repo; these files are consumed by Linux inside the +# container, where a CRLF shebang fails as "no such file or directory". +*.sh text eol=lf +*.conf text eol=lf +*.template text eol=lf +Dockerfile text eol=lf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..957cf3c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 + +# ---- Stage 1: build the static bundle ---- +FROM node:22-alpine AS build +WORKDIR /app + +# Deps from the lockfile first, so this layer survives source-only edits. +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +# Build against a placeholder base. The entrypoint swaps it for $BASE_URL at startup, +# so one image can be served from any path without rebuilding. +RUN npm run build -- --base=/__R2B_BASE__/ + +# ---- Stage 2: serve it ---- +# Ready2Blob is entirely client-side, so the runtime image is just nginx + dist/. +FROM nginx:alpine AS runtime + +# Pristine copy; the entrypoint stages it into the web root on each start. +COPY --from=build /app/dist /opt/ready2blob/html + +COPY docker/security-headers.conf /etc/nginx/snippets/security-headers.conf +COPY docker/nginx.conf.template /etc/nginx/ready2blob.conf.template +COPY docker/40-ready2blob-base-url.sh /docker-entrypoint.d/40-ready2blob-base-url.sh +RUN chmod +x /docker-entrypoint.d/40-ready2blob-base-url.sh + +# Path the app is served from. "/" for a dedicated (sub)domain, "/ready2blob/" when +# mounted under a path on a shared host. +ENV BASE_URL=/ + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://localhost/ || exit 1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5dccc9f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + ready2blob: + build: . + image: ready2blob:latest + container_name: ready2blob + ports: + - "8080:80" + environment: + # "/" for a dedicated (sub)domain; "/ready2blob/" to mount under a path. + BASE_URL: "/" + restart: unless-stopped diff --git a/docker/40-ready2blob-base-url.sh b/docker/40-ready2blob-base-url.sh new file mode 100644 index 0000000..f65529d --- /dev/null +++ b/docker/40-ready2blob-base-url.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Applies $BASE_URL to the built bundle and the nginx config at container start. +# +# The bundle is built with Vite's base set to the literal token below, so every asset +# URL in the emitted HTML/JS/CSS carries it. Rewriting that token here is what lets one +# image serve from any path without a rebuild. +set -eu + +TOKEN='/__R2B_BASE__/' +SRC='/opt/ready2blob/html' +DEST='/usr/share/nginx/html' + +# Normalize to a leading and trailing slash: "app" and "/app" both mean "/app/". +base="${BASE_URL:-/}" +case "$base" in /*) ;; *) base="/$base" ;; esac +case "$base" in */) ;; *) base="$base/" ;; esac + +echo "ready2blob: serving from base $base" + +# Rebuild the web root from the pristine copy on every start, so both restarts and +# BASE_URL changes are idempotent. +rm -rf "${DEST:?}"/* +mkdir -p "$DEST$base" +cp -R "$SRC/." "$DEST$base" + +# '|' as the sed delimiter, since both needle and replacement contain '/'. +find "$DEST" -type f \( -name '*.html' -o -name '*.js' -o -name '*.css' \) \ + -exec sed -i "s|$TOKEN|$base|g" {} + + +# Restrict envsubst to BASE_URL so nginx's own $uri / $1 survive untouched. +export BASE_URL="$base" +envsubst '${BASE_URL}' \ + < /etc/nginx/ready2blob.conf.template \ + > /etc/nginx/conf.d/default.conf diff --git a/docker/nginx.conf.template b/docker/nginx.conf.template new file mode 100644 index 0000000..98f1d2a --- /dev/null +++ b/docker/nginx.conf.template @@ -0,0 +1,33 @@ +# Rendered to /etc/nginx/conf.d/default.conf at startup, with ${BASE_URL} substituted. +# See docker/40-ready2blob-base-url.sh. +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + + include /etc/nginx/snippets/security-headers.conf; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/css application/javascript image/svg+xml application/json; + + # Vite emits content-hashed filenames under assets/ — safe to cache forever. + location ${BASE_URL}assets/ { + include /etc/nginx/snippets/security-headers.conf; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + + # index.html must never be cached, or clients pin to a stale bundle after a redeploy. + location = ${BASE_URL}index.html { + include /etc/nginx/snippets/security-headers.conf; + add_header Cache-Control "no-cache" always; + } + + # No `$uri/` here on purpose: if the directory exists but holds no index file, + # nginx answers 403 instead of falling through to the fallback. + location / { + try_files $uri ${BASE_URL}index.html; + } +} diff --git a/docker/security-headers.conf b/docker/security-headers.conf new file mode 100644 index 0000000..d6577fd --- /dev/null +++ b/docker/security-headers.conf @@ -0,0 +1,15 @@ +# Ready2Blob handles cloud credentials in the browser and sends them nowhere. +# These headers keep it that way: no framing, no external origins, no referrer leakage. +# +# Included per-location as well as at server level on purpose: an `add_header` inside +# a location block REPLACES every header inherited from the parent, so any location +# that sets its own Cache-Control must re-include this file. +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "DENY" always; +add_header Referrer-Policy "no-referrer" always; +add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), usb=()" always; + +# script-src keeps 'unsafe-inline' for the theme/FOUC bootstrap in index.html. +# To tighten it: build once, read the emitted inline