feat: give the frontend a deployment artifact, and show its logo

The repository had no container image and no production-shaped serving
configuration. `dist/server.mjs` is a preview server that applies neither
the security headers nor the cache policy `config/hosting/` declares, so a
deployment had nothing correct to run.

`scripts/generate-nginx-config.ts` derives the server block from
`dist/tech-log-serving-contract.json` plus the two hosting policy files, so
the served headers and cache lifetimes cannot drift from what the contract
declares. It emits no TLS and no proxy blocks: the edge terminates TLS and
routes /api, and baking a backend address into the image would tie the
bundle to one deployment. Static surfaces use `alias` because a base-path
build serves /dev/assets/... out of dist/assets/..., which `root` plus URI
would look for one directory too deep.

The image copies that config next to the bundle and normalises permissions:
the build writes config.json 0600, which nginx cannot read, so the container
came up healthy and answered 403 for the one file the SPA needs to boot.

index.html never referenced public/favicon.svg. The file shipped and nginx
served it, but browsers asked for /favicon.ico, got a 404, and fell back to
the default icon. `%BASE_URL%` rather than an absolute path so a prefixed
deployment points at its own copy.

development.json moves to the HTTP Studio source; the mock source has no
backend to authenticate against, which is the whole point of that profile.
This commit is contained in:
DongHyeonka
2026-08-20 16:14:09 +09:00
parent 5e2b1a5586
commit 83409bef7a
7 changed files with 591 additions and 1 deletions
+96
View File
@@ -0,0 +1,96 @@
# syntax=docker/dockerfile:1
#
# The frontend deployment artifact. The repository had none — `dist/server.mjs`
# is a preview server that applies neither the security headers nor the cache
# policy `config/hosting/` declares — so a deployment had nothing to run.
#
# Two stages: the build produces `dist/` and, from the serving contract, the
# nginx configuration that matches it; the runtime is nginx with both.
# ---------------------------------------------------------------------------
# build
# ---------------------------------------------------------------------------
# Pinned by digest: the release-provenance gate requires an immutable runner
# identity, and a floating tag cannot give one.
FROM node@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build
WORKDIR /src
# The profile is baked at build time (scripts/generate-runtime-config.ts), so it
# has to be chosen here rather than at `docker run`. `dist/config.json` stays a
# separate file in the image, which is what makes build-once/promote possible:
# a deployment can replace just that file without rebuilding the bundle.
ARG APP_PROFILE=production
ENV APP_PROFILE=${APP_PROFILE}
# The bundle and the nginx locations must agree on the prefix the deployment
# serves this under: "/" at a domain root, "/dev/" behind a path prefix.
ARG VITE_ROUTER_BASE_PATH=/
ENV VITE_ROUTER_BASE_PATH=${VITE_ROUTER_BASE_PATH}
# `CI=true` turns on the release-provenance gate (scripts/lib/build-environment.ts),
# which refuses to build without an identity for the artifact. That is the point:
# a deployed bundle that cannot say which commit it came from is not traceable,
# and the checklist asks exactly that. Supplied as build args so the caller —
# a pipeline or the deploy script — owns the values.
ENV CI=true
ARG VITE_BUILD_ID
ARG VITE_COMMIT_SHA
ARG RELEASE_ID
ARG CI_RUNNER_IMAGE
ARG SOURCE_DATE_EPOCH
ENV VITE_BUILD_ID=${VITE_BUILD_ID}
ENV VITE_COMMIT_SHA=${VITE_COMMIT_SHA}
ENV RELEASE_ID=${RELEASE_ID}
ENV CI_RUNNER_IMAGE=${CI_RUNNER_IMAGE}
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
# The two values a deployment is allowed to supply (scripts/generate-runtime-
# config.ts OVERRIDES); everything else is fixed by the profile. API_BASE_URL
# has to be absolute — the runtime canonicalises it with `new URL(value)` — so
# even a same-origin deployment names its own origin here. The committed
# production profile ships a placeholder (https://api.example.com/), which is
# what a deployment that forgets this would silently serve.
ARG RUNTIME_API_BASE_URL
ARG RUNTIME_TELEMETRY_ENDPOINT
ENV RUNTIME_API_BASE_URL=${RUNTIME_API_BASE_URL}
ENV RUNTIME_TELEMETRY_ENDPOINT=${RUNTIME_TELEMETRY_ENDPOINT}
RUN corepack enable
# Dependencies first so a source-only change does not re-resolve them.
COPY package.json pnpm-lock.yaml ./
RUN corepack pnpm install --frozen-lockfile --ignore-scripts
COPY . .
RUN corepack pnpm build \
&& node scripts/generate-nginx-config.ts
# ---------------------------------------------------------------------------
# runtime
# ---------------------------------------------------------------------------
FROM nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 AS runtime
# Replaces the packaged default server block; the generated file is the whole
# server definition, including the BFF proxy locations.
RUN rm /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/nginx.conf /etc/nginx/conf.d/tech-log.conf
COPY --from=build /src/dist/ /usr/share/nginx/html/
# The generated config is served from /usr/share/nginx/html as root, so the two
# copies above would also publish nginx.conf itself. It is not secret, but it is
# not a page either.
RUN rm -f /usr/share/nginx/html/nginx.conf /usr/share/nginx/html/server.mjs \
&& rm -rf /usr/share/nginx/html/.vite \
# The build writes config.json 0600, which nginx (running as `nginx`) cannot
# read — the container came up healthy and answered 403 for the one file the
# SPA needs before it can boot. Normalise what is served to world-readable.
&& chmod -R a+rX /usr/share/nginx/html
EXPOSE 80
# No `nginx -t` here: proxy_pass names are resolved when the config loads, and
# `backend`/`keycloak` only exist on the compose network. The container's own
# startup is the check, and it fails loudly.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1${VITE_ROUTER_BASE_PATH:-/}config.json || exit 1
+1 -1
View File
@@ -13,7 +13,7 @@
"SERVICE_WORKER": "DEFAULT", "SERVICE_WORKER": "DEFAULT",
"OFFLINE_COMMANDS": "DEFAULT" "OFFLINE_COMMANDS": "DEFAULT"
}, },
"TECH_LOG_STUDIO_SOURCE": "MOCK", "TECH_LOG_STUDIO_SOURCE": "HTTP",
"FEATURE_OVERRIDES": { "FEATURE_OVERRIDES": {
"reference-feature": "DEFAULT" "reference-feature": "DEFAULT"
} }
+24
View File
@@ -0,0 +1,24 @@
# Keycloak realm
`tech-log-realm.json` is imported by the `keycloak` service at start
(`--import-realm`). It exists because the realm was previously created by hand,
which meant §27 of the release checklist — "Keycloak Realm 설정을 복원할 수
있다" — had no answer: nothing in either repository described the realm.
What it declares, and why each part is load-bearing:
- **`studio-author` realm role.** `StudioAuthzEnvironmentPostProcessor` maps this
name to `studio:read` and `studio:write`. The name is configurable through
`APP_STUDIO_AUTHOR_ROLE`; if you change it here, change it there too.
- **`tech-log-bff` confidential client.** The Authorization Code flow belongs to
the backend, not the browser — the SPA never holds a token. `redirectUris` is
relative so the same realm works on any origin the deployment is served from.
- **`realm-roles` protocol mapper.** Without it the roles never reach the token,
the registry resolves zero permissions, and every Studio call answers 403.
## Values that must be replaced
`CHANGE_ME_BFF_SECRET` and `CHANGE_ME_STUDIO_PASSWORD` are placeholders, and the
deploy script substitutes them from the environment before import. They are left
visible rather than pre-filled so a realm file committed with a real secret is an
obvious mistake rather than a quiet one.
+57
View File
@@ -0,0 +1,57 @@
{
"realm": "tech-log",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"loginTheme": "keycloak",
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"roles": {
"realm": [
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
]
},
"clients": [
{
"clientId": "tech-log-bff",
"name": "Tech Log BFF",
"description": "백엔드가 소유하는 Authorization Code 클라이언트. SPA 는 토큰을 직접 들지 않는다.",
"enabled": true,
"publicClient": false,
"secret": "CHANGE_ME_BFF_SECRET",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"redirectUris": ["/login/oauth2/code/*"],
"webOrigins": ["+"],
"protocolMappers": [
{
"name": "realm-roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": {
"claim.name": "realm_access.roles",
"jsonType.label": "String",
"multivalued": "true",
"access.token.claim": "true",
"id.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "studio",
"enabled": true,
"emailVerified": true,
"email": "studio@tech-log.local",
"firstName": "Studio",
"lastName": "Author",
"credentials": [{ "type": "password", "value": "CHANGE_ME_STUDIO_PASSWORD", "temporary": false }],
"realmRoles": ["default-roles-tech-log", "studio-author"]
}
]
}
+180
View File
@@ -0,0 +1,180 @@
# The Tech Log dev stack: one origin, five services.
#
# nginx is the only published port. Everything the browser touches — the SPA,
# /api, the OIDC redirect chain, and Keycloak under /auth — arrives on the same
# origin, which is what lets the session be a plain first-party httpOnly cookie
# instead of a cross-site one needing SameSite=None.
#
# browser ──> frontend(nginx) ──┬─> / SPA bundle
# ├─> /api backend
# ├─> /oauth2 /login /logout backend (BFF)
# └─> /auth keycloak
#
# Secrets here are development values and are meant to be replaced by the
# deployment; they are named in .env so nothing is baked into an image.
name: tech-log
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-tech_log}
POSTGRES_USER: ${POSTGRES_USER:-tech_log}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
TZ: UTC
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tech_log} -d ${POSTGRES_DB:-tech_log}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
networks: [tech-log]
redis:
# Holds the Studio session. Losing it signs everyone out; it holds nothing
# else, so it is not backed by a volume on purpose.
image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
networks: [tech-log]
keycloak:
image: quay.io/keycloak/keycloak:26.7.0
command: ["start-dev", "--import-realm", "--http-relative-path=/auth"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD}
KC_HTTP_ENABLED: "true"
# Behind nginx: Keycloak must build its URLs from the forwarded host, or
# the redirect back from the login page points at the container.
KC_HOSTNAME: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}/auth
KC_HOSTNAME_STRICT: "false"
KC_PROXY_HEADERS: xforwarded
KC_HEALTH_ENABLED: "true"
volumes:
- ${KEYCLOAK_IMPORT_DIR:-./deploy/keycloak}:/opt/keycloak/data/import:ro
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /auth/health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"]
interval: 15s
timeout: 5s
retries: 20
start_period: 40s
restart: unless-stopped
networks: [tech-log]
backend:
image: ${BACKEND_IMAGE:-tech-log-backend:local}
volumes:
- tls-public:/tls-public:ro
# The image entrypoint is `java -jar /app/app.jar`; this wraps it so the
# frontend's certificate lands in the JVM truststore first. Without it the
# OIDC metadata fetch fails PKIX validation and the process crash-loops.
entrypoint:
- /bin/sh
- -c
- |
until [ -f /tls-public/server.crt ]; do sleep 1; done
# The image runs as a non-root user, so the JVM's own cacerts is not
# writable — importing there silently did nothing and the metadata fetch
# kept failing PKIX. Copy it somewhere writable, add the edge
# certificate, and point the JVM at that.
cp "/opt/java/openjdk/lib/security/cacerts" /tmp/truststore.jks
keytool -importcert -noprompt -trustcacerts -alias tech-log-edge \
-file /tls-public/server.crt \
-keystore /tmp/truststore.jks -storepass changeit
exec java \
-Djavax.net.ssl.trustStore=/tmp/truststore.jks \
-Djavax.net.ssl.trustStorePassword=changeit \
-jar /app/app.jar
environment:
SPRING_PROFILES_ACTIVE: local
# Persistence
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tech_log}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-tech_log}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver
SPRING_FLYWAY_ENABLED: "true"
SPRING_JPA_HIBERNATE_DDL_AUTO: none
CA_SKELETON_PERSISTENCE_VENDOR: postgresql
# BFF session
CA_SKELETON_SECURITY_AUTH_MODE: redis-session
CA_SKELETON_SECURITY_SESSION_COOKIE_NAME: TECHLOG_SESSION
APP_REDIS_ENABLED: "true"
APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED: "true"
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: "6379"
# OIDC. The issuer is the browser-facing URL because the tokens carry it
# and the browser is redirected there; the container reaches the same
# Keycloak through nginx on the compose network.
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID: ${OIDC_CLIENT_ID:-tech-log-bff}
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_SCOPE: openid,profile,email
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_AUTHORIZATION_GRANT_TYPE: authorization_code
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_REDIRECT_URI: "${PUBLIC_ORIGIN}/login/oauth2/code/keycloak"
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI: ${PUBLIC_ORIGIN}/auth/realms/${KEYCLOAK_REALM:-tech-log}
APP_STUDIO_AUTHOR_ROLE: ${STUDIO_AUTHOR_ROLE:-studio-author}
APP_STUDIO_POST_LOGIN_REDIRECT: "${PUBLIC_ORIGIN}/studio"
# Behind a proxy: trust the forwarded headers nginx sets, so redirect URLs
# and client IPs are the browser's, not the container's.
APP_SERVER_FORWARD_HEADERS_STRATEGY: framework
TZ: UTC
# The issuer in a token is the browser-facing URL, and the backend has to
# both validate that exact string and fetch the realm's metadata from it.
# Inside the container that host does not resolve, so discovery failed and
# the process crash-looped. Mapping the public host to the docker gateway
# makes one URL work from both sides — the browser reaches nginx directly,
# the backend reaches the same nginx through the published port.
extra_hosts:
- "${PUBLIC_HOST:?set PUBLIC_HOST}:host-gateway"
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
keycloak: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/healthcheck"]
interval: 15s
timeout: 5s
retries: 10
start_period: 45s
restart: unless-stopped
networks: [tech-log]
frontend:
image: ${FRONTEND_IMAGE:-tech-log-frontend:local}
# The backend fetches the realm metadata from the same HTTPS origin the
# browser uses, so it has to trust this certificate. Publishing it to a
# shared volume keeps one certificate for both sides; a deployment that
# mounts a CA-issued certificate over /etc/nginx/tls needs neither this nor
# the backend's import step.
volumes:
- tls-public:/tls-public
command:
- /bin/sh
- -c
- "cp /etc/nginx/tls/server.crt /tls-public/server.crt && exec nginx -g 'daemon off;'"
ports:
- "${PUBLIC_HTTP_PORT:-8088}:80"
- "${PUBLIC_PORT:-8443}:443"
depends_on:
backend: { condition: service_started }
keycloak: { condition: service_started }
restart: unless-stopped
networks: [tech-log]
networks:
tech-log:
driver: bridge
volumes:
postgres-data:
tls-public:
+5
View File
@@ -5,6 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Tech Log frontend" /> <meta name="description" content="Tech Log frontend" />
<title>Tech Log</title> <title>Tech Log</title>
<!-- public/favicon.svg 는 빌드가 dist 루트로 복사하고 nginx 도 서빙하지만,
참조가 없어 브라우저는 /favicon.ico 를 찾다가 404 를 받고 기본 아이콘을
띄우고 있었다. %BASE_URL% 은 Vite 가 base 로 치환한다 — 경로 프리픽스
배포(/dev/)에서도 같은 파일을 가리키게 하려면 절대경로여선 안 된다. -->
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+228
View File
@@ -0,0 +1,228 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* Emits the nginx configuration the deployed frontend is served with.
*
* Generated rather than hand-written because three files already decide what it
* must say, and a copy of them would drift: `dist/tech-log-serving-contract.json`
* (which paths are SPA routes and what a miss answers with),
* `config/hosting/security-headers.json`, and `config/hosting/cache-policy.json`.
* The repository had no frontend deployment artifact at all — no Dockerfile, no
* server config — so those two hosting files described a contract nothing
* fulfilled: `dist/server.mjs` applies neither, answering `no-cache` for hashed
* assets and sending no security headers.
*
* This serves static files only. TLS and the BFF paths belong to the edge: the
* deployment's own nginx terminates HTTPS and sends `/api`, the OIDC redirect
* chain and the identity provider to the backend directly (in Kubernetes,
* Traefik does). A second proxy hop here would only add a place for the two
* routing tables to disagree.
*
* The base path comes from `VITE_ROUTER_BASE_PATH`, the same value the bundle is
* built with: served under a prefix, every route and asset lives under it too.
*/
const DIST = "dist";
const OUT = path.join(DIST, "nginx.conf");
type ServingContract = Readonly<{
publicSpaPaths: readonly string[];
studioPathPrefix: string;
studioSpaPathPatterns: readonly string[];
notFound: Readonly<{ status: number; contentType: string; body: string }>;
}>;
type HostingHeaders = Readonly<{ headers: Readonly<Record<string, string>> }>;
type CachePolicy = Readonly<{
surfaces: Readonly<
Record<
string,
Readonly<{
path?: string;
pathPattern?: string;
cacheControl?: string;
securityHeaders?: boolean;
}>
>
>;
}>;
async function readJson<T>(file: string): Promise<T> {
return JSON.parse(await readFile(file, "utf8")) as T;
}
/** nginx location matching is not regex-escaped for us; only `=` exact paths are literal. */
function exactLocation(pathname: string): string {
return pathname;
}
/**
* A JS regex from the contract translated for nginx. Both use PCRE-ish syntax
* for what the contract uses (`^`, `$`, `[^/]+`, alternation), so the pattern
* carries over unchanged — asserted rather than assumed, because a pattern that
* silently failed to translate would open a Studio route to the 404 branch.
*/
function studioRegex(pattern: string): string {
// nginx uses PCRE, so anchors, character classes, alternation and plain groups
// carry over as written. Lookaround and backreferences do not translate the
// same way and would silently change which paths match, so they are refused.
if (/\(\?[=!<]|\\[1-9]/.test(pattern)) {
throw new Error(
`studio SPA pattern uses a construct this generator does not translate: ${pattern}`,
);
}
return pattern;
}
function headerDirectives(
headers: Readonly<Record<string, string>>,
indent: string,
): string {
return Object.entries(headers)
.map(([name, value]) => `${indent}add_header ${name} "${value}" always;`)
.join("\n");
}
async function main(): Promise<void> {
const contract = await readJson<ServingContract>(
path.join(DIST, "tech-log-serving-contract.json"),
);
const security = await readJson<HostingHeaders>(
"config/hosting/security-headers.json",
);
const cache = await readJson<CachePolicy>("config/hosting/cache-policy.json");
const surfaces = cache.surfaces;
const indexCache = surfaces["index"]?.cacheControl ?? "no-cache";
const configCache = surfaces["runtimeConfig"]?.cacheControl ?? "no-store";
const manifestCache = surfaces["releaseManifest"]?.cacheControl ?? "no-store";
const assetCache = surfaces["hashedAsset"]?.cacheControl ?? "no-cache";
const secure = headerDirectives(security.headers, " ");
// The bundle's own base path. `/` for a deployment at the domain root, `/dev/`
// for one served under a prefix — the routes below have to carry it or nginx
// matches paths the browser never asks for.
const rawBase = process.env["VITE_ROUTER_BASE_PATH"] ?? "/";
const basePath = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
const [notFoundType, notFoundCharsetParam] = contract.notFound.contentType
.split(";")
.map((part) => part.trim());
const notFoundCharset = (notFoundCharsetParam ?? "charset=utf-8")
.replace(/^charset=/i, "")
.toLowerCase();
const publicLocations = contract.publicSpaPaths
.map(
(pathname) => ` location = ${basePath}${exactLocation(pathname)} {
${secure}
add_header Cache-Control "${indexCache}" always;
try_files /index.html =404;
}`,
)
.join("\n\n");
const studioLocations = contract.studioSpaPathPatterns
.map(
(pattern) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} {
${secure}
add_header Cache-Control "${indexCache}" always;
try_files /index.html =404;
}`,
)
.join("\n\n");
const conf = `# Generated by scripts/generate-nginx-config.ts — do not edit.
# Sources: dist/tech-log-serving-contract.json, config/hosting/security-headers.json,
# config/hosting/cache-policy.json
#
# Plain HTTP on purpose: the edge terminates TLS and this container is only ever
# reached from inside the deployment network.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# The bundle is small and already compressed at rest by the build; gzip here
# covers the JSON surfaces and index.html.
gzip on;
gzip_types text/css application/javascript text/javascript application/json;
gzip_min_length 1024;
# Static surfaces, per config/hosting/cache-policy.json
# ---------------------------------------------------------------------------
location = ${basePath}/config.json {
alias /usr/share/nginx/html/config.json;
${secure}
add_header Cache-Control "${configCache}" always;
}
location = ${basePath}/release-manifest.json {
alias /usr/share/nginx/html/release-manifest.json;
${secure}
add_header Cache-Control "${manifestCache}" always;
}
# Content-hashed filenames, so the long TTL is safe and revalidation is waste.
location ${basePath}/assets/ {
# alias, not root + URI: under a base path the request is /dev/assets/x.js
# while the file is dist/assets/x.js, so root would look for
# dist/dev/assets/x.js and answer 404 for every script on the page.
alias /usr/share/nginx/html/assets/;
add_header Cache-Control "${assetCache}" always;
}
# Source maps are not published (cache-policy sourceMap.public = false).
location ~ \\.map$ {
return 404;
}
# ---------------------------------------------------------------------------
# SPA routes. Enumerated from the serving contract rather than a catch-all:
# a path that is not a real route answers 404 instead of a 200 shell, which is
# what tells a crawler the difference.
# ---------------------------------------------------------------------------
${publicLocations}
${studioLocations}
location = ${basePath}/favicon.svg {
alias /usr/share/nginx/html/favicon.svg;
add_header Cache-Control "${assetCache}" always;
}
location = ${basePath}/media/ {
return 404;
}
location ${basePath}/media/ {
alias /usr/share/nginx/html/media/;
add_header Cache-Control "${assetCache}" always;
}
# Anything else is not a route this deployment serves.
location / {
# The contract states the content type with its charset attached
# (text/plain;charset=UTF-8), but nginx takes the two separately —
# default_type rejects a parameter outright.
default_type ${notFoundType};
charset ${notFoundCharset};
return ${contract.notFound.status} "${contract.notFound.body}";
}
}
`;
await writeFile(OUT, conf, "utf8");
process.stdout.write(
`nginx config: ${OUT} (${contract.publicSpaPaths.length} public routes, ` +
`${contract.studioSpaPathPatterns.length} studio patterns)\n`,
);
}
await main();