diff --git a/.github/workflows/release-deploy.yml b/.github/workflows/release-deploy.yml
index 7400aa91..1ecde4a7 100644
--- a/.github/workflows/release-deploy.yml
+++ b/.github/workflows/release-deploy.yml
@@ -137,6 +137,7 @@ jobs:
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
export IMAGE_TAG="$IMAGE_TAG"
+ export COMPOSE_PROFILES=observability
docker compose pull
docker compose up -d --remove-orphans --no-build
diff --git a/.github/workflows/stack-tests.yml b/.github/workflows/stack-tests.yml
index 7ff6aac4..9f695916 100644
--- a/.github/workflows/stack-tests.yml
+++ b/.github/workflows/stack-tests.yml
@@ -194,7 +194,7 @@ jobs:
cache-from: type=gha,scope=zookeeper-certgen
cache-to: type=gha,mode=max,scope=zookeeper-certgen
- # ── Frontend (dev for E2E, prod for scanning/deployment) ─────────
+ # ── Frontend (nginx + SSL) ─────────────────────────────────────
- name: Build frontend image
uses: docker/build-push-action@v6
with:
@@ -205,16 +205,6 @@ jobs:
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
- - name: Build frontend-prod image
- uses: docker/build-push-action@v6
- with:
- context: ./frontend
- file: ./frontend/Dockerfile.prod
- load: true
- tags: integr8scode-frontend-prod:latest
- cache-from: type=gha,scope=frontend-prod
- cache-to: type=gha,mode=max,scope=frontend-prod
-
# ── Push all images to GHCR in parallel ────────────────────────
- name: Push all images to GHCR
if: ${{ !github.event.pull_request.head.repo.fork }}
@@ -227,13 +217,12 @@ jobs:
docker tag integr8scode-backend:latest "$IMG/backend:$TAG"
docker tag integr8scode-cert-generator:latest "$IMG/cert-generator:$TAG"
docker tag integr8scode-zookeeper-certgen:latest "$IMG/zookeeper-certgen:$TAG"
- docker tag integr8scode-frontend:latest "$IMG/frontend-dev:$TAG"
- docker tag integr8scode-frontend-prod:latest "$IMG/frontend:$TAG"
+ docker tag integr8scode-frontend:latest "$IMG/frontend:$TAG"
- # Push all 6 images in parallel, tracking each PID
+ # Push all 5 images in parallel, tracking each PID
declare -A PIDS
for name in base backend cert-generator zookeeper-certgen \
- frontend-dev frontend; do
+ frontend; do
docker push "$IMG/$name:$TAG" &
PIDS[$name]=$!
done
diff --git a/docker-compose.yaml b/docker-compose.yaml
index e49a6bc8..aedb618c 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -127,7 +127,7 @@ services:
start_period: 3s
frontend:
- image: ghcr.io/hardmax71/integr8scode/frontend-dev:${IMAGE_TAG:-latest}
+ image: ghcr.io/hardmax71/integr8scode/frontend:${IMAGE_TAG:-latest}
container_name: frontend
build:
context: ./frontend
@@ -138,17 +138,14 @@ services:
backend:
condition: service_started
volumes:
- - ./frontend:/app
- - /app/node_modules
- - ./frontend/certs:/app/certs:ro
+ - ./frontend/certs:/etc/nginx/certs:ro
- shared_ca:/shared_ca:ro
ports:
- "5001:5001"
networks:
- app-network
environment:
- - VITE_BACKEND_URL=https://backend:443
- - NODE_EXTRA_CA_CERTS=/shared_ca/ca.pem
+ - BACKEND_URL=https://backend:443
healthcheck:
test: ["CMD-SHELL", "curl -k -f -s https://localhost:5001 >/dev/null || exit 1"]
interval: 2s
@@ -159,8 +156,8 @@ services:
grafana:
container_name: grafana
- image: grafana/grafana:12.3.1
profiles: ["observability"]
+ image: grafana/grafana:12.3.1
user: "472"
ports:
- "3000:3000"
diff --git a/docs/operations/cicd.md b/docs/operations/cicd.md
index ff452d85..b22f10b9 100644
--- a/docs/operations/cicd.md
+++ b/docs/operations/cicd.md
@@ -92,7 +92,7 @@ no setup to overlap.
## Stack Tests (the main workflow)
-This is the core testing workflow. It builds all 6 container images, pushes them to GHCR with immutable SHA-based
+This is the core testing workflow. It builds all 5 container images, pushes them to GHCR with immutable SHA-based
tags, then runs E2E tests on separate runners that pull images from the registry.
```mermaid
@@ -103,7 +103,7 @@ graph TD
end
subgraph "Phase 2: Build"
- C["Build & Push 6 Images to GHCR"]
+ C["Build & Push 5 Images to GHCR"]
end
subgraph "Phase 3: E2E (parallel runners)"
@@ -131,7 +131,7 @@ the image build is skipped entirely.
### Phase 2: Build and push
-All 6 images are built on a single runner and pushed to GHCR with an immutable `sha-<7chars>` tag:
+All 5 images are built on a single runner and pushed to GHCR with an immutable `sha-<7chars>` tag:
| Image | Source |
|----------------------|---------------------------------------------|
@@ -139,19 +139,17 @@ All 6 images are built on a single runner and pushed to GHCR with an immutable `
| `backend` | `backend/Dockerfile` |
| `cert-generator` | `cert-generator/Dockerfile` |
| `zookeeper-certgen` | `backend/zookeeper/Dockerfile.certgen` |
-| `frontend-dev` | `frontend/Dockerfile` |
-| `frontend` | `frontend/Dockerfile.prod` |
+| `frontend` | `frontend/Dockerfile` |
Workers reuse the `backend` image with different `command:` overrides in docker-compose, so no separate worker images
-are needed. Of these 6 images, 5 are scanned by Trivy and promoted to `latest` in the
-[Docker Scan & Promote](#docker-scan-promote) workflow. The `frontend-dev` image is excluded — it's the Rollup dev
-server build used only for E2E tests in CI and is never deployed to production.
+are needed. All 5 images are scanned by Trivy and promoted to `latest` in the
+[Docker Scan & Promote](#docker-scan-promote) workflow.
The base image is cached separately as a zstd-compressed tarball since its dependencies rarely change. The backend
image depends on it via `--build-context base=docker-image://integr8scode-base:latest`. Utility and frontend images
use GHA layer caching.
-All 6 images are pushed to GHCR in parallel, with each push tracked by PID so individual failures are reported:
+All 5 images are pushed to GHCR in parallel, with each push tracked by PID so individual failures are reported:
```yaml
declare -A PIDS
@@ -440,7 +438,7 @@ Playwright browsers are cached by `package-lock.json` hash. On cache hit, only s
### Parallel image push
-All 6 images are pushed to GHCR concurrently using background processes with PID tracking. Each push failure is
+All 5 images are pushed to GHCR concurrently using background processes with PID tracking. Each push failure is
reported individually via `::error::` annotations.
## Running locally
diff --git a/docs/operations/nginx-configuration.md b/docs/operations/nginx-configuration.md
index ae0e90d8..55db200a 100644
--- a/docs/operations/nginx-configuration.md
+++ b/docs/operations/nginx-configuration.md
@@ -99,9 +99,15 @@ block defines **any** `proxy_set_header`, all parent-level `proxy_set_header` di
Grafana is only available when the `observability` Docker Compose profile is active. Without it, requests to `/grafana/`
return 502 (expected).
+The `resolver` + `set $upstream` pattern is used here so nginx resolves `grafana` at request time instead of at startup.
+Without this, nginx would fail to start when the Grafana container is not running (e.g., when the `observability` profile
+is not active). Docker's embedded DNS resolver (`127.0.0.11`) handles container name resolution on the internal network.
+
| Directive | Purpose |
|---------------------------------------------------------------|------------------------------------------------------------------------------|
-| `proxy_pass http://grafana:3000` | Forward requests to the Grafana container on the internal Docker network |
+| `resolver 127.0.0.11 valid=30s ipv6=off` | Use Docker's embedded DNS; cache results for 30s; skip IPv6 (Docker bridge is IPv4) |
+| `set $grafana_upstream http://grafana:3000` | Store upstream in a variable so nginx resolves it at request time, not startup |
+| `proxy_pass $grafana_upstream` | Forward requests to the Grafana container on the internal Docker network |
| `proxy_set_header Host $host` | Forward the original `Host` header so Grafana sees the client's hostname |
| `proxy_set_header X-Real-IP $remote_addr` | Pass the client's real IP address |
| `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` | Append client IP to the proxy chain header |
@@ -244,7 +250,7 @@ The nginx configuration uses environment variable substitution via the official
feature:
```dockerfile
---8<-- "frontend/Dockerfile.prod:production_stage"
+--8<-- "frontend/Dockerfile:production_stage"
```
The nginx image automatically processes files in `/etc/nginx/templates/*.template` and outputs the result to
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
index 3f73aed7..b7bd6489 100644
--- a/frontend/.dockerignore
+++ b/frontend/.dockerignore
@@ -21,6 +21,9 @@ build/
# Testing
coverage/
.nyc_output/
+test-results/
+playwright-report/
+e2e/
# Logs
logs/
@@ -51,9 +54,8 @@ temp/
*.swp
*.orig
-# Git
+# Git (keep .gitignore — Tailwind CSS v4 needs it for content detection)
.git/
-.gitignore
.gitattributes
# Documentation
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 00000000..23f50edb
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+public/build/
+dist/
+coverage/
+test-results/
+playwright-report/
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 965c92d0..3b7f9438 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -1,19 +1,35 @@
-FROM node:22-slim
+# Build stage
+FROM node:22-alpine AS builder
WORKDIR /app
-# Install curl for healthcheck (not included in slim image)
-RUN apt-get update && apt-get install -y --no-install-recommends curl \
- && rm -rf /var/lib/apt/lists/*
-
-# Copy lockfile for reproducible, faster installs
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
+RUN npm run build
+
+# --8<-- [start:production_stage]
+# Production stage
+FROM nginx:1.27-alpine
+
+# Install curl for healthcheck
+RUN apk add --no-cache curl
+
+# Copy built static files
+COPY --from=builder /app/public /usr/share/nginx/html
+
+# Copy nginx config template (envsubst runs at container startup)
+# The nginx image automatically processes /etc/nginx/templates/*.template
+# and outputs to /etc/nginx/conf.d/ with the .template suffix removed
+COPY nginx.conf.template /etc/nginx/templates/default.conf.template
-COPY openssl.cnf /app/openssl.cnf
+# Create writable directories for nginx (required for read-only root filesystem)
+# and certs directory for TLS certificates mounted at runtime
+RUN mkdir -p /var/cache/nginx /var/run /etc/nginx/certs && \
+ chown -R nginx:nginx /var/cache/nginx /var/run /etc/nginx/certs
EXPOSE 5001
-CMD ["npm", "run", "dev"]
\ No newline at end of file
+CMD ["nginx", "-g", "daemon off;"]
+# --8<-- [end:production_stage]
diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod
deleted file mode 100644
index f99de6ce..00000000
--- a/frontend/Dockerfile.prod
+++ /dev/null
@@ -1,31 +0,0 @@
-# Build stage
-FROM node:22-alpine AS builder
-
-WORKDIR /app
-
-COPY package.json package-lock.json ./
-RUN npm ci
-
-COPY . .
-RUN npm run build
-
-# --8<-- [start:production_stage]
-# Production stage
-FROM nginx:alpine
-
-# Copy built static files
-COPY --from=builder /app/public /usr/share/nginx/html
-
-# Copy nginx config template (envsubst runs at container startup)
-# The nginx image automatically processes /etc/nginx/templates/*.template
-# and outputs to /etc/nginx/conf.d/ with the .template suffix removed
-COPY nginx.conf.template /etc/nginx/templates/default.conf.template
-
-# Create writable directories for nginx (required for read-only root filesystem)
-RUN mkdir -p /var/cache/nginx /var/run && \
- chown -R nginx:nginx /var/cache/nginx /var/run
-
-EXPOSE 5001
-
-CMD ["nginx", "-g", "daemon off;"]
-# --8<-- [end:production_stage]
diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template
index 3d45ac55..88df162c 100644
--- a/frontend/nginx.conf.template
+++ b/frontend/nginx.conf.template
@@ -1,14 +1,21 @@
# --8<-- [start:server_block]
server {
- listen 5001;
+ listen 5001 ssl;
server_name _;
+ ssl_certificate /etc/nginx/certs/server.crt;
+ ssl_certificate_key /etc/nginx/certs/server.key;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
+ ssl_prefer_server_ciphers on;
+
root /usr/share/nginx/html;
index index.html;
# --8<-- [end:server_block]
# --8<-- [start:security_headers]
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; connect-src 'self';";
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
@@ -85,7 +92,9 @@ server {
# --8<-- [start:grafana_proxy]
location /grafana/ {
- proxy_pass http://grafana:3000;
+ resolver 127.0.0.11 valid=30s ipv6=off;
+ set $grafana_upstream http://grafana:3000;
+ proxy_pass $grafana_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 715c11b3..e7662ded 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -70,6 +70,7 @@
"http-proxy": "^1.18.1",
"jsdom": "^27.4.0",
"monocart-reporter": "^2.10.0",
+ "postcss-lightningcss": "^1.0.2",
"rollup-plugin-serve": "^3.0.0",
"svelte-check": "^4.3.6",
"svelte-eslint-parser": "^1.4.1",
@@ -6698,6 +6699,22 @@
"postcss": "^8.2.15"
}
},
+ "node_modules/postcss-lightningcss": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/postcss-lightningcss/-/postcss-lightningcss-1.0.2.tgz",
+ "integrity": "sha512-jI9gBe/2/ZEDYGDAHEHKbGLA3Dfn2uUTUCVsP3mDxpvmX6ifDdFqYB00GNRdny676gTcfo7XUCQoc4OYz20/TA==",
+ "dev": true,
+ "dependencies": {
+ "browserslist": "^4.19.1",
+ "lightningcss": "^1.22.0"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >= 21"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
"node_modules/postcss-load-config": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 16ec596b..a1d57c3b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -79,6 +79,7 @@
"http-proxy": "^1.18.1",
"jsdom": "^27.4.0",
"monocart-reporter": "^2.10.0",
+ "postcss-lightningcss": "^1.0.2",
"rollup-plugin-serve": "^3.0.0",
"svelte-check": "^4.3.6",
"svelte-eslint-parser": "^1.4.1",
diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs
index 78286dd6..4653db3a 100644
--- a/frontend/postcss.config.cjs
+++ b/frontend/postcss.config.cjs
@@ -1,5 +1,10 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
+ "postcss-lightningcss": {
+ lightningcssOptions: {
+ minify: true,
+ },
+ },
},
-}
\ No newline at end of file
+}
diff --git a/frontend/public/index.html b/frontend/public/index.html
index 6df6c8c3..da0c9786 100644
--- a/frontend/public/index.html
+++ b/frontend/public/index.html
@@ -21,9 +21,7 @@
}
-
-
-
+
diff --git a/frontend/rollup.config.js b/frontend/rollup.config.js
index a6e02157..c995cacc 100644
--- a/frontend/rollup.config.js
+++ b/frontend/rollup.config.js
@@ -175,7 +175,7 @@ export default {
}),
postcss({
extract: 'bundle.css',
- minimize: production,
+ minimize: false,
}),
typescript({
sourceMap: true,