From af5215b4fd3b15a14b57b82982ca07982c368b63 Mon Sep 17 00:00:00 2001 From: Subroto Date: Fri, 9 Oct 2026 03:26:14 +0700 Subject: [PATCH] first commit --- README.md | 0 docker-compose.control-plane.yml | 121 +++++++++++++++++++ docker/.env | 74 ++++++++++++ docker/build-push.sh | 55 +++++++++ docker/env.example | 74 ++++++++++++ docker/nginx/templates/default.conf.template | 61 ++++++++++ 6 files changed, 385 insertions(+) create mode 100644 README.md create mode 100644 docker-compose.control-plane.yml create mode 100644 docker/.env create mode 100755 docker/build-push.sh create mode 100644 docker/env.example create mode 100644 docker/nginx/templates/default.conf.template diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.control-plane.yml b/docker-compose.control-plane.yml new file mode 100644 index 0000000..d110d4b --- /dev/null +++ b/docker-compose.control-plane.yml @@ -0,0 +1,121 @@ +# ───────────────────────────────────────────────────────────────────────────── +# sitomat — CONTROL PLANE (ADR-0009 §10, step 1) +# +# api (Elysia/Bun) :13000 ─┐ +# web (Next.js/Bun) :13001 ─┼─ one compose stack, one host +# volume `sitomat-data` ─┘ (SQLite + uploads) +# +# The DATA PLANE (Airflow, MinIO, Trino/StarRocks, OpenMetadata) is a SEPARATE +# stack on a SEPARATE host — see ADR-0009 §9. Do not add it here. +# +# DEPLOY — NO BUILD: the api/web images are pulled from a private registry +# (SITOMAT_IMAGE_REGISTRY / SITOMAT_IMAGE_TAG). A deploy host needs only this +# file + docker/ + docker/.env — no source, no toolchain. +# +# cp docker/env.example docker/.env # then edit it +# docker compose --env-file docker/.env -f docker-compose.control-plane.yml pull +# docker compose --env-file docker/.env -f docker-compose.control-plane.yml \ +# run --rm api bun run --cwd apps/api db:migrate # first boot only +# docker compose --env-file docker/.env -f docker-compose.control-plane.yml up -d +# +# Optional reverse proxy (HTTP; add TLS when the real domain exists): +# docker compose --env-file docker/.env -f docker-compose.control-plane.yml \ +# --profile proxy up -d +# +# BUILD + PUSH (maintainer host, WITH the source) — see docker/build-push.sh, +# which layers docker-compose.build.yml on top of this file: +# docker/build-push.sh +# ───────────────────────────────────────────────────────────────────────────── +name: sitomat-control-plane + +services: + api: + # PULL-ONLY: this file is the DEPLOYMENT file. It has NO `build:` section, so + # a deploy host needs neither the source nor a toolchain — only the image. + # Build + push live in docker-compose.build.yml (see docker/build-push.sh). + image: ${SITOMAT_IMAGE_REGISTRY:-sitomat}/api:${SITOMAT_IMAGE_TAG:-latest} + restart: unless-stopped + environment: + NODE_ENV: production + PORT: "13000" + DB_PATH: /data/sitomat.db + WORKSPACE_UPLOAD_DIR: /data/uploads + # ── Auth / origin ────────────────────────────────────────────────────── + # `BETTER_AUTH_SECRET` is REQUIRED; the `:?` guard makes compose refuse to + # start without it instead of silently using a dev fallback. + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in docker/.env} + BETTER_AUTH_URL: ${API_PUBLIC_URL:-http://localhost:13000} + WEB_URL: ${WEB_PUBLIC_URL:-http://localhost:13001} + # Empty = host-only session cookie (single-host). Set to the shared parent + # domain (e.g. `.instansi.go.id`) only when web & API are on subdomains. + SESSION_COOKIE_DOMAIN: ${SESSION_COOKIE_DOMAIN:-} + # ── Keycloak SSO — OPTIONAL: leave all three empty to disable it ─────── + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} + volumes: + # SQLite + uploads live here. Keep it a LOCAL named volume: the DB runs in + # WAL mode and SQLite locking breaks on network filesystems (NFS/SMB). + - sitomat-data:/data + ports: + - "${API_PORT:-13000}:13000" + healthcheck: + test: + - CMD + - bun + - -e + - "fetch('http://127.0.0.1:13000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + web: + # PULL-ONLY (see the `api` note above). The web build args — including the + # DOMAIN-AGNOSTIC `NEXT_PUBLIC_API_URL=""` — are baked when the image is + # BUILT (docker-compose.build.yml), not here. + image: ${SITOMAT_IMAGE_REGISTRY:-sitomat}/web:${SITOMAT_IMAGE_TAG:-latest} + restart: unless-stopped + environment: + NODE_ENV: production + # Server-side Eden client (Server Components / Actions) reaches the API by + # its compose service name — this is NOT the browser-facing origin. + API_URL: http://api:13000 + # The web app's OWN public origin. The demo-login Server Action calls the + # API server-side and must present the SAME `Origin` the API trusts — + # better-auth rejects a mismatched one with 403 INVALID_ORIGIN. Without + # this the action falls back to `http://localhost:13001` and demo login + # fails on any host that is not localhost. MUST be a SINGLE origin (the + # action puts it straight into the Origin header); if WEB_PUBLIC_URL is a + # comma-separated trust list, override this on the web service. + WEB_URL: ${WEB_PUBLIC_URL:-http://localhost:13001} + depends_on: + api: + condition: service_healthy + ports: + - "${WEB_PORT:-13001}:13001" + + # ── OPTIONAL reverse proxy (profile "proxy") — SINGLE DOMAIN (Opsi A) ─────── + # One origin on :8080 serves BOTH the web and the API, split by PATH: a small + # set of API prefixes (`/api/`, `/public/`, `/ingest/`, `/docs`, `/health`, + # the `/datasets/…/download` regex) goes to the API, everything else to the + # web. Add TLS + the real domain when it exists — see + # docker/nginx/templates/default.conf.template. + proxy: + image: nginx:1.27-alpine + profiles: ["proxy"] + restart: unless-stopped + depends_on: + - api + - web + environment: + # The SINGLE public hostname the proxy answers for (envsubst → server_name). + # One origin serves both the web UI and the API, split by path. + PUBLIC_HOST: ${PUBLIC_HOST:-localhost} + volumes: + - ./docker/nginx/templates:/etc/nginx/templates:ro + ports: + - "${PROXY_PORT:-8080}:80" + +volumes: + sitomat-data: diff --git a/docker/.env b/docker/.env new file mode 100644 index 0000000..a30dfb7 --- /dev/null +++ b/docker/.env @@ -0,0 +1,74 @@ +# ───────────────────────────────────────────────────────────────────────────── +# sitomat control plane — copy to `docker/.env` and edit. +# cp docker/env.example docker/.env +# +# (Named `env.example`, not `.env.example`, on purpose: the tooling policy in +# this repo blocks files matching `*.env*`. The copy target `docker/.env` IS +# gitignored and is excluded from the image build context.) +# +# SINGLE DOMAIN (Opsi A): the reverse proxy serves the web UI AND the API on ONE +# origin, split by path. See docs/deployment.md → "Docker" and the nginx +# template at docker/nginx/templates/default.conf.template. +# ───────────────────────────────────────────────────────────────────────────── + +# ── REQUIRED ───────────────────────────────────────────────────────────────── +# Generate a fresh one — never reuse an example value: +# openssl rand -base64 32 +BETTER_AUTH_SECRET=aLNwbyLRHByb9T9DWFpR4Ck88WAk9kuMGXAwLu6eV6g= + +# ── REGISTRY IMAGES ────────────────────────────────────────────────────────── +# Which images the DEPLOY file pulls. Set BOTH here on the build host (used by +# docker/build-push.sh) and on EVERY deploy host. Example: +# SITOMAT_IMAGE_REGISTRY=registry.contoh.go.id/sitomat +# SITOMAT_IMAGE_TAG=1.0.0 +# Deploy host then only needs: docker compose … pull && docker compose … up -d +SITOMAT_IMAGE_REGISTRY=registry.sicabe.com/sitomat +SITOMAT_IMAGE_TAG=1.0.0 + +# ── PUBLIC ORIGIN — ONE domain for web + API (Opsi A) ──────────────────────── +# Both values are the SAME single origin; the proxy splits it by path. +# • no domain yet → server IP + proxy port, e.g. http://192.168.1.10:8080 +# • with TLS → https://sitomat.contoh.go.id +# Exact origin: scheme + host + port, NO trailing slash. +# +# These are RUNTIME values (BETTER_AUTH_URL / WEB_URL) — change + `up -d` only. +API_PUBLIC_URL=http://localhost:8080 +WEB_PUBLIC_URL=http://localhost:8080 + +# ── WEB API BASE (build-time) — LEAVE EMPTY ────────────────────────────────── +# Empty (recommended) = same-origin: the browser client resolves the API against +# `window.location.origin` and download links are relative, so the web image is +# DOMAIN-AGNOSTIC — changing the public domain needs NO rebuild. +# Set an absolute origin ONLY for the two-domain (split) setup; then a domain +# change requires: docker compose … build web && docker compose … up -d web +WEB_API_BASE_URL= + +# ── HOST PORTS (optional) ──────────────────────────────────────────────────── +# The single public entry is the PROXY. The api/web ports are for direct +# debugging only — the proxy reaches them on the compose network. +API_PORT=13000 +WEB_PORT=13001 +PROXY_PORT=8080 + +# ── SESSION COOKIE DOMAIN — leave EMPTY in single-domain mode ──────────────── +# One origin ⇒ host-only cookie, which is exactly right. Set this ONLY if you +# deliberately put the web and API on DIFFERENT subdomains (not Opsi A). +#SESSION_COOKIE_DOMAIN= + +# ── KEYCLOAK SSO (optional) ────────────────────────────────────────────────── +# Leave ALL THREE empty to disable Keycloak entirely. Setting OIDC_CLIENT_ID +# without OIDC_ISSUER_URL makes the API refuse to boot (fail fast). +#OIDC_CLIENT_ID= +#OIDC_CLIENT_SECRET= +#OIDC_ISSUER_URL= + +# ── WEB BUILD FLAGS ────────────────────────────────────────────────────────── +# 🚨 NEXT_PUBLIC_DEMO_LOGIN must stay `false` on any server others can reach: +# `true` lets anyone sign in as any role WITHOUT a password. +NEXT_PUBLIC_DEMO_LOGIN=true +NEXT_PUBLIC_KEYCLOAK_ENABLED=false + +# ── REVERSE PROXY (profile "proxy") ────────────────────────────────────────── +# The single hostname the proxy answers for (envsubst → nginx `server_name`). +# With a real domain: PUBLIC_HOST=sitomat.contoh.go.id +PUBLIC_HOST=localhost diff --git a/docker/build-push.sh b/docker/build-push.sh new file mode 100755 index 0000000..53cffbd --- /dev/null +++ b/docker/build-push.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Build + push the sitomat control-plane images to a PRIVATE REGISTRY. +# +# Run from anywhere in the repo, on a host WITH the source. The DEPLOY host does +# not need this script (nor the source) — it only pulls. +# +# Requires a prior `docker login `. +# +# Env (usually from docker/.env — loaded automatically when present): +# SITOMAT_IMAGE_REGISTRY e.g. registry.contoh.go.id/sitomat +# SITOMAT_IMAGE_TAG e.g. 1.0.0 (or demo / prod / latest) +# NEXT_PUBLIC_DEMO_LOGIN true|false — BAKED into the web image +# NEXT_PUBLIC_KEYCLOAK_ENABLED true|false — BAKED into the web image +# WEB_API_BASE_URL empty = same-origin (recommended); set an +# absolute origin only for two-domain setups +# +# Examples: +# SITOMAT_IMAGE_REGISTRY=registry.contoh.go.id/sitomat SITOMAT_IMAGE_TAG=1.0.0 \ +# NEXT_PUBLIC_DEMO_LOGIN=false docker/build-push.sh +# +# # a DEMO image (demo login ON) — separate tag, because the flag is baked: +# SITOMAT_IMAGE_REGISTRY=registry.contoh.go.id/sitomat SITOMAT_IMAGE_TAG=demo \ +# NEXT_PUBLIC_DEMO_LOGIN=true docker/build-push.sh +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +cd "$(dirname "$0")/.." + +ENV_FILE="docker/.env" +ENV_FLAG="" +if [ -f "$ENV_FILE" ]; then + ENV_FLAG="--env-file $ENV_FILE" +fi + +: "${SITOMAT_IMAGE_REGISTRY:?set SITOMAT_IMAGE_REGISTRY, e.g. registry.contoh.go.id/sitomat}" +: "${SITOMAT_IMAGE_TAG:?set SITOMAT_IMAGE_TAG, e.g. 1.0.0}" + +FILES="-f docker-compose.control-plane.yml -f docker-compose.build.yml" + +echo "==> building api + web (tag ${SITOMAT_IMAGE_TAG})" +# shellcheck disable=SC2086 +docker compose $ENV_FLAG $FILES build api web + +echo "==> pushing api + web to ${SITOMAT_IMAGE_REGISTRY}" +# shellcheck disable=SC2086 +docker compose $ENV_FLAG $FILES push api web + +echo +echo "==> pushed:" +echo " ${SITOMAT_IMAGE_REGISTRY}/api:${SITOMAT_IMAGE_TAG}" +echo " ${SITOMAT_IMAGE_REGISTRY}/web:${SITOMAT_IMAGE_TAG}" +echo +echo "On a deploy host, set the SAME SITOMAT_IMAGE_REGISTRY/TAG in docker/.env," +echo "then: docker compose --env-file docker/.env -f docker-compose.control-plane.yml pull && ... up -d" diff --git a/docker/env.example b/docker/env.example new file mode 100644 index 0000000..8bff3ec --- /dev/null +++ b/docker/env.example @@ -0,0 +1,74 @@ +# ───────────────────────────────────────────────────────────────────────────── +# sitomat control plane — copy to `docker/.env` and edit. +# cp docker/env.example docker/.env +# +# (Named `env.example`, not `.env.example`, on purpose: the tooling policy in +# this repo blocks files matching `*.env*`. The copy target `docker/.env` IS +# gitignored and is excluded from the image build context.) +# +# SINGLE DOMAIN (Opsi A): the reverse proxy serves the web UI AND the API on ONE +# origin, split by path. See docs/deployment.md → "Docker" and the nginx +# template at docker/nginx/templates/default.conf.template. +# ───────────────────────────────────────────────────────────────────────────── + +# ── REQUIRED ───────────────────────────────────────────────────────────────── +# Generate a fresh one — never reuse an example value: +# openssl rand -base64 32 +BETTER_AUTH_SECRET= + +# ── REGISTRY IMAGES ────────────────────────────────────────────────────────── +# Which images the DEPLOY file pulls. Set BOTH here on the build host (used by +# docker/build-push.sh) and on EVERY deploy host. Example: +# SITOMAT_IMAGE_REGISTRY=registry.contoh.go.id/sitomat +# SITOMAT_IMAGE_TAG=1.0.0 +# Deploy host then only needs: docker compose … pull && docker compose … up -d +SITOMAT_IMAGE_REGISTRY=sitomat +SITOMAT_IMAGE_TAG=latest + +# ── PUBLIC ORIGIN — ONE domain for web + API (Opsi A) ──────────────────────── +# Both values are the SAME single origin; the proxy splits it by path. +# • no domain yet → server IP + proxy port, e.g. http://192.168.1.10:8080 +# • with TLS → https://sitomat.contoh.go.id +# Exact origin: scheme + host + port, NO trailing slash. +# +# These are RUNTIME values (BETTER_AUTH_URL / WEB_URL) — change + `up -d` only. +API_PUBLIC_URL=http://localhost:8080 +WEB_PUBLIC_URL=http://localhost:8080 + +# ── WEB API BASE (build-time) — LEAVE EMPTY ────────────────────────────────── +# Empty (recommended) = same-origin: the browser client resolves the API against +# `window.location.origin` and download links are relative, so the web image is +# DOMAIN-AGNOSTIC — changing the public domain needs NO rebuild. +# Set an absolute origin ONLY for the two-domain (split) setup; then a domain +# change requires: docker compose … build web && docker compose … up -d web +WEB_API_BASE_URL= + +# ── HOST PORTS (optional) ──────────────────────────────────────────────────── +# The single public entry is the PROXY. The api/web ports are for direct +# debugging only — the proxy reaches them on the compose network. +API_PORT=13000 +WEB_PORT=13001 +PROXY_PORT=8080 + +# ── SESSION COOKIE DOMAIN — leave EMPTY in single-domain mode ──────────────── +# One origin ⇒ host-only cookie, which is exactly right. Set this ONLY if you +# deliberately put the web and API on DIFFERENT subdomains (not Opsi A). +#SESSION_COOKIE_DOMAIN= + +# ── KEYCLOAK SSO (optional) ────────────────────────────────────────────────── +# Leave ALL THREE empty to disable Keycloak entirely. Setting OIDC_CLIENT_ID +# without OIDC_ISSUER_URL makes the API refuse to boot (fail fast). +#OIDC_CLIENT_ID= +#OIDC_CLIENT_SECRET= +#OIDC_ISSUER_URL= + +# ── WEB BUILD FLAGS ────────────────────────────────────────────────────────── +# 🚨 NEXT_PUBLIC_DEMO_LOGIN must stay `false` on any server others can reach: +# `true` lets anyone sign in as any role WITHOUT a password. +NEXT_PUBLIC_DEMO_LOGIN=false +NEXT_PUBLIC_KEYCLOAK_ENABLED=false + +# ── REVERSE PROXY (profile "proxy") ────────────────────────────────────────── +# The single hostname the proxy answers for (envsubst → nginx `server_name`). +# With a real domain: PUBLIC_HOST=sitomat.contoh.go.id +PUBLIC_HOST=localhost diff --git a/docker/nginx/templates/default.conf.template b/docker/nginx/templates/default.conf.template new file mode 100644 index 0000000..d1c0b5b --- /dev/null +++ b/docker/nginx/templates/default.conf.template @@ -0,0 +1,61 @@ +# ───────────────────────────────────────────────────────────────────────────── +# sitomat — SINGLE-DOMAIN reverse proxy (Opsi A). +# +# ONE public origin serves BOTH the web UI and the API; routing is by PATH. A +# small, explicit set of API prefixes goes to the API, everything else to the +# web — which is what lets the deployment expose a single port / domain. +# +# WHY ONLY THESE PREFIXES: most API routes (`/datasets`, `/submissions`, +# `/services`, `/standards`, …) COLLIDE with web pages of the same name. They do +# not need a public path because the web calls them SERVER-SIDE (Eden → +# `http://api:13000`) over the internal network. Only endpoints the BROWSER — or +# an external M2M client — hits directly are routed here: +# +# /api/auth/* better-auth (login/session) ← NEXT_PUBLIC_API_URL +# /api/v1/* documented read API (F09-A) +# /public/* open-data downloads (F07) +# /ingest/v1/* push ingest (F09-B) +# /docs swagger +# /health health check +# /datasets//versions//download browser download (F02) +# +# The last one is a REGEX on purpose: it is the ONLY browser-facing API path at +# the ROOT, and a plain prefix would swallow the web's `/datasets/[id]` page. +# +# With web + API on ONE origin there is no cross-origin request, so CORS is moot +# and the session cookie stays host-only (no `SESSION_COOKIE_DOMAIN`). +# +# `${PUBLIC_HOST}` is substituted by the nginx image's envsubst step. +# This is HTTP; add `listen 443 ssl` + certificates when TLS terminates here. +# ───────────────────────────────────────────────────────────────────────────── + +# Uploads (F02) can be several MB — nginx's 1m default would answer 413. +client_max_body_size 100m; + +server { + listen 80; + server_name ${PUBLIC_HOST}; + + # Inherited by every `location` below. + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # better-auth checks the browser's Origin against WEB_URL; forward it as-is. + proxy_set_header Origin $http_origin; + + # ── API ────────────────────────────────────────────────────────────────── + location = /health { proxy_pass http://api:13000; } + location /docs { proxy_pass http://api:13000; } + location /api/ { proxy_pass http://api:13000; } + location /public/ { proxy_pass http://api:13000; } + location /ingest/ { proxy_pass http://api:13000; } + + # The one root-level browser API path (see the header note). Regex locations + # are evaluated BEFORE the prefix `location /` below, so this wins. + location ~ ^/datasets/[^/]+/versions/[^/]+/download$ { + proxy_pass http://api:13000; + } + + # ── Web (everything else) ──────────────────────────────────────────────── + location / { proxy_pass http://web:13001; } +}