Compose convention: dev/prod target switch, HMR-friendly Caddy labels

Aligns with the warehack-ing host convention (matches birdcage-docs):

Dockerfile — multi-stage with named targets:
  base   shared npm ci + COPY
  dev    npx astro dev --host 0.0.0.0 --port 4321  (HMR enabled)
  build  npx astro build (produces /app/dist)
  prod   caddy:2-alpine serves /srv with /health probe + HEALTHCHECK

docker-compose.yml — picks the target via APP_ENV in .env:
  build.target = ${APP_ENV:-dev}
  caddy.reverse_proxy upstream port = ${APP_PORT:-4321}
  Adds the streaming/HMR caddy labels CLAUDE.md requires for Vite-over-
  Caddy: flush_interval=-1, transport.read/write_timeout=0, keepalive,
  stream_timeout=24h, stream_close_delay=5s
  Volume mounts ./src, ./public, astro.config.mjs (live in dev, harmless
  in prod since the prod stage doesn't reference /app)

astro.config.mjs — vite.server.hmr block now picks up VITE_HMR_HOST
env var and configures host/protocol/clientPort for wss-on-443 HMR
through Caddy. Falls back to undefined when unset so plain
'npm run dev' still works.

Caddyfile — adds /health endpoint for the Dockerfile HEALTHCHECK,
SPA-style try_files fallback chain (path -> path/index.html -> /index.html).

Makefile — adds 'make dev' / 'make prod' that rewrite APP_ENV+APP_PORT
in .env then rebuild. Tail-logs after up/restart so you see startup
diagnostics without a separate 'make logs'.

.env.example — five vars (COMPOSE_PROJECT_NAME, APP_ENV, APP_PORT,
PUBLIC_DOMAIN, VITE_HMR_HOST), defaulting to prod mode against the
public hai-omni-pro-ii.warehack.ing domain. .env stays gitignored so
each environment (local, remote) can override.

Local container rebuilt + recreated, healthy, /health returns 200,
PUBLIC_DOMAIN locally still set to .l.warehack.ing in the host .env.
This commit is contained in:
Ryan Malloy 2026-05-10 18:05:54 -06:00
parent 0525895dbe
commit 17cd57f1bf
6 changed files with 118 additions and 97 deletions

View File

@ -1,3 +1,17 @@
# Copy to .env and adjust as needed. # Copy to .env and edit. `make dev` and `make prod` rewrite APP_ENV/APP_PORT
COMPOSE_PROJECT=hai-omni-docs # in place, so leave those two lines as-is — the Makefile owns them.
DOMAIN=hai-omni-pro-ii.l.warehack.ing
# Container + network names. Keep distinct so multiple stacks don't clash.
COMPOSE_PROJECT_NAME=omni-pca-docs
# Mode toggle. dev = Astro dev server + HMR on 4321; prod = static caddy on 80.
APP_ENV=prod
APP_PORT=80
# Public hostname Caddy will route to this container. Match the DNS record.
PUBLIC_DOMAIN=hai-omni-pro-ii.warehack.ing
# Vite HMR client hostname — only used in dev. Same as PUBLIC_DOMAIN when
# you want to develop through the live reverse proxy with a real cert,
# or 'localhost' for local-only.
VITE_HMR_HOST=hai-omni-pro-ii.warehack.ing

View File

@ -1,22 +1,13 @@
# Inner Caddy: just serves the static dist on :80.
# The host's caddy-docker-proxy handles TLS and routing for the public hostname.
:80 { :80 {
root * /srv root * /srv
encode zstd gzip encode gzip
handle /health {
respond "ok" 200
}
handle {
try_files {path} {path}/index.html {path}/ /index.html
file_server file_server
# SPA-ish fallback: prefer the directory's index.html, then a 404 page.
try_files {path} {path}/ /404.html
header {
# Light security defaults; the outer Caddy can override.
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "interest-cohort=()"
} }
# Long cache for fingerprinted assets emitted by Astro.
@hashed path_regexp \.[A-Za-z0-9_-]{8,}\.(js|css|woff2?|svg|png|jpg|jpeg|webp|avif)$
header @hashed Cache-Control "public, max-age=31536000, immutable"
} }

View File

@ -1,39 +1,34 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
#
# Multi-stage Dockerfile matching the warehack-ing services convention:
# * dev — Astro dev server with HMR, source-mounted via compose volumes
# * build — runs `astro build`, produces /app/dist
# * prod — Caddy serves the static dist on port 80, plus a /health probe
#
# Pick the stage at compose-build time via APP_ENV={dev,prod} in .env.
# ---- builder ---- # ── base: shared dependency install ──────────────────────────────
# Pinned to lts-alpine so it works against Docker Hub's cached image set. FROM node:lts-slim AS base
# Bump to node:25-alpine when 25 stabilises in the registry.
FROM node:lts-alpine AS builder
ENV ASTRO_TELEMETRY_DISABLED=1 \
NODE_ENV=production \
CI=true
WORKDIR /app WORKDIR /app
ENV ASTRO_TELEMETRY_DISABLED=1
# Install deps in their own layer for better caching.
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \ RUN npm ci
npm ci --include=dev
# Copy the rest of the source and build.
COPY . . COPY . .
RUN npm run build
# ---- runtime ---- # ── dev: Astro dev server with HMR ──────────────────────────────
FROM caddy:latest AS runtime FROM base AS dev
ENV HOST=0.0.0.0
EXPOSE 4321
CMD ["npx", "astro", "dev", "--host", "0.0.0.0", "--port", "4321"]
# Run as non-root. # ── build: static site generation ───────────────────────────────
RUN addgroup -S docs && adduser -S -G docs docs FROM base AS build
RUN npx astro build
WORKDIR /srv # ── prod: Caddy serving static files ────────────────────────────
FROM caddy:2-alpine AS prod
COPY --from=builder /app/dist /srv
COPY Caddyfile /etc/caddy/Caddyfile COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv
# caddy:2-alpine ships with a sane default user model; keep ports unprivileged.
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
USER docs CMD wget -qO- http://127.0.0.1:80/health || exit 1
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

View File

@ -1,50 +1,37 @@
# hai-omni-docs Makefile .PHONY: up down logs rebuild dev prod clean ci
#
# Local dev: make dev (Astro dev server, hot reload, no docker)
# Smoke build: make ci (npm run build — verify static output)
# Deploy: make build && make up
#
# Reads .env if present so DOMAIN / COMPOSE_PROJECT propagate to compose.
SHELL := /bin/bash up:
COMPOSE := docker compose docker compose up -d --build
.PHONY: help dev install ci build up down restart logs ps clean
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
install: ## Install npm dependencies
npm install
dev: ## Start Astro dev server with hot reload (host)
npm run dev
ci: ## Smoke-test the static build locally
npm run build
build: ## Build the docker image
$(COMPOSE) build
up: ## Start the docs container in the background
$(COMPOSE) up -d
@echo @echo
@echo "==> Tailing recent logs ..." @echo "==> Tailing recent logs ..."
@$(COMPOSE) logs -f --tail=20 @docker compose logs -f --tail=20
down: ## Stop and remove the docs container down:
$(COMPOSE) down docker compose down
restart: ## Recreate the docs container logs:
$(COMPOSE) up -d --force-recreate docker compose logs -f --tail=50
@$(COMPOSE) logs -f --tail=20
logs: ## Follow logs rebuild:
$(COMPOSE) logs -f --tail=50 docker compose down
docker compose up -d --build
@docker compose logs -f --tail=20
ps: ## Show container status # Flip .env into dev mode (Astro dev server + HMR on 4321) and rebuild.
$(COMPOSE) ps dev:
@sed -i 's/^APP_ENV=.*/APP_ENV=dev/' .env
@sed -i 's/^APP_PORT=.*/APP_PORT=4321/' .env
$(MAKE) rebuild
clean: ## Remove build artifacts # Flip .env into prod mode (Caddy serving static dist on 80) and rebuild.
rm -rf dist .astro prod:
@sed -i 's/^APP_ENV=.*/APP_ENV=prod/' .env
@sed -i 's/^APP_PORT=.*/APP_PORT=80/' .env
$(MAKE) rebuild
clean:
docker compose down --rmi local -v
# Smoke-test the static build outside Docker.
ci:
npm ci && npm run build

View File

@ -84,6 +84,17 @@ export default defineConfig({
vite: { vite: {
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
// HMR behind Caddy reverse proxy: explicit hostname + wss + 443
// (per CLAUDE.md "HMR Behind Reverse Proxy" section). Falls
// back to defaults when no VITE_HMR_HOST env var is set so
// `npm run dev` outside Docker still works.
hmr: process.env.VITE_HMR_HOST
? {
host: process.env.VITE_HMR_HOST,
protocol: 'wss',
clientPort: 443,
}
: undefined,
}, },
}, },
}); });

View File

@ -1,18 +1,41 @@
# Astro + Starlight docs site for warehack.ing/omni-pca.
#
# Two modes via APP_ENV in .env:
# APP_ENV=dev APP_PORT=4321 astro dev with HMR (volume-mounted)
# APP_ENV=prod APP_PORT=80 caddy serving the built static dist
#
# Switch with `make dev` or `make prod` (both rebuild + restart).
services: services:
docs: docs:
build: build:
context: . context: .
dockerfile: Dockerfile target: ${APP_ENV:-dev}
image: ${COMPOSE_PROJECT:-hai-omni-docs}/docs:latest container_name: ${COMPOSE_PROJECT_NAME:-omni-pca-docs}
container_name: ${COMPOSE_PROJECT:-hai-omni-docs}-docs
restart: unless-stopped restart: unless-stopped
environment:
- PUBLIC_DOMAIN=${PUBLIC_DOMAIN}
- VITE_HMR_HOST=${VITE_HMR_HOST}
networks: networks:
- caddy - caddy
expose:
- "80"
labels: labels:
caddy: ${DOMAIN:-hai-omni-pro-ii.l.warehack.ing} caddy: ${PUBLIC_DOMAIN}
caddy.reverse_proxy: "{{upstreams 80}}" caddy.reverse_proxy: "{{upstreams ${APP_PORT:-4321}}}"
# Streaming/HMR-friendly Caddy config (CLAUDE.md "HMR Behind Caddy"):
caddy.reverse_proxy.flush_interval: "-1"
caddy.reverse_proxy.transport: http
caddy.reverse_proxy.transport.read_timeout: "0"
caddy.reverse_proxy.transport.write_timeout: "0"
caddy.reverse_proxy.transport.keepalive: 5m
caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
caddy.reverse_proxy.stream_timeout: 24h
caddy.reverse_proxy.stream_close_delay: 5s
# Volume mounts only matter in dev mode; in prod they're harmless
# (the prod stage doesn't reference /app — it serves /srv from caddy).
volumes:
- ./src:/app/src
- ./public:/app/public
- ./astro.config.mjs:/app/astro.config.mjs
networks: networks:
caddy: caddy: