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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.planning
|
||||||
|
.claude
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
*.local
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
@@ -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
|
||||||
+36
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <script> from dist/index.html,
|
||||||
|
# and swap 'unsafe-inline' for its 'sha256-...' hash.
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
|
||||||
Reference in New Issue
Block a user