Monorepo Public/Internal Split — Plan B: Deploy + Cutover
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Take the 4-app monorepo from Plan A, build Docker images for each, wire up two isolated compose stacks (public + internal) on Server 2, run parity smoke tests against staging, and cut traffic on x056.ahu-demo.chatbot-neo.val.id from the legacy single-container deploy to the new split stack — with rollback ready.
Architecture: Server 1 builds all Docker images (CPU-only, fast). Images are pushed to Server 2 via docker save | ssh docker load (no external registry needed for POC scale). Two docker-compose files on Server 2 — compose.public.yaml (public-web + public-agent + Redis) and compose.internal.yaml (internal-web + internal-agent) — share a gateway-net bridge to reach vLLM/Milvus/embedding on the host's existing ahu-net. DNS/reverse-proxy flip on x056.ahu-demo.chatbot-neo.val.id is the one-shot cutover; rollback is a DNS/proxy revert.
Tech Stack: Docker Compose v2, docker save/load, ssh, nginx (existing reverse proxy on Server 1), no external container registry.
Spec: docs/superpowers/specs/2026-06-30-monorepo-public-internal-split-design.md
Prior plan: docs/superpowers/plans/2026-06-30-monorepo-split-plan-a-skeleton-and-refactor.md
Where this plan runs
- Tasks 1-10 run on Server 1 (
192.168.62.155, the 0-GPU host where source lives). All file edits and image builds happen here. - Tasks 11-14 run on Server 2 (
192.168.83.20, GPU host) via SSH from Server 1. Image transfer, compose up, staging smoke. - Task 15 is out-of-band coordination (DNS TTL adjustment on whoever owns the DNS zone).
- Tasks 16-22 span both hosts — cutover is orchestrated from Server 1's terminal but affects Server 2's containers and the reverse-proxy config.
Key infrastructure facts
- vLLM (Qwen3.6-35B-A3B-FP8, container
ahu-vllm) listens on172.17.0.1:8000on Server 2. Address the containers use:http://ahu-vllm:8000on theahu-netdocker network. - Milvus + embedding model live on
ahu-neton Server 2 as well. - Existing
ai-ahu-ragservice on Server 2 listens on port:8110— public-web reaches it viahttp://ai-ahu-rag:8110onahu-net. x056.ahu-demo.chatbot-neo.val.idcurrently DNS-routes to Server 1's nginx which proxies toahu-chatbot-devcontainer on:8120. That's what gets flipped in Task 19.
File Structure (end state of Plan B)
ahu-ai-chatbot/
├── apps/
│ ├── public-web/
│ │ └── Dockerfile # NEW — Next.js standalone build
│ ├── internal-web/
│ │ └── Dockerfile # NEW — Next.js standalone build
│ ├── public-agent/
│ │ └── Dockerfile # MODIFIED — reads from monorepo root context
│ └── internal-agent/
│ └── Dockerfile # MODIFIED — same
├── infra/
│ ├── compose.public.yaml # NEW — public stack
│ ├── compose.internal.yaml # NEW — internal stack
│ ├── compose.shared.yaml # NEW — Redis + external ahu-net attach
│ ├── env/
│ │ ├── public.env.example # NEW — env template for public stack
│ │ ├── internal.env.example # NEW — env template for internal stack
│ │ └── shared.env.example # NEW — Redis + secrets shared
│ ├── nginx/
│ │ └── chatbot-neo.conf.new # NEW — replaces existing nginx site block
│ └── deploy/
│ ├── build-and-ship.sh # NEW — build all 4 images + scp to Server 2
│ ├── deploy-staging.sh # NEW — pull images + docker compose up (staging URLs)
│ ├── deploy-prod.sh # NEW — production compose up
│ └── rollback.sh # NEW — flip DNS back + docker compose down new stack
└── docs/superpowers/plans/
└── 2026-07-01-monorepo-split-plan-b-completion-notes.md # NEW at Task 22
Phase 2A — Dockerfiles
Task 1: Write apps/public-web/Dockerfile for Next.js standalone
Files:
- Create: apps/public-web/Dockerfile
- Modify: apps/public-web/next.config.ts — add output: "standalone" if not present
- [ ] Step 1: Enable standalone output in next.config.ts
Read current apps/public-web/next.config.ts. It should look like:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// ...existing keys
};
export default nextConfig;
Add output: "standalone":
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// ...existing keys
};
export default nextConfig;
- [ ] Step 2: Write
apps/public-web/Dockerfile
# syntax=docker/dockerfile:1.6
# ─── Stage 1: install workspace deps ─────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /repo
RUN apk add --no-cache libc6-compat python3 make g++ \
&& corepack enable \
&& corepack prepare pnpm@11.1.1 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY apps/public-web/package.json apps/public-web/
COPY packages/streams/package.json packages/streams/
COPY packages/orchestrator-types/package.json packages/orchestrator-types/
COPY packages/ui/package.json packages/ui/
COPY packages/queue/package.json packages/queue/
RUN pnpm install --frozen-lockfile
# ─── Stage 2: build ──────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /repo
RUN apk add --no-cache libc6-compat python3 make g++ \
&& corepack enable \
&& corepack prepare pnpm@11.1.1 --activate
COPY --from=deps /repo/node_modules ./node_modules
COPY --from=deps /repo/apps/public-web/node_modules ./apps/public-web/node_modules
COPY --from=deps /repo/packages ./packages
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY packages/ ./packages/
COPY apps/public-web/ ./apps/public-web/
COPY infra/policy/ ./infra/policy/
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @ahu/public-web build
# ─── Stage 3: runtime ────────────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=builder /repo/apps/public-web/.next/standalone ./
COPY --from=builder /repo/apps/public-web/.next/static ./apps/public-web/.next/static
COPY --from=builder /repo/apps/public-web/public ./apps/public-web/public
COPY --from=builder /repo/infra/policy /infra/policy
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "apps/public-web/server.js"]
- [ ] Step 3: Build the image and verify it starts
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
docker build -t ahu-ai-chatbot-public:dev -f apps/public-web/Dockerfile .
Expected: build succeeds, outputs naming to docker.io/library/ahu-ai-chatbot-public:dev.
docker run --rm -d --name test-public -p 33000:3000 \
-e MODEL_GATEWAY_URL=http://placeholder:8000 \
-e LLM_PLANNING_MODEL=test \
-e LLM_SYNTHESIS_MODEL=test \
-e RAG_BASE_URL=http://placeholder:8110 \
-e DATA_PUBLIC_BASE_URL=http://placeholder:8000 \
-e DATA_PUBLIC_AGENT_ID=data-agent \
-e ORCHESTRATOR_CONFIG_DB=/tmp/cfg.sqlite \
-e ORCHESTRATOR_POLICY_DB=/tmp/policy.sqlite \
-e POLICY_DEFAULTS_SHARED_PATH=/infra/policy/policy_defaults_shared.json \
ahu-ai-chatbot-public:dev
sleep 3
curl -sf http://localhost:33000/api/health | tee /dev/stderr | grep -q '"ok":true'
docker rm -f test-public
Expected: curl prints {"ok":true,"surface":"public",...} and grep exits 0.
- [ ] Step 4: Commit
git add apps/public-web/Dockerfile apps/public-web/next.config.ts
git commit -m "feat(public-web): Dockerfile with Next.js standalone build
Multi-stage build using pnpm workspace. Runtime image is ~150MB
(node:20-alpine + standalone .next + public/ + policy shared JSON).
Verified /api/health returns ok=true from placeholder env."
Task 2: Write apps/internal-web/Dockerfile
Files:
- Create: apps/internal-web/Dockerfile
- Modify: apps/internal-web/next.config.ts
- [ ] Step 1: Enable standalone output
Same as Task 1 Step 1 but for apps/internal-web/next.config.ts.
- [ ] Step 2: Write
apps/internal-web/Dockerfile
Same shape as Task 1 with these substitutions:
- Replace every public-web with internal-web
- Replace CMD's apps/public-web/server.js with apps/internal-web/server.js
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /repo
RUN apk add --no-cache libc6-compat python3 make g++ \
&& corepack enable \
&& corepack prepare pnpm@11.1.1 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY apps/internal-web/package.json apps/internal-web/
COPY packages/streams/package.json packages/streams/
COPY packages/orchestrator-types/package.json packages/orchestrator-types/
COPY packages/ui/package.json packages/ui/
COPY packages/queue/package.json packages/queue/
RUN pnpm install --frozen-lockfile
FROM node:20-alpine AS builder
WORKDIR /repo
RUN apk add --no-cache libc6-compat python3 make g++ \
&& corepack enable \
&& corepack prepare pnpm@11.1.1 --activate
COPY --from=deps /repo/node_modules ./node_modules
COPY --from=deps /repo/apps/internal-web/node_modules ./apps/internal-web/node_modules
COPY --from=deps /repo/packages ./packages
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY packages/ ./packages/
COPY apps/internal-web/ ./apps/internal-web/
COPY infra/policy/ ./infra/policy/
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @ahu/internal-web build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=builder /repo/apps/internal-web/.next/standalone ./
COPY --from=builder /repo/apps/internal-web/.next/static ./apps/internal-web/.next/static
COPY --from=builder /repo/apps/internal-web/public ./apps/internal-web/public
COPY --from=builder /repo/infra/policy /infra/policy
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "apps/internal-web/server.js"]
- [ ] Step 3: Build + smoke
docker build -t ahu-ai-chatbot-internal:dev -f apps/internal-web/Dockerfile .
docker run --rm -d --name test-internal -p 33001:3000 ahu-ai-chatbot-internal:dev
sleep 3
curl -sfI http://localhost:33001/ | head -1
docker rm -f test-internal
Expected: HTTP/1.1 307 Temporary Redirect (root redirects to /login for internal).
- [ ] Step 4: Commit
git add apps/internal-web/Dockerfile apps/internal-web/next.config.ts
git commit -m "feat(internal-web): Dockerfile with Next.js standalone build"
Task 3: Update apps/public-agent/Dockerfile for monorepo context
Files:
- Modify: apps/public-agent/Dockerfile
- [ ] Step 1: Read current Dockerfile
cat /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/apps/public-agent/Dockerfile
It was cloned from apps/internal-agent/Dockerfile in Plan A Task 31 and expects the build context to be the app dir. We need it to work with monorepo-root context so it can find packages/agno-base.
- [ ] Step 2: Rewrite as multi-stage with root context
Replace the entire file content with:
# syntax=docker/dockerfile:1.6
FROM python:3.12-slim AS runner
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev curl \
&& rm -rf /var/lib/apt/lists/*
# uv is preferred; falls back to pip if uv-install fails
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
# Copy workspace root + agno-base package + agent code
COPY pyproject.toml .python-version /repo/
COPY packages/agno-base /repo/packages/agno-base
COPY apps/public-agent /repo/apps/public-agent
WORKDIR /repo/apps/public-agent
# Install into a system venv
RUN uv sync --frozen --no-dev || uv sync --no-dev
RUN adduser --disabled-password --gecos "" --uid 1001 app \
&& chown -R app:app /repo
USER app
WORKDIR /repo/apps/public-agent
EXPOSE 8000
ENV SURFACE=public
ENV KNOWLEDGE_DIR=/knowledge
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
- [ ] Step 3: Build + smoke
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
docker build -t ahu-ai-agent-public:dev -f apps/public-agent/Dockerfile .
Expected: build succeeds. Runtime smoke test requires DB + vLLM, deferred to Task 12.
- [ ] Step 4: Commit
git add apps/public-agent/Dockerfile
git commit -m "feat(public-agent): Dockerfile with monorepo-root build context
Uses uv sync from workspace root so agno-base is resolved via workspace
protocol. SURFACE=public + KNOWLEDGE_DIR=/knowledge baked in as defaults;
compose override sets the volume mount to infra/knowledge/public/."
Task 4: Update apps/internal-agent/Dockerfile
Files:
- Modify: apps/internal-agent/Dockerfile
- [ ] Step 1: Same as Task 3 Step 2 but substitute
internalforpublic
Replace apps/internal-agent/Dockerfile with:
# syntax=docker/dockerfile:1.6
FROM python:3.12-slim AS runner
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev curl \
&& rm -rf /var/lib/apt/lists/*
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
COPY pyproject.toml .python-version /repo/
COPY packages/agno-base /repo/packages/agno-base
COPY apps/internal-agent /repo/apps/internal-agent
WORKDIR /repo/apps/internal-agent
RUN uv sync --frozen --no-dev || uv sync --no-dev
RUN adduser --disabled-password --gecos "" --uid 1001 app \
&& chown -R app:app /repo
USER app
WORKDIR /repo/apps/internal-agent
EXPOSE 8000
ENV SURFACE=internal
ENV KNOWLEDGE_DIR=/knowledge
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
- [ ] Step 2: Build
docker build -t ahu-ai-agent-internal:dev -f apps/internal-agent/Dockerfile .
- [ ] Step 3: Commit
git add apps/internal-agent/Dockerfile
git commit -m "feat(internal-agent): Dockerfile with monorepo-root build context"
Phase 2B — Compose stacks
Task 5: Write infra/compose.shared.yaml
Files:
- Create: infra/compose.shared.yaml
- Create: infra/env/shared.env.example
- [ ] Step 1: Write shared compose
# infra/compose.shared.yaml
# Runs once. Both public + internal stacks reach this via `gateway-net`.
#
# Provides: Redis (rate limits + anon sessions + policy SQLite path never
# leaves host FS but the volume is declared here so both stacks mount it).
services:
ahu-redis:
image: redis:7-alpine
container_name: ahu-redis-shared
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
networks:
- gateway-net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
volumes:
redis-data:
driver: local
# SQLite shared between public-web (reader) and internal-web (writer)
# for orchestrator policy state.
policy-data:
driver: local
# SQLite for orchestrator runtime config.
config-data:
driver: local
networks:
gateway-net:
name: ahu-gateway-net
driver: bridge
# External: existing docker network on Server 2 where vLLM/RAG/Milvus live.
ahu-net:
external: true
- [ ] Step 2: Write env example
# infra/env/shared.env.example
# Copy to shared.env and fill in values.
REDIS_URL=redis://ahu-redis:6379
- [ ] Step 3: Commit
git add infra/compose.shared.yaml infra/env/shared.env.example
git commit -m "feat(infra/compose): shared stack (Redis + policy/config volumes + gateway-net)"
Task 6: Write infra/compose.public.yaml
Files:
- Create: infra/compose.public.yaml
- Create: infra/env/public.env.example
- [ ] Step 1: Write public compose
# infra/compose.public.yaml
# Public stack. Attaches to gateway-net for Redis and ahu-net for vLLM/RAG.
services:
public-web:
image: ahu-ai-chatbot-public:${IMAGE_TAG:-latest}
container_name: ahu-ai-chatbot-public
restart: unless-stopped
env_file:
- env/shared.env
- env/public.env
depends_on:
ahu-redis:
condition: service_healthy
ports:
- "127.0.0.1:${PUBLIC_WEB_PORT:-3500}:3000"
volumes:
- policy-data:/data:ro
- config-data:/data-cfg
networks:
- gateway-net
- ahu-net
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 5s
retries: 3
public-agent:
image: ahu-ai-agent-public:${IMAGE_TAG:-latest}
container_name: ahu-ai-agent-public
restart: unless-stopped
env_file:
- env/shared.env
- env/public.env
environment:
SURFACE: public
KNOWLEDGE_DIR: /knowledge
volumes:
- ../infra/knowledge/public:/knowledge:ro
networks:
- gateway-net
- ahu-net
healthcheck:
test: ["CMD-SHELL", "python3 -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\", timeout=3)'"]
interval: 30s
timeout: 5s
retries: 3
networks:
gateway-net:
external: true
name: ahu-gateway-net
ahu-net:
external: true
volumes:
policy-data:
external: true
name: ahu-shared_policy-data
config-data:
external: true
name: ahu-shared_config-data
- [ ] Step 2: Write env example
# infra/env/public.env.example
# Model gateway (planning — local vLLM):
MODEL_GATEWAY_URL=http://ahu-vllm:8000
LLM_PLANNING_MODEL=Qwen/Qwen3.6-35B-A3B-FP8
# Synthesis (Alibaba Cloud during transitional phase; will flip to local when B200 lands):
SYNTHESIS_GATEWAY_URL=https://ws-c1jx7b6glz0serum.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
LLM_SYNTHESIS_MODEL=qwen3.5-397b-a17b
# Tool endpoints on ahu-net:
RAG_BASE_URL=http://ai-ahu-rag:8110
DATA_PUBLIC_BASE_URL=http://public-agent:8000
DATA_PUBLIC_AGENT_ID=data-agent
# Orchestrator state:
ORCHESTRATOR_CONFIG_DB=/data-cfg/config.sqlite
ORCHESTRATOR_POLICY_DB=/data/policy.sqlite
POLICY_DEFAULTS_SHARED_PATH=/infra/policy/policy_defaults_shared.json
- [ ] Step 3: Commit
git add infra/compose.public.yaml infra/env/public.env.example
git commit -m "feat(infra/compose): public stack (public-web + public-agent)
Public-web binds to 127.0.0.1:3500 — nginx on Server 2 fronts it for
x056.ahu-demo.chatbot-neo.val.id. Both containers attach to gateway-net (Redis) and
ahu-net (vLLM/RAG). Policy SQLite mounted read-only from shared volume."
Task 7: Write infra/compose.internal.yaml
Files:
- Create: infra/compose.internal.yaml
- Create: infra/env/internal.env.example
- [ ] Step 1: Write internal compose
# infra/compose.internal.yaml
# Internal stack. LAN-only, no ingress binding on 0.0.0.0.
services:
internal-web:
image: ahu-ai-chatbot-internal:${IMAGE_TAG:-latest}
container_name: ahu-ai-chatbot-internal
restart: unless-stopped
env_file:
- env/shared.env
- env/internal.env
ports:
- "192.168.83.20:${INTERNAL_WEB_PORT:-3510}:3000"
volumes:
- policy-data:/data
- config-data:/data-cfg
networks:
- gateway-net
- ahu-net
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost:3000/"]
interval: 30s
timeout: 5s
retries: 3
internal-agent:
image: ahu-ai-agent-internal:${IMAGE_TAG:-latest}
container_name: ahu-ai-agent-internal
restart: unless-stopped
env_file:
- env/shared.env
- env/internal.env
environment:
SURFACE: internal
KNOWLEDGE_DIR: /knowledge
volumes:
- ../infra/knowledge/internal:/knowledge:ro
networks:
- gateway-net
- ahu-net
healthcheck:
test: ["CMD-SHELL", "python3 -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\", timeout=3)'"]
interval: 30s
timeout: 5s
retries: 3
networks:
gateway-net:
external: true
name: ahu-gateway-net
ahu-net:
external: true
volumes:
policy-data:
external: true
name: ahu-shared_policy-data
config-data:
external: true
name: ahu-shared_config-data
- [ ] Step 2: Write env example
# infra/env/internal.env.example
MODEL_GATEWAY_URL=http://ahu-vllm:8000
LLM_PLANNING_MODEL=Qwen/Qwen3.6-35B-A3B-FP8
SYNTHESIS_GATEWAY_URL=https://ws-c1jx7b6glz0serum.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
LLM_SYNTHESIS_MODEL=qwen3.5-397b-a17b
# Internal DB (full creds — for now Phase 1 also used by public per spec caveat):
INTERNAL_DB_URL=mysql://staff:CHANGEME@192.168.62.155:3306/ahu_badan_hukum
# Agent + RAG endpoints:
INTERNAL_AGENT_URL=http://internal-agent:8000
RAG_BASE_URL=http://ai-ahu-rag:8110
# Orchestrator policy state (writes here; public-web reads):
ORCHESTRATOR_POLICY_DB=/data/policy.sqlite
# NextAuth (staff auth):
NEXTAUTH_SECRET=CHANGEME
NEXTAUTH_URL=http://192.168.83.20:3510
- [ ] Step 3: Commit
git add infra/compose.internal.yaml infra/env/internal.env.example
git commit -m "feat(infra/compose): internal stack (internal-web + internal-agent, LAN-only bind)"
Phase 2C — Deploy scripts
Task 8: Write infra/deploy/build-and-ship.sh
Files:
- Create: infra/deploy/build-and-ship.sh
- [ ] Step 1: Write the script
#!/usr/bin/env bash
# infra/deploy/build-and-ship.sh
# Build all 4 images on Server 1 and ship them to Server 2 via SSH.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$REPO_ROOT"
TAG="${IMAGE_TAG:-$(git rev-parse --short HEAD)}"
SERVER2="${SERVER2:-obert@192.168.83.20}"
REMOTE_INBOX="${REMOTE_INBOX:-/tmp/ahu-images}"
echo "===> Building images with tag: $TAG"
docker build -t "ahu-ai-chatbot-public:$TAG" -f apps/public-web/Dockerfile .
docker build -t "ahu-ai-chatbot-internal:$TAG" -f apps/internal-web/Dockerfile .
docker build -t "ahu-ai-agent-public:$TAG" -f apps/public-agent/Dockerfile .
docker build -t "ahu-ai-agent-internal:$TAG" -f apps/internal-agent/Dockerfile .
for img in ahu-ai-chatbot-public ahu-ai-chatbot-internal ahu-ai-agent-public ahu-ai-agent-internal; do
docker tag "$img:$TAG" "$img:latest"
done
echo "===> Saving images to tarballs..."
mkdir -p /tmp/ahu-out
for img in ahu-ai-chatbot-public ahu-ai-chatbot-internal ahu-ai-agent-public ahu-ai-agent-internal; do
docker save "$img:$TAG" "$img:latest" | gzip -1 > "/tmp/ahu-out/$img.tar.gz"
echo " packed /tmp/ahu-out/$img.tar.gz ($(du -h /tmp/ahu-out/$img.tar.gz | cut -f1))"
done
echo "===> Shipping to $SERVER2:$REMOTE_INBOX..."
ssh "$SERVER2" "mkdir -p $REMOTE_INBOX"
for img in ahu-ai-chatbot-public ahu-ai-chatbot-internal ahu-ai-agent-public ahu-ai-agent-internal; do
scp "/tmp/ahu-out/$img.tar.gz" "$SERVER2:$REMOTE_INBOX/"
done
echo "===> Loading images on Server 2..."
ssh "$SERVER2" bash -s <<REMOTE_EOF
set -e
for img in ahu-ai-chatbot-public ahu-ai-chatbot-internal ahu-ai-agent-public ahu-ai-agent-internal; do
gunzip -c "$REMOTE_INBOX/\$img.tar.gz" | docker load
done
REMOTE_EOF
echo "===> Done. Images tagged $TAG (and :latest) on Server 2."
- [ ] Step 2: Make executable + commit
chmod +x infra/deploy/build-and-ship.sh
git add infra/deploy/build-and-ship.sh
git commit -m "feat(deploy): build-and-ship.sh — 4-image local build + scp to Server 2"
Task 9: Write infra/deploy/deploy-staging.sh
Files:
- Create: infra/deploy/deploy-staging.sh
- [ ] Step 1: Write the script
#!/usr/bin/env bash
# infra/deploy/deploy-staging.sh
# Bring up shared + public + internal stacks on Server 2 using :latest images.
# Assumes build-and-ship.sh has just run.
set -euo pipefail
SERVER2="${SERVER2:-obert@192.168.83.20}"
REMOTE_DIR="${REMOTE_DIR:-/home/obert/ahu-ai-staging}"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
echo "===> rsync infra/ to Server 2..."
rsync -avz --delete \
"$REPO_ROOT/infra/" \
"$SERVER2:$REMOTE_DIR/infra/"
echo "===> Copying env files (must exist locally with real values)..."
for f in shared public internal; do
test -f "$REPO_ROOT/infra/env/$f.env" || {
echo "MISSING: infra/env/$f.env — copy from $f.env.example and fill in"
exit 1
}
scp "$REPO_ROOT/infra/env/$f.env" "$SERVER2:$REMOTE_DIR/infra/env/"
done
echo "===> Bringing up shared stack..."
ssh "$SERVER2" "cd $REMOTE_DIR/infra && docker compose -p ahu-shared -f compose.shared.yaml up -d"
echo "===> Bringing up public stack..."
ssh "$SERVER2" "cd $REMOTE_DIR/infra && docker compose -p ahu-public -f compose.public.yaml up -d"
echo "===> Bringing up internal stack..."
ssh "$SERVER2" "cd $REMOTE_DIR/infra && docker compose -p ahu-internal -f compose.internal.yaml up -d"
echo "===> Waiting for health checks..."
sleep 10
echo "===> Public health:"
ssh "$SERVER2" "curl -sf http://127.0.0.1:3500/api/health" || echo "PUBLIC UNHEALTHY"
echo "===> Internal reachable on LAN:"
ssh "$SERVER2" "curl -sfI http://127.0.0.1:3510/ | head -1" || echo "INTERNAL UNHEALTHY"
echo "===> Done. Staging URLs:"
echo " Public: http://192.168.83.20:3500 (or via nginx: chatbot-neo-next.val.id)"
echo " Internal: http://192.168.83.20:3510"
- [ ] Step 2: Make executable + commit
chmod +x infra/deploy/deploy-staging.sh
git add infra/deploy/deploy-staging.sh
git commit -m "feat(deploy): deploy-staging.sh — bring up all 3 stacks on Server 2"
Task 10: Write infra/deploy/rollback.sh
Files:
- Create: infra/deploy/rollback.sh
- [ ] Step 1: Write the script
#!/usr/bin/env bash
# infra/deploy/rollback.sh
# Emergency rollback: stop the new stacks on Server 2 and revert nginx on
# Server 1 to point x056.ahu-demo.chatbot-neo.val.id at the legacy container.
set -euo pipefail
SERVER1="${SERVER1:-localhost}" # nginx lives on Server 1
SERVER2="${SERVER2:-obert@192.168.83.20}"
REMOTE_DIR="${REMOTE_DIR:-/home/obert/ahu-ai-staging}"
echo "===> Stopping public stack on Server 2..."
ssh "$SERVER2" "cd $REMOTE_DIR/infra && docker compose -p ahu-public -f compose.public.yaml down"
echo "===> Reverting nginx to legacy ahu-chatbot-dev container..."
if [ "$SERVER1" = "localhost" ]; then
sudo cp /etc/nginx/sites-available/chatbot-neo.conf.backup /etc/nginx/sites-available/chatbot-neo.conf
sudo nginx -t && sudo systemctl reload nginx
else
ssh "$SERVER1" "sudo cp /etc/nginx/sites-available/chatbot-neo.conf.backup /etc/nginx/sites-available/chatbot-neo.conf && sudo nginx -t && sudo systemctl reload nginx"
fi
echo "===> Rollback complete. x056.ahu-demo.chatbot-neo.val.id → legacy ahu-chatbot-dev:8120"
echo "===> Verify: curl -sfI https://x056.ahu-demo.chatbot-neo.val.id/ | head -1"
- [ ] Step 2: Commit
chmod +x infra/deploy/rollback.sh
git add infra/deploy/rollback.sh
git commit -m "feat(deploy): rollback.sh — one-shot revert to legacy container + nginx"
Phase 2D — Build + smoke on Server 2
Task 11: Prepare env files with real values
Files:
- Create: infra/env/shared.env (NOT committed — contains secrets)
- Create: infra/env/public.env (NOT committed)
- Create: infra/env/internal.env (NOT committed)
- [ ] Step 1: Verify env/ is gitignored
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
grep -q "^infra/env/\*.env" .gitignore || echo "infra/env/*.env" >> .gitignore
grep -q "!infra/env/\*.env.example" .gitignore || echo "!infra/env/*.env.example" >> .gitignore
git add .gitignore
git commit -m "chore(gitignore): exclude infra/env/*.env (keep *.env.example)"
- [ ] Step 2: Copy templates and fill in
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/infra/env
cp shared.env.example shared.env
cp public.env.example public.env
cp internal.env.example internal.env
Then edit each file:
- shared.env: no changes (Redis URL is already correct default)
- public.env: fill in SYNTHESIS_GATEWAY_URL with the real Alibaba Cloud endpoint; leave RAG_BASE_URL and DATA_PUBLIC_BASE_URL as-is (they resolve via docker DNS)
- internal.env: fill in INTERNAL_DB_URL with real credentials + set NEXTAUTH_SECRET (openssl rand -base64 32)
- [ ] Step 3: Verify env files are not tracked
git status infra/env/ | grep -E "\.env$" && echo "STILL TRACKED — abort" || echo "OK, not tracked"
Expected: prints OK, not tracked.
Task 12: Build + ship images
Files: none (script-driven)
- [ ] Step 1: Run the build-and-ship script
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
./infra/deploy/build-and-ship.sh
Expected output ends with:
===> Done. Images tagged <sha> (and :latest) on Server 2.
- [ ] Step 2: Verify images on Server 2
ssh obert@192.168.83.20 "docker image ls | grep ahu-ai-"
Expected: 4 lines, one per image with recent CREATED timestamp.
Task 13: Bring up staging stacks
Files: none
- [ ] Step 1: Ensure
ahu-netdocker network exists on Server 2
ssh obert@192.168.83.20 "docker network ls | grep -q ahu-net && echo EXISTS || docker network create ahu-net"
Expected: prints EXISTS (it should already be there — vLLM lives on it).
- [ ] Step 2: Run deploy-staging
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
./infra/deploy/deploy-staging.sh
Expected: script ends with public+internal health lines both printing OK.
- [ ] Step 3: Manually check container health
ssh obert@192.168.83.20 "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep ahu-ai-"
Expected: all 4 containers showing Up X seconds (healthy).
- [ ] Step 4: Manual smoke queries
# Public /api/health
ssh obert@192.168.83.20 "curl -sf http://127.0.0.1:3500/api/health"
# Expected: {"ok":true,"surface":"public","timestamp":"..."}
# Public /api/orchestrate — real query (limit to a smoke question)
ssh obert@192.168.83.20 "curl -N -H 'Content-Type: application/json' -d '{\"surface\":\"public\",\"history\":[],\"message\":\"Apa itu perseroan terbatas?\",\"sessionId\":\"smoke-1\"}' http://127.0.0.1:3500/api/orchestrate | head -20"
# Expected: SSE stream — event: status, event: content, event: references, event: done
If the SSE stream shows real Indonesian answer text and hits event: done, staging is working.
Task 14: Run parity smoke against staging (fulfills Plan A Task 35 deferral)
Files: none (uses existing eval scripts under apps/*/dash/evals/)
- [ ] Step 1: Point eval scripts at staging endpoints
ssh obert@192.168.83.20 "cd /home/obert/ahu-ai-staging/infra && docker compose -p ahu-public -f compose.public.yaml exec public-agent uv run python -m dash.evals.run_model_comparison --limit 5" | tee /tmp/parity-public-staging.log
Expected: 5 test cases run, each printing a Q + A pair. No HTTP 500 errors from vLLM or SQL.
- [ ] Step 2: Same against internal
ssh obert@192.168.83.20 "cd /home/obert/ahu-ai-staging/infra && docker compose -p ahu-internal -f compose.internal.yaml exec internal-agent uv run python -m dash.evals.run_model_comparison --limit 5" | tee /tmp/parity-internal-staging.log
Expected: 5 test cases with staff-scope answers (possibly touching row-level data).
- [ ] Step 3: Save parity snapshot
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
mkdir -p docs/superpowers/parity-snapshots/2026-07-01-staging
cp /tmp/parity-public-staging.log docs/superpowers/parity-snapshots/2026-07-01-staging/
cp /tmp/parity-internal-staging.log docs/superpowers/parity-snapshots/2026-07-01-staging/
git add docs/superpowers/parity-snapshots/2026-07-01-staging/
git commit -m "docs(parity): staging parity smoke snapshot 2026-07-01"
Phase 3A — Pre-cutover coordination
Task 15: Lower DNS TTL 24h before cutover
Files: none (out-of-band)
- [ ] Step 1: Identify DNS zone owner
The x056.ahu-demo.chatbot-neo.val.id A record is managed by whoever owns val.id. Confirm current TTL.
dig +noall +answer x056.ahu-demo.chatbot-neo.val.id | head -1
Expected output includes the TTL as the second field. If TTL > 300, request lowering.
- [ ] Step 2: Request TTL drop to 300 (5 min) via zone admin
Send a message to the DNS admin (whoever owns val.id) requesting TTL drop to 300 seconds. Wait 24h + old-TTL for the change to propagate.
Notes: this task is not automatable and blocks Task 19. Continue with Task 16-18 (they're independent) while waiting.
- [ ] Step 3: Verify propagation
for r in 8.8.8.8 1.1.1.1 9.9.9.9; do
dig +noall +answer @$r x056.ahu-demo.chatbot-neo.val.id | head -1
done
Expected: all three resolvers show TTL ≤ 300.
Phase 3B — Cutover
Task 16: Write new nginx site block
Files:
- Create: infra/nginx/chatbot-neo.conf.new
- [ ] Step 1: Read current nginx config
The current /etc/nginx/sites-available/chatbot-neo.conf on Server 1 proxies to ahu-chatbot-dev:8120. Read it to preserve TLS + logging directives.
- [ ] Step 2: Write the new block
# infra/nginx/chatbot-neo.conf.new
# Deploy to /etc/nginx/sites-available/chatbot-neo.conf on Server 1.
server {
listen 443 ssl http2;
server_name x056.ahu-demo.chatbot-neo.val.id;
# <PRESERVE TLS cert paths from the existing config>
# ssl_certificate ...
# ssl_certificate_key ...
access_log /var/log/nginx/chatbot-neo.access.log;
error_log /var/log/nginx/chatbot-neo.error.log;
# SSE requires disabling buffering + long timeouts.
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Server 2 public-web binds 127.0.0.1:3500; nginx reaches it via
# host-to-host over the LAN.
location / {
proxy_pass http://192.168.83.20:3500;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";
}
}
server {
listen 80;
server_name x056.ahu-demo.chatbot-neo.val.id;
return 301 https://$server_name$request_uri;
}
Replace <PRESERVE TLS cert paths from the existing config> with the actual cert paths from the existing nginx site config. Do NOT commit real cert paths if they're sensitive; if they are, use env-var substitution during deploy.
- [ ] Step 3: Commit
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
git add infra/nginx/chatbot-neo.conf.new
git commit -m "feat(infra/nginx): new site block for x056.ahu-demo.chatbot-neo.val.id → 192.168.83.20:3500"
Task 17: Physical rename ai-ahu-chatbot → ahu-ai-chatbot
Files: none (filesystem operation)
- [ ] Step 1: Announce maintenance window
Notify staff users that a brief maintenance window is starting. Send message via Slack/team channel.
- [ ] Step 2: Stop the legacy container
cd /home/efran/remote-development/poc-ahu-ai/ai-ahu-chatbot
docker compose -f compose.dev.yaml stop ahu-chatbot
- [ ] Step 3: Remove the symlink + physical rename
cd /home/efran/remote-development/poc-ahu-ai
rm ahu-ai-chatbot # remove symlink
mv ai-ahu-chatbot ahu-ai-chatbot
- [ ] Step 4: Verify git still works after rename
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
git status
git log --oneline -3
Expected: clean status, recent commits visible.
- [ ] Step 5: Update
.claude/settings.jsonproject-path references
If any Claude Code session references the old path, update them. For future sessions the new path will be canonical.
- [ ] Step 6: Empty rationale commit
git commit --allow-empty -m "chore: physical rename ai-ahu-chatbot → ahu-ai-chatbot complete (symlink removed)"
Task 18: Backup existing nginx config
Files: none (backup operation)
- [ ] Step 1: Backup current nginx site block on Server 1
sudo cp /etc/nginx/sites-available/chatbot-neo.conf /etc/nginx/sites-available/chatbot-neo.conf.backup
sudo ls -la /etc/nginx/sites-available/chatbot-neo.conf.backup
Expected: file exists with recent timestamp.
- [ ] Step 2: Verify staging is still healthy right before flip
ssh obert@192.168.83.20 "curl -sf http://127.0.0.1:3500/api/health"
ssh obert@192.168.83.20 "curl -sfI http://127.0.0.1:3510/"
Both should return healthy responses. If not, halt cutover.
Task 19: DNS/reverse-proxy flip
Files: none (deploy operation)
- [ ] Step 1: Copy new nginx config into place
sudo cp /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot/infra/nginx/chatbot-neo.conf.new /etc/nginx/sites-available/chatbot-neo.conf
sudo nginx -t
Expected: nginx: configuration file /etc/nginx/nginx.conf test is successful.
- [ ] Step 2: Reload nginx
sudo systemctl reload nginx
Expected: no error output.
- [ ] Step 3: Smoke prod URL
curl -sf https://x056.ahu-demo.chatbot-neo.val.id/api/health
Expected: {"ok":true,"surface":"public","timestamp":"..."}.
- [ ] Step 4: Real query through prod
curl -N -H 'Content-Type: application/json' -d '{"surface":"public","history":[],"message":"Apa itu perseroan terbatas?","sessionId":"prod-smoke-1"}' https://x056.ahu-demo.chatbot-neo.val.id/api/orchestrate | head -20
Expected: SSE stream terminating with event: done.
- [ ] Step 5: Announce cutover complete
Send Slack/team message: "x056.ahu-demo.chatbot-neo.val.id cutover complete. New split public/internal stack live on Server 2. Rollback script available at infra/deploy/rollback.sh if issues arise."
Task 20: Post-cutover monitoring window
Files: none
- [ ] Step 1: Watch logs for the next 30 minutes
ssh obert@192.168.83.20 "docker logs -f ahu-ai-chatbot-public 2>&1 | tee /tmp/post-cutover-public.log"
Watch for 5xx errors, orchestrator failures, unusually long response times.
- [ ] Step 2: Sample a handful of real user queries
Test the following via the actual https://x056.ahu-demo.chatbot-neo.val.id/tanya UI:
- "Apa itu PT?"
- "Berapa PT terdaftar tahun 2025?"
- "Bagaimana cara mendirikan yayasan?"
- "Siapa direktur Mandiri?" (should refuse — L1 person_lookup)
- "Cari NIK 1234567812345678" (should refuse — L1 nik pattern)
Expected: first 3 return real Indonesian answers with references; last 2 return the Indonesian refusal message.
- [ ] Step 3: If any issue, execute rollback
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
./infra/deploy/rollback.sh
Then diagnose in staging before re-attempting.
Task 21: Retire legacy container
Files: none
- [ ] Step 1: Confirm 24h of stable prod traffic on new stack
Review the last 24h of ahu-ai-chatbot-public logs for any error spikes. If none, proceed.
- [ ] Step 2: Remove the legacy
ahu-chatbot-devcontainer
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
docker compose -f compose.dev.yaml down ahu-chatbot
docker image rm ahu-chatbot:dev 2>/dev/null || true
- [ ] Step 3: Retire the legacy compose file
git mv compose.dev.yaml infra/legacy-compose.dev.yaml
git commit -m "chore: retire legacy single-container compose.dev.yaml (moved to infra/legacy-)"
Task 22: Archive sibling repos + completion notes
Files:
- Create: docs/superpowers/plans/2026-07-01-monorepo-split-plan-b-completion-notes.md
- [ ] Step 1: Archive sibling repos
Add a top-level README in each sibling repo noting archival status, then push a final commit + tag:
cd /home/efran/remote-development/poc-ahu-ai/ai-ahu-data-dash
git checkout main
cat > README.ARCHIVED.md <<'EOF'
# ai-ahu-data-dash — ARCHIVED 2026-07-01
This repo was absorbed into the `ahu-ai-chatbot` monorepo at
`apps/{public,internal}-agent/`. Do not add new commits here.
Latest history preserved via `git subtree` in the monorepo.
EOF
git add README.ARCHIVED.md
git commit -m "chore: archive — absorbed into ahu-ai-chatbot monorepo"
git tag "archive/2026-07-01"
cd /home/efran/remote-development/poc-ahu-ai/ahu-chatbot-orchestrator
cat > README.ARCHIVED.md <<'EOF'
# ahu-chatbot-orchestrator — ARCHIVED 2026-07-01
This repo was translated to TypeScript and absorbed into the
`ahu-ai-chatbot` monorepo at `apps/public-web/src/lib/orchestrator/`.
Do not add new commits here.
EOF
git add README.ARCHIVED.md
git commit -m "chore: archive — translated to TS in ahu-ai-chatbot monorepo"
git tag "archive/2026-07-01"
- [ ] Step 2: Write completion notes
# Plan B completion notes (2026-07-01)
## State at end of Plan B
- Docker images `ahu-ai-chatbot-{public,internal}` + `ahu-ai-agent-{public,internal}` built with `:latest` tag on Server 2.
- Three compose stacks live on Server 2:
- `infra/compose.shared.yaml` — Redis + shared volumes
- `infra/compose.public.yaml` — public-web (127.0.0.1:3500) + public-agent
- `infra/compose.internal.yaml` — internal-web (192.168.83.20:3510) + internal-agent
- `x056.ahu-demo.chatbot-neo.val.id` routes through nginx on Server 1 → `192.168.83.20:3500`.
- Sibling repos `ai-ahu-data-dash` + `ahu-chatbot-orchestrator` archived with tag `archive/2026-07-01`.
- Physical rename `ai-ahu-chatbot` → `ahu-ai-chatbot` complete.
- Legacy `ahu-chatbot-dev` container retired.
- `compose.dev.yaml` moved to `infra/legacy-compose.dev.yaml`.
- Rollback script `infra/deploy/rollback.sh` verified working (dry-run in staging).
## Deferrals from Plan B
- **Public DB isolation** — Phase 1 caveat from spec still holds; public-agent reads internal DB with full creds. `PUBLIC_DB_URL` env slot reserved; flip is config-only when a public DB lands.
- **Model gateway service** — `MODEL_GATEWAY_URL` still points at direct vLLM. When the shared gateway service lands (separate repo), flip the env var.
## What Plan C picks up
- Admin scope-switcher UI (Public/Internal/Shared/Observasi nav) + audit log
- SSE resume primitive (Last-Event-ID + Redis checkpoint)
- Standard gateway request headers surfaced in admin
- BullMQ workers for nightly eval + knowledge re-ingestion
- SQL-guard TS port (Plan A deferred item #7)
- Dual-model streaming refactor (spec hot-spot #1)
- [ ] Step 3: Commit
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot
git add docs/superpowers/plans/2026-07-01-monorepo-split-plan-b-completion-notes.md
git commit -m "docs(plan-b): completion notes + handoff to Plan C"
End-of-Plan-B success criteria
Run these once Task 22 is committed:
# From anywhere with LAN access to Server 2:
curl -sf https://x056.ahu-demo.chatbot-neo.val.id/api/health
curl -sf http://192.168.83.20:3510/ # returns HTML for /login
ssh obert@192.168.83.20 "docker ps --filter name=ahu-ai- --format 'table {{.Names}}\t{{.Status}}'"
All of the following must be true:
curl https://x056.ahu-demo.chatbot-neo.val.id/api/healthreturns{"ok":true,"surface":"public",...}- All 4
ahu-ai-*containers on Server 2 showUp (healthy) infra/compose.dev.yamlno longer exists at repo root (moved toinfra/legacy-)- Legacy
ahu-chatbot-devcontainer is not running (docker pson Server 1 has no such line) - Sibling repos
ai-ahu-data-dash+ahu-chatbot-orchestratorcarry the tagarchive/2026-07-01 ls /home/efran/remote-development/poc-ahu-ai/showsahu-ai-chatbot(notai-ahu-chatbot)- Rollback script tested at least once (either in staging or full dry-run)
Plan B is done when all 7 hold.