Hostinger KVM 8 · Ubuntu 24.04 LTS · Docker · CPU mode

self-hosted-ai-lab
v1 + v2 · 1 Server

// v1: Ollama · Open WebUI · Claude Code · OpenRouter · OpenClaw · Sim.ai · n8n · Qdrant · pgAdmin // v2: 11 MCP servers · voice agent · voice-playground · yt-dlp-ui · code-server

v2 (May 2026, shipped): 11 HTTP MCP servers — mcp-postgres, mcp-airtable, mcp-web-search, mcp-yt-dlp, mcp-sim, mcp-n8n, mcp-voice, mcp-voice-history, mcp-gmail, mcp-gcal (added in Phase 4 for the voice agent's close-loop email + demo-booking tools), and mcp-hostinger (wraps the official hostinger-api-mcp — 118 tools across VPS/DNS/domains/billing/hosting) — plus the livekit-agent voice worker (inbound + outbound PSTN via Twilio + LiveKit, 14 function-tools, LLM failover Claude Haiku 4.5 → OpenRouter → Ollama, Deepgram STT/TTS, persona name follows the selected voice, supervisor transfer with hold music, end-of-call logs to voice_pg + async outbox to Airtable). Three browser UIs: yt-dlp-ui (ytdl.pocketcode.in), voice-playground (voice.pocketcode.in — model/voice picker + ▶ preview, WhatsApp-style live transcripts, voice_pg-backed call history with pending callbacks), and host-native code-server (code.pocketcode.in — browser VS Code with Claude IDE). The tabs below cover both v1.0 and v2 in order: v1 services → v1 verify → v2 secrets → 11 MCPs → voice playground → voice data plane → voice agent → outbound → code-server → shared plumbing. Full release notes in CHANGELOG.md.
Plan
KVM 8
Hostinger top tier
vCPU
8 cores
AMD EPYC
RAM
32 GB
DDR4
Storage
400 GB NVMe
vs 100 GB on Kamatera
Inference
CPU mode
Ollama on AMD EPYC
OS
Ubuntu 24.04
Noble Numbat LTS
Price
~$20/mo
vs $286 on Kamatera
v1.0 baseline · ai-stack Docker network
🦙
Ollama
:11434
💬
Open WebUI
:8080
✦C
Claude Code
CLI only
OpenRouter
:4000
🦞
OpenClaw
:18789
n8n
n8n
:5678
sim
Sim.ai
:3000
🐘
sim-db-1 PG
:5432
Qdrant
:6333
🐘
pgAdmin
:5050
v2 — MCP layer (11 servers · HTTP + bearer auth)
PG
mcp-postgres
:8765
AT
mcp-airtable
:8766
mcp-web-search
:8767
📺
mcp-yt-dlp
:8768
sim
mcp-sim
:8771
n8n
mcp-n8n
:8772
📲
mcp-voice
:8774
🗄️
mcp-voice-history
:8775
✉️
mcp-gmail
:8776
📅
mcp-gcal
:8777
🛰️
mcp-hostinger
:8778
v2 — browser UIs + voice worker + host IDE
📺
yt-dlp-ui
:8769
🎙️
voice-playground
:8770
📞
livekit-agent
outbound
💻
code-server
host :8773
🌐
Hostinger Server Setup
// Create VPS · Install Docker · Configure network & firewall
ResourceHostinger KVM 8Why
vCPU8 cores AMD EPYCParallel container workloads
RAM32 GBSim.ai alone needs 12 GB
Storage400 GB NVMeLLM models + DB growth
OSUbuntu 24.04 LTS (Noble Numbat)cgroup v2 · best Docker support
1
Create Your Hostinger VPS
🔗Go to: hostinger.com/vps-hosting → Choose Plan → KVM 8

During setup select Ubuntu 24.04 LTS as the OS. After provisioning, note your server's public IP from the hPanel dashboard.

💡Hostinger provisions in under 60 seconds. You'll have hPanel access immediately with a built-in Docker Manager GUI included for free.
2
SSH Into Your Server from Mac
bash · your Mac terminal
ssh root@YOUR_SERVER_IP

# Verify Ubuntu version
lsb_release -a
# Expected: Ubuntu 24.04.x LTS

# Confirm cgroup v2 (Ubuntu 24.04 default)
stat -fc %T /sys/fs/cgroup
# Expected output: cgroup2fs  ✓
✏️Replace before running:
PlaceholderWhat to putWhere to find it
YOUR_SERVER_IPYour Hostinger server's public IP addresshPanel → VPS section → click your server → IP shown at top of dashboard (e.g. 82.115.x.x)
3
Update System & Install Essentials
bash
apt update && apt upgrade -y
apt install -y curl wget git nano ufw fail2ban htop
4
Install Docker & Docker Compose
bash
# Official Docker install — works perfectly on Ubuntu 24.04
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
apt install -y docker-compose-plugin

# Verify
docker --version
docker compose version
Ubuntu 24.04 ships cgroup v2 by default — Docker detects it automatically. No manual configuration needed.
5
Create Docker Network & Folder Structure

One shared Docker bridge network lets all containers talk to each other by name — completely internal to the server, no extra Hostinger networking needed. Run each command separately:

bash · command 1 of 3
docker network create ai-stack
bash · command 2 of 3
mkdir -p ~/ai-stack/{ollama,claude-code,openclaw,sim}
bash · command 3 of 3
echo "✅ Network and folders ready"
6
Configure Firewall (UFW)

Run each command separately:

bash · command 1 of 5
ufw allow OpenSSH
bash · command 2 of 5
ufw allow 8080
bash · command 3 of 5
ufw allow 3000
bash · command 4 of 5
ufw allow 11434
bash · command 5 of 5 — enable & verify
ufw --force enable && ufw status
⚠️Restrict Ollama to your IP only in production: ufw allow from YOUR_IP to any port 11434
🤖
Claude CLI (host install)
// Anthropic's official CLI on the host · used directly via SSH / web terminal
💡Two ways to use Claude Code on this stack. This tab covers installing the official claude CLI directly on the host (Ubuntu), so you can drop into claude from any SSH session or the web terminal. Tab 4 (Claude Code Container) covers a containerized variant with isolated env vars per project. Most people install both: the host CLI for ad-hoc work, the container for repeatable per-project runs.
⚠️Requires an Anthropic account (sign-in flow) or an API key from console.anthropic.com. The CLI prompts you on first run.
1
Install the CLI

Anthropic ships an installer that drops a single binary under ~/.claude/bin/ and adds it to your PATH on next shell. No package manager pollution.

bash · one-line install
curl -fsSL https://claude.ai/install.sh | bash

Re-source ~/.bashrc (or open a new shell) so the new PATH takes effect:

bash · reload shell
source ~/.bashrc
📦Alternative: npm. If you already have Node 20+ on the host, npm install -g @anthropic-ai/claude-code also works. The official installer is recommended because it self-updates and stays isolated under ~/.claude/.
2
Verify the install
bash · expect a version like 1.x.y or claude-code 0.x
claude --version
bash · confirm the binary location
which claude
3
Sign in (one-time)

First invocation opens a browser sign-in flow (paste the printed URL into your local browser since the VPS has no GUI). Subsequent runs reuse the saved credentials at ~/.claude/.

bash · interactive sign-in on first run
claude
🔑API-key-only mode (no browser, useful for CI / automation): export ANTHROPIC_API_KEY before invoking the CLI. secrets/secrets.env has a placeholder; populate it, source it, then run claude.
4
Smoke test

Drop into a project directory and ask Claude to summarise it:

bash · ad-hoc question from inside the project tree
cd ~/pocketcode-project
claude

At the prompt: "summarise this repo in five bullets". If you get a sensible answer, you're done — close with Ctrl+D or /exit.

📚Official docs: docs.claude.com/en/docs/claude-code · Updates: claude update · Uninstall: delete ~/.claude/
🦙
Ollama
// Local LLM runner · CPU mode · Port 11434 · ollama/ollama
💡Running in CPU mode on your Hostinger KVM 8. Inference is slower than GPU but fully functional — small models (3B–8B) respond in 5–20 seconds which is fine for personal use and curriculum development.
1
Run Ollama Container (CPU mode)

This is a multi-line command. Create a script file, paste the content, make it executable, then run it:

bash · step 1 — create the script file
touch ~/ai-stack/ollama/run-ollama.sh
bash · step 2 — open in nano editor
nano ~/ai-stack/ollama/run-ollama.sh
bash · step 3 — paste this into nano, then press Ctrl+O to save, Ctrl+X to exit
#!/bin/bash
docker run -d \
  --name ollama \
  --network ai-stack \
  --restart unless-stopped \
  -v ollama-data:/root/.ollama \
  -p 11434:11434 \
  ollama/ollama
bash · step 4 — make executable and run
chmod +x ~/ai-stack/ollama/run-ollama.sh
bash · step 5 — execute the script
bash ~/ai-stack/ollama/run-ollama.sh
2
Pull Models & Test
bash · command 1 — pull a model
docker exec -it ollama ollama pull llama3.2
bash · command 2 — test the API
curl http://localhost:11434/api/generate -d '{
  "model": "llama3.2",
  "prompt": "Hello World!"
}'
bash · command 3 — list downloaded models
docker exec -it ollama ollama list
💡Other containers reach Ollama at http://ollama:11434 via the ai-stack network.
3
Recommended Models by VRAM
ModelVRAMBest for
llama3.2:3b~2 GBFast responses, low resource
llama3.2:8b~5 GBGood quality general use
mistral:7b~5 GBCoding + reasoning
deepseek-r1:8b~6 GBStrong reasoning tasks
gemma2:9b~7 GBCurriculum & education content
💬
Open WebUI
// ChatGPT-style frontend for Ollama · ghcr.io/open-webui/open-webui · port 8080
Open WebUI Logo
Open WebUI
Extensible, self-hosted AI interface that runs entirely offline · supports Ollama and any OpenAI-compatible API · 90,000+ ⭐ on GitHub
🌐 openwebui.com · 📦 github.com/open-webui · 📚 docs
💡Why install this? Ollama alone is just an API — visiting http://localhost:11434 shows "Ollama is running" on a blank page. Open WebUI is the most popular Ollama frontend (90,000+ GitHub stars in 2026), giving you a polished ChatGPT-like web interface for your local models. Features include conversation history, model switching, document Q&A (RAG), file uploads, multi-user accounts, and an OpenAI-compatible API.
1
Create the Run Script
bash · step 1 — create folder and script
mkdir -p ~/ai-stack/open-webui && nano ~/ai-stack/open-webui/run-open-webui.sh
bash · step 2 — paste this, then Ctrl+O save, Ctrl+X exit
#!/bin/bash
docker run -d \
  --name open-webui \
  --network ai-stack \
  --restart unless-stopped \
  -e OLLAMA_BASE_URL=http://ollama:11434 \
  -e WEBUI_AUTH=true \
  -v open-webui-data:/app/backend/data \
  ghcr.io/open-webui/open-webui:main
💡Notice no -p port mapping. Open WebUI listens on 8080 inside the container, but we don't expose it directly — Caddy reaches it via the internal ai-stack network. Until you're ready for HTTPS (Tab 14), you'll access it via SSH tunnel in Step 4. This is cleaner than exposing port 8080 to the public IP.

Make the script executable:

bash · step 3 — make executable
chmod +x ~/ai-stack/open-webui/run-open-webui.sh
2
Launch Open WebUI Container
bash · launch
bash ~/ai-stack/open-webui/run-open-webui.sh

First boot pulls the image (~600MB) — takes 1-2 minutes. Subsequent launches are instant.

bash · watch boot logs (Ctrl+C to exit)
docker logs open-webui -f

Wait for Uvicorn running on http://0.0.0.0:8080 — that's the ready signal.

3
Verify It's Running
bash · check container is up
docker ps --filter "name=open-webui" --format "{{.Status}}\t{{.Names}}"

Should show Up X seconds.

Verify it can reach Ollama (from inside the container):

bash · test ollama connection from open-webui
docker exec open-webui curl -s http://ollama:11434/api/tags | head -5

Should return JSON with your installed models — confirms network bridge works.

4
Access via SSH Tunnel (Initial Setup)

Until you set up HTTPS in Tab 14, you'll access Open WebUI via SSH tunnel from your Mac. On your Mac terminal:

bash · SSH tunnel from your Mac
ssh -N -L 8080:open-webui:8080 root@YOUR_SERVER_IP
💡The tunnel target is open-webui:8080 (the container's internal hostname) — SSH will route through the server's Docker network. The YOUR_SERVER_IP placeholder is your Hostinger server's actual IP.

Keep that terminal open. Then open in your Mac browser:

browser URL
http://127.0.0.1:8080

Open WebUI sign-up page should load.

🔒After Tab 14 (HTTPS) is complete, you'll access Open WebUI at https://chat.pocketcode.in — no SSH tunnel needed.
5
Create Admin Account (First User)
⚠️The first user to sign up becomes admin. Do this immediately after launching — don't leave the instance accessible without an admin or someone scanning could squat the role.
  1. Click Sign up (not Sign in — there are no existing accounts)
  2. Enter your name, email, and a strong password
  3. Click Sign up → you're now admin
💡Future signups will land in a pending approval queue that only you (admin) can approve. This means even if Open WebUI is publicly accessible, random people can't just create accounts and use your models.
6
Pull a Model & Start Chatting

If you haven't pulled any models yet, do it from inside the Ollama container:

bash · on server — pull a starter model
docker exec ollama ollama pull llama3.2:3b

Or any model from Tab 2. Open WebUI will auto-detect it.

Back in the browser:

  1. Click the model dropdown at the top center of the chat window
  2. Select your model
  3. Type a message and hit Enter — responses stream in real-time
7
Useful Features to Explore
FeatureHow to use
Document Q&A (RAG)Click the 📎 paperclip in chat input → upload PDF/DOCX/MD → model answers questions about it
Knowledge collectionsProfile → Workspace → Knowledge → New collection → upload multiple docs → reference in chat with #collection-name
Custom system promptsClick ⚙️ on a chat → "System prompt" → pin instructions for the conversation
Model parametersSame ⚙️ menu → adjust temperature, top_p, context length per chat
OpenAI-compatible APISettings → Connections → enable API → point other tools at https://chat.pocketcode.in/api/ as if it were OpenAI
Multi-userAdmin panel → Users → invite teammates → approve their signups
💡Embedding model for RAG: For document Q&A to work well, pull a small embedding model: docker exec ollama ollama pull nomic-embed-text. Then in Open WebUI → Settings → Documents → set "Embedding Model" to nomic-embed-text.
🤖
Claude Code
// Anthropic CLI coding agent · @anthropic-ai/claude-code · node:20-slim
🔗Devcontainer docs: code.claude.com/docs/en/devcontainer · Docker sandbox: docs.docker.com
⚠️Requires Anthropic API key from console.anthropic.com
1
Create Dockerfile
bash · step 1 — create the file
touch ~/ai-stack/claude-code/Dockerfile
bash · step 2 — open in nano
nano ~/ai-stack/claude-code/Dockerfile
dockerfile · step 3 — paste this, then Ctrl+O save, Ctrl+X exit
FROM node:20-slim
RUN useradd -m -u 1001 claude
RUN npm install -g @anthropic-ai/claude-code
WORKDIR /workspace
USER claude
ENTRYPOINT ["claude"]
2
Build Image
bash · command 1 — go to claude-code folder
cd ~/ai-stack/claude-code
bash · command 2 — build the image
docker build -t claude-code .
3
Run on a Project

Create a run script for interactive use:

bash · step 1 — create script
touch ~/ai-stack/claude-code/run-claude.sh
bash · step 2 — open in nano
nano ~/ai-stack/claude-code/run-claude.sh
bash · step 3 — paste this, then Ctrl+O save, Ctrl+X exit
#!/bin/bash
docker run -it --rm \
  --name claude-code \
  --network ai-stack \
  -v $(pwd):/workspace \
  -v ai-shared-data:/shared \
  -e ANTHROPIC_API_KEY="sk-ant-xxxx" \
  claude-code
✏️Replace before saving:
PlaceholderWhat to putWhere to get it
sk-ant-xxxxYour Anthropic API keyconsole.anthropic.com → API Keys
bash · step 4 — make executable
chmod +x ~/ai-stack/claude-code/run-claude.sh
bash · step 5 — run from any project folder
bash ~/ai-stack/claude-code/run-claude.sh
💡Full community setup with MCP + persistent config: github.com/VishalJ99/claude-docker
🔀
OpenRouter
// Cloud API — not self-hosted · Provides 200+ models via one API key
⚠️OpenRouter is a cloud service — no Docker image. You use it by pointing tools to https://openrouter.ai/api/v1 with your API key.
1
Get Your API Key

Generate a key and save it. You'll paste it into OpenClaw and Sim.ai settings to give them access to 200+ models including Claude, GPT-4o, Gemini, and more.

2
Optional: LiteLLM Proxy Container

One internal endpoint for all containers instead of hardcoding OpenRouter URLs everywhere:

bash · step 1 — create script
touch ~/ai-stack/run-openrouter-proxy.sh
bash · step 2 — open in nano
nano ~/ai-stack/run-openrouter-proxy.sh
bash · step 3 — paste this, then Ctrl+O save, Ctrl+X exit
#!/bin/bash
docker run -d \
  --name openrouter-proxy \
  --network ai-stack \
  --restart unless-stopped \
  -e OPENROUTER_API_KEY="sk-or-v1-xxxx" \
  -p 4000:4000 \
  ghcr.io/berriai/litellm:main-latest \
  --model openrouter/anthropic/claude-3.5-sonnet \
  --port 4000
✏️Replace before saving:
PlaceholderWhat to putWhere to get it
sk-or-v1-xxxxYour OpenRouter API keyopenrouter.ai/keys
bash · step 4 — make executable and run
chmod +x ~/ai-stack/run-openrouter-proxy.sh && bash ~/ai-stack/run-openrouter-proxy.sh
💡Other containers call this proxy at http://openrouter-proxy:4000 via the ai-stack network.
🦞
OpenClaw
// Personal AI assistant · CLI chat via ClickClack · OpenRouter + Ollama
🔗GitHub: github.com/openclaw/openclaw · Docs: docs.openclaw.ai/install/docker · Pre-built: github.com/phioranex/openclaw-docker
1
Pull the Docker Image
bash
docker pull ghcr.io/phioranex/openclaw-docker:latest
2
Fix Permissions (Required — avoids EACCES error)

The container runs as the node user (UID 1000) but the host directories are owned by root. Pre-create them with open permissions to avoid a permission denied error during onboarding:

bash · command 1 — create required directories
mkdir -p ~/.openclaw/agents/main/agent
bash · command 2 — create workspace directory
mkdir -p ~/.openclaw/workspace
bash · command 3 — open permissions so container can write
chmod -R 777 ~/.openclaw
⚠️Skipping this step causes: Error: EACCES: permission denied, mkdir '/home/node/.openclaw/agents/main/agent'
3
Create & Run the Onboarding Script
bash · step 1 — create script
touch ~/ai-stack/openclaw/onboard-openclaw.sh
bash · step 2 — open in nano
nano ~/ai-stack/openclaw/onboard-openclaw.sh
bash · step 3 — paste this, then Ctrl+O save, Ctrl+X exit
#!/bin/bash
docker run -it --rm \
  -v ~/.openclaw:/home/node/.openclaw \
  -v ~/.openclaw/workspace:/home/node/.openclaw/workspace \
  ghcr.io/phioranex/openclaw-docker:latest onboard
bash · step 4 — make executable and run
chmod +x ~/ai-stack/openclaw/onboard-openclaw.sh && bash ~/ai-stack/openclaw/onboard-openclaw.sh
4
Onboarding Wizard — Answer Guide

The wizard asks several questions. Here are the recommended answers for your setup:

AI Provider

💡Select OpenRouter and enter your sk-or-v1-xxxx key from openrouter.ai/keys. OpenRouter gives you 200+ models on one key and has free tier models — more cost-friendly than direct Anthropic API.

Model

💡Select Auto — OpenRouter picks the best model per request. For free usage, you can also set: meta-llama/llama-3.1-8b-instruct:free

Channel (QuickStart)

💡Select ClickClack — OpenClaw's built-in web chat. No external accounts or bot setup needed. Others (Discord, Telegram, Slack etc.) require extra API tokens.

Search Provider

💡Select DuckDuckGo Search — free, no API key required, works immediately. Others cost money or need extra setup.

Install Missing Skill Dependencies

💡Select Skip for now — install plugins later only when you need them (e.g. github, nano-pdf, obsidian). None are required to get started.

API Keys for Plugins (goplaces, notion, openai-whisper etc.)

💡Select No for all of them — you skipped the plugins so none of these keys are needed.

Enable Hooks

💡Select 📝 command-logger and 💾 session-memory using Spacebar, then Enter. Skip the rest. session-memory lets OpenClaw remember previous conversations.

How to Hatch

💡Select Hatch in Terminal — launches OpenClaw immediately so you can test it right away.
5
Verify OpenClaw is Running

After hatching you should see this status bar at the bottom:

expected output
local ready | idle
agent main | session main | openrouter/openrouter/auto | tokens ?/200k
What it means: local ready = running · idle = waiting for input · openrouter/auto = connected to OpenRouter · 200k = context window. Now just type and press Enter to chat.
💡The ? on tokens just means no messages sent yet — it shows a number once you start chatting.
6
How to Start OpenClaw for Daily Use

After onboarding is done, do not run the onboard script again. Instead create a dedicated start script that launches straight into chat:

bash · command 1 — create start script
touch ~/ai-stack/openclaw/start-openclaw.sh
bash · command 2 — open in nano
nano ~/ai-stack/openclaw/start-openclaw.sh
bash · command 3 — paste this, then Ctrl+O save, Ctrl+X exit
#!/bin/bash
docker run -it --rm \
  -v ~/.openclaw:/home/node/.openclaw \
  -v ~/.openclaw/workspace:/home/node/.openclaw/workspace \
  ghcr.io/phioranex/openclaw-docker:latest chat
⚠️The chat command at the end is required. Without it, Docker just prints the help menu and exits — it does not open the chat interface.
bash · command 4 — make executable
chmod +x ~/ai-stack/openclaw/start-openclaw.sh
bash · command 5 — launch OpenClaw
bash ~/ai-stack/openclaw/start-openclaw.sh
💡Use this script every time you want to chat with OpenClaw. It skips onboarding and goes straight to the terminal UI.
7
How to Exit OpenClaw

There are two ways to exit — use the in-app command first, Ctrl+C only as a last resort:

preferred — type inside OpenClaw chat then press Enter
/exit
alternative
/quit
⚠️If the terminal freezes and Ctrl+C doesn't work, open a new SSH session and run: pkill -f "openclaw" — then close the frozen terminal window.
8
Start OpenClaw as a Background Container (Gateway Mode)

To run OpenClaw as a persistent background gateway service, create a startup script. The gateway run command at the end is required — without it the container just prints the help menu and crashes in a restart loop.

bash · step 1 — create script using heredoc (avoids copy-paste issues)
cat > ~/ai-stack/openclaw/run-openclaw.sh << 'SCRIPT'
#!/bin/bash
docker run -d \
  --name openclaw \
  --network ai-stack \
  --restart unless-stopped \
  -v ~/.openclaw:/home/node/.openclaw \
  -v ~/.openclaw/workspace:/home/node/.openclaw/workspace \
  -p 18789:18789 \
  -e ANTHROPIC_API_KEY="sk-ant-xxxx" \
  -e OPENROUTER_API_KEY="sk-or-v1-xxxx" \
  -e OLLAMA_BASE_URL="http://ollama:11434" \
  -e DATABASE_URL="postgresql://postgres:postgres@sim-db-1:5432/openclaw" \
  -e OPENCLAW_GATEWAY_BIND=lan \
  ghcr.io/phioranex/openclaw-docker:latest gateway run
SCRIPT
⚠️Port 18789 is the only port you need. It serves both the Control UI / Dashboard and the gateway WebSocket. You'll access it via SSH tunnel in Step 9.
📋About port 18791 (browser plugin): Earlier versions of this guide exposed port 18791 for a browser extension. We've removed it — see the "Known Limitation" callout at the bottom of this tab for details.
✏️Edit the keys before running:
PlaceholderWhat to putWhere to get it
sk-ant-xxxxYour Anthropic API key (optional)console.anthropic.com → API Keys
sk-or-v1-xxxxYour OpenRouter API keyopenrouter.ai/keys
Open with nano ~/ai-stack/openclaw/run-openclaw.sh to edit. Use heredoc (above) instead of typing the script line by line — avoids invisible character / backslash continuation errors.
bash · step 2 — make executable and run
chmod +x ~/ai-stack/openclaw/run-openclaw.sh && bash ~/ai-stack/openclaw/run-openclaw.sh
bash · step 3 — verify it is running (not Restarting)
docker ps | grep openclaw
⚠️If it shows Restarting (0) — check logs with docker logs openclaw --tail 30. Most common cause: missing gateway run at the end of the docker image line.
9
Verify Installation — Access Dashboard via SSH Tunnel

This is your final verification — confirms OpenClaw is fully working with browser access. Complete it to make sure the install succeeded end-to-end.

⚠️Why direct browser access doesn't work: OpenClaw's Control UI binds to 127.0.0.1 inside the container (known bug — issue #30990). Docker port mapping cannot forward external traffic to a localhost-only service. The solution is an SSH tunnel + auth token.

Step A — Set bind mode to lan (one-time fix)

The config file overrides env vars, so even with OPENCLAW_GATEWAY_BIND=lan in the script, the saved config defaults to loopback. Override it explicitly:

bash · command 1 — on server SSH session
docker exec openclaw node /app/dist/index.js config set gateway.bind lan
bash · command 2 — restart to apply
docker restart openclaw

Step B — Verify port 18789 is mapped

bash · command 3 — check port mappings
docker port openclaw

Expected output — should show port 18789:

expected output
18789/tcp -> 0.0.0.0:18789
18789/tcp -> [::]:18789
⚠️If port 18789 is missing — your run script doesn't have -p 18789:18789. Re-do Step 8 with the corrected script and redeploy.

Step C — Get your auth token

The dashboard requires a token to authenticate your browser session. The token is generated during onboarding and stored in the config file:

bash · command 4 — print the config file
docker exec openclaw cat /home/node/.openclaw/openclaw.json

Find the gateway.auth.token value — it looks like this:

json · find this in the output
"auth": {
  "mode": "token",
  "token": "5a72ec1c666424130b638942c6fbb55c17132c686391d25e"
}
💡Copy the token value (the long hex string in quotes). You'll paste it into the dashboard URL in Step E.
⚠️Do not use docker exec openclaw node /app/dist/index.js config get gateway.auth.token — it returns __OPENCLAW_REDACTED__ for security. Read the JSON file directly.

Step D — Open SSH tunnel from your Mac

Open a brand new terminal window on your Mac (not your server SSH session) and run:

bash · command 5 — run on your MAC (new terminal)
ssh -N -L 18789:127.0.0.1:18789 root@YOUR_SERVER_IP
✏️Replace: YOUR_SERVER_IP → your Hostinger server IP (from hPanel)
💡Enter your server password. The terminal will appear to hang silently — that's correct. The -N flag means "no command, just forward". Leave this terminal open and untouched. Closing it kills the tunnel.

Step E — Open OpenClaw dashboard in browser with token

Build your URL by replacing YOUR_TOKEN_HERE with the token from Step C:

url — paste into Mac browser address bar
http://127.0.0.1:18789/#token=YOUR_TOKEN_HERE
✏️Replace: YOUR_TOKEN_HERE → the token value from Step C (just the hex string, no quotes)
The OpenClaw dashboard should load. You can now chat with your agent, manage skills, configure channels, and view sessions — all from your Mac browser. Verification complete.

Daily usage

To access OpenClaw later, just repeat Step D (SSH tunnel) and Step E (browser URL with token). The token doesn't expire unless you regenerate it. Save the full URL as a browser bookmark for one-click access.

Troubleshooting

ProblemFix
"This site can't be reached"SSH tunnel terminal is closed. Re-run command 5 on your Mac.
"This page isn't working" / ERR_EMPTY_RESPONSETunnel forwarding to wrong port inside container. Make sure it's 18789:127.0.0.1:18789.
"docker port openclaw" shows nothingScript missing -p 18789:18789. Stop/rm container, fix script (Step 8), redeploy.
Dashboard loads but shows "Unauthorized"Token wrong or missing in URL. Re-copy from Step C, paste after #token=
"address already in use" on Mac tunnelExisting tunnel running. Kill it: pkill -f "18789:127.0.0.1"
Container restarts in a loopCheck docker logs openclaw --tail 30 — usually missing gateway run at end of script
🛑Known Limitation — Browser Extension does NOT work with self-hosted OpenClaw.

If you discover the OpenClaw browser extension (Chrome Web Store) and try to point it at this self-hosted instance, it will fail with errors like:
  • "Empty reply from server" on port 18791
  • "Wrong port: this is likely the gateway, not the relay. Use gateway port + 3"
  • "Relay not reachable/authenticated at http://127.0.0.1:18792/"
The extension expects a "relay" service on port 18792 that exists only in OpenClaw's hosted/cloud edition. It is NOT shipped in self-hosted OpenClaw 2026.5.12 (verified by inspecting the binary — there are zero relay.* config keys).

Recommended path: The official OpenClaw docs recommend "Browser control via node host" instead — a different mechanism where you pair a separate device/node that runs the browser and OpenClaw controls it via CDP. See docs.openclaw.ai → Browser control via node host if you ever need this. For most use cases, the Dashboard + agents + channels work fine without the extension.
🔀
n8n — Workflow Automation
// Self-hosted workflow automation · Port 5678 · Connects all services together
🔗Docs: docs.n8n.io/hosting/docker · Image: hub.docker.com/r/n8nio/n8n
💡What n8n does: Visual workflow builder that connects your AI services together. Trigger workflows on schedules, webhooks, or events — chain Ollama → OpenRouter → PostgreSQL → email/Slack with no code. Think of it as the "glue" for your AI stack.
📋Prerequisites — complete these first:
  • Tab 12 (Databases) Step 2 — Create the n8n database in sim-db-1
  • Tab 11 (Shared Storage) Step 1 — Create the ai-shared-data Docker volume and /root/ai-stack/uploads folder (run script below mounts both)
  • Tab 0 (Server Setup) — The ai-stack Docker network must exist
1
Generate Encryption Key

n8n encrypts all credentials (API keys, passwords) with a master encryption key. Generate one and save it — losing it means losing access to all stored credentials.

bash · command — generate encryption key (copy the output)
openssl rand -hex 32
⚠️Copy this output to a safe place (1Password, secure note, etc.). You'll need it for the run script below.
2
Create Run Script
bash · step 1 — create folder and script
mkdir -p ~/ai-stack/n8n && touch ~/ai-stack/n8n/run-n8n.sh
bash · step 2 — open in nano
nano ~/ai-stack/n8n/run-n8n.sh
bash · step 3 — paste this, then Ctrl+O save, Ctrl+X exit
#!/bin/bash
docker run -d \
  --name n8n \
  --network ai-stack \
  --restart unless-stopped \
  -p 5678:5678 \
  -e DB_TYPE=postgresdb \
  -e DB_POSTGRESDB_HOST=sim-db-1 \
  -e DB_POSTGRESDB_PORT=5432 \
  -e DB_POSTGRESDB_DATABASE=n8n \
  -e DB_POSTGRESDB_USER=postgres \
  -e DB_POSTGRESDB_PASSWORD=postgres \
  -e N8N_ENCRYPTION_KEY="YOUR_ENCRYPTION_KEY_HERE" \
  -e N8N_HOST=YOUR_SERVER_IP \
  -e N8N_PORT=5678 \
  -e N8N_PROTOCOL=http \
  -e WEBHOOK_URL=http://YOUR_SERVER_IP:5678/ \
  -e GENERIC_TIMEZONE=Asia/Kolkata \
  -e N8N_RUNNERS_ENABLED=true \
  -v n8n-data:/home/node/.n8n \
  -v ai-shared-data:/shared \
  -v /root/ai-stack/uploads:/uploads \
  n8nio/n8n
✏️Replace before saving:
PlaceholderWhat to putWhere to get it
YOUR_ENCRYPTION_KEY_HEREThe hex string from Step 1Output of openssl rand -hex 32
YOUR_SERVER_IPYour Hostinger server IP (appears 2 times)hPanel → VPS dashboard
Asia/KolkataYour timezone (optional)Change if you're outside India — e.g. America/New_York, Europe/London
Leave database, network, and volume settings exactly as shown.
3
Run n8n
🔒No firewall rule needed. We're accessing n8n via SSH tunnel (next step) so port 5678 stays closed to the public internet — more secure. The container still binds to 0.0.0.0:5678 internally so other Docker containers on ai-stack can reach it at http://n8n:5678.
bash · command 1 — make executable and run
chmod +x ~/ai-stack/n8n/run-n8n.sh && bash ~/ai-stack/n8n/run-n8n.sh
bash · command 2 — verify running
docker ps | grep n8n

Expected — must show Up X seconds with port mapping:

expected output
...   n8nio/n8n   ...   Up 8 seconds   0.0.0.0:5678->5678/tcp   n8n
⚠️If Restarting (1) — check logs: docker logs n8n --tail 30. Common causes: wrong encryption key format, can't reach sim-db-1, n8n database doesn't exist in PostgreSQL.
💡If you ever want external webhook access (for Slack, Discord, GitHub triggers), open port 5678 later with ufw allow 5678 AND add -e N8N_SECURE_COOKIE=false to the run script.
4
Access Web UI via SSH Tunnel & Create Owner Account
🔒Why SSH tunnel? n8n enforces secure cookies by default — browsers refuse to set the auth cookie over plain HTTP unless the host is localhost/127.0.0.1 (treated as a secure origin). Direct access via http://YOUR_SERVER_IP:5678 would show: "Your n8n server is configured to use a secure cookie..." error and block login. SSH tunnel solves this AND keeps port 5678 closed to the public internet.

On your Mac terminal (NOT the server SSH session), open a tunnel:

bash · run on Mac terminal — keep this running
ssh -N -L 5678:127.0.0.1:5678 root@YOUR_SERVER_IP
✏️Replace: YOUR_SERVER_IP → your Hostinger server IP. Leave the terminal running (no prompt will appear — that's correct, the tunnel is active).

Then open in Chrome / Safari on your Mac:

url — open in Mac browser
http://127.0.0.1:5678

On first launch, n8n shows an account creation page. Fill in:

FieldWhat to enter
EmailYour email (real or test — used for password reset)
First / Last nameYour name
PasswordStrong password — 8+ chars, mixed case, number
After signup you land on the n8n workflow canvas. The self-hosted Community Edition is free forever with unlimited workflows and executions.
💡Daily use: open the SSH tunnel each time you want to use n8n. To make it easier, add a Mac terminal alias: echo 'alias n8n-tunnel="ssh -N -L 5678:127.0.0.1:5678 root@YOUR_SERVER_IP"' >> ~/.zshrc — then just run n8n-tunnel from any Mac terminal.
5
Set Up Credentials for Your AI Stack

In n8n, click Personal in the left sidebar → switch to the Credentials tab → Create Credential (top right). Add credentials for the services you'll use in workflows:

💡Alternative path: when building a workflow, click any service node (Ollama, Postgres, etc.) and use the "Credential to connect with" dropdown → + Create new credential. Same result, created inline while you build.

A. Ollama credential

Search for "Ollama" in the credential type picker, then:

FieldValue
Base URLhttp://ollama:11434

B. OpenRouter credential (OpenAI-compatible)

Search for "OpenAI" credential type (OpenRouter uses the OpenAI API spec):

FieldValue
API KeyYour sk-or-v1-xxxx key
Base URLhttps://openrouter.ai/api/v1

C. PostgreSQL credential (shared database)

Search for "Postgres" credential type:

FieldValue
Hostsim-db-1
Databaseailab (or whichever you need)
Userpostgres
Passwordpostgres
Port5432
SSLDisable

D. Anthropic credential (optional)

FieldValue
API KeyYour sk-ant-xxxx key
💡Click "Test" before saving each credential — n8n will verify it can actually connect. If Postgres test fails, check that sim-db-1 is on the ai-stack network (Tab 12 Step 1).
6
Build Your First Workflow — Test the Stack

A quick test workflow to confirm everything works together. Click + Add workflow in n8n.

  1. Add a Manual Trigger node (click + → search "manual")
  2. Add an Ollama node → connect after Manual Trigger
    • Credential: Pick the Ollama credential from Step 5A
    • Operation: Generate Text
    • Model: llama3.2 (or whatever you've pulled)
    • Prompt: Say hello to the world
  3. Add a Postgres node → connect after Ollama
    • Credential: Pick the Postgres credential from Step 5C
    • Operation: Execute Query
    • Query: INSERT INTO ai_outputs (source, data) VALUES ('n8n', '{{ JSON.stringify($json) }}')
  4. Click Execute Workflow
If both nodes show green checkmarks, your full stack works: n8n → Ollama → PostgreSQL. You can now build any automation chaining all your services.
⚠️If the Postgres step fails with "relation ai_outputs does not exist", you haven't created the table yet — go to Tab 12 Step 5 first.
7
Useful n8n Workflow Patterns for Your Stack
PatternNodes to chainUse case
RAG pipelineRead File → Ollama Embed → Postgres (pgvector) InsertEmbed docs from /uploads for semantic search
Scheduled summaryCron → Postgres Query → OpenRouter (Claude) → EmailDaily AI-generated reports
Webhook → AIWebhook → OpenRouter → Slack/DiscordExternal apps trigger AI responses
File processingWatch /uploads → Ollama → Write to /sharedAuto-process uploaded files
OpenClaw notifierPostgres trigger → HTTP Request to OpenClawOpenClaw notifies you on DB events
💡n8n has 400+ pre-built nodes — Gmail, Slack, GitHub, Notion, etc. Check the node library in the workflow canvas.
🧪
Sim.ai (SimStudio)
// Visual AI agent workflow builder · Port 3000 · Redis + PostgreSQL required
🔗GitHub: github.com/simstudioai/sim · Docs: docs.sim.ai/self-hosting/docker
⚠️Sim.ai requires minimum 2 vCPU · 12 GB RAM · 20 GB storage. Hostinger KVM 8 covers this with plenty of headroom.
1
Clone the Repository
bash · command 1 — go to sim folder
cd ~/ai-stack/sim
bash · command 2 — clone the repo
git clone https://github.com/simstudioai/sim.git .
bash · command 3 — confirm docker-compose.prod.yml is present
ls
2
Add Redis to docker-compose.prod.yml (Required)

Sim.ai's realtime container requires Redis. The default compose file does not include it — you must add it manually, otherwise sim-realtime-1 will fail with getaddrinfo ESERVFAIL.

bash — open compose file
nano ~/ai-stack/sim/docker-compose.prod.yml

Edit 1 — Find the realtime service's depends_on block and add redis to it:

yaml · find this (realtime depends_on)
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1:3002/health']
yaml · replace with this
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1:3002/health']

Edit 2 — Find the volumes: section at the very bottom and add the Redis service above it. The indentation must be exactly 2 spaces:

yaml · find this (bottom of file)
volumes:
  postgres_data:
yaml · replace with this (2 spaces before redis:)
  redis:
    image: redis:alpine
    restart: unless-stopped
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
⚠️Indentation is critical in YAML. The redis: line must have exactly 2 spaces before it — same as db: and realtime:. Zero spaces causes a validation error.

Save with Ctrl+O then Ctrl+X.

3
Create .env File

Generate your 3 secret keys first — run each command separately and copy each output:

bash · command 1 — generate BETTER_AUTH_SECRET
openssl rand -hex 32
bash · command 2 — generate ENCRYPTION_KEY
openssl rand -hex 32
bash · command 3 — generate INTERNAL_API_SECRET
openssl rand -hex 32
bash · command 4 — create and open .env
touch ~/ai-stack/sim/.env && nano ~/ai-stack/sim/.env
config · command 5 — paste this entire block into nano, then Ctrl+O save, Ctrl+X exit
DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio
BETTER_AUTH_SECRET=PASTE_KEY_1_HERE
ENCRYPTION_KEY=PASTE_KEY_2_HERE
INTERNAL_API_SECRET=PASTE_KEY_3_HERE
NEXT_PUBLIC_APP_URL=http://YOUR_SERVER_IP:3000
BETTER_AUTH_URL=http://YOUR_SERVER_IP:3000
ANTHROPIC_API_KEY=sk-ant-xxxx
OPENROUTER_API_KEY=sk-or-v1-xxxx
OLLAMA_URL=http://ollama:11434
REDIS_URL=redis://redis:6379
✏️Replace every placeholder before saving:
PlaceholderWhat to putWhere to get it
PASTE_KEY_1_HEREOutput of command 1Copy from terminal output above
PASTE_KEY_2_HEREOutput of command 2Copy from terminal output above
PASTE_KEY_3_HEREOutput of command 3Copy from terminal output above
YOUR_SERVER_IPYour Hostinger server IPhPanel → VPS dashboard → IP shown at top
sk-ant-xxxxYour Anthropic API keyconsole.anthropic.com → API Keys
sk-or-v1-xxxxYour OpenRouter API keyopenrouter.ai/keys
Leave DATABASE_URL, OLLAMA_URL and REDIS_URL exactly as shown.
⚠️Use OLLAMA_URL not OLLAMA_BASE_URL — the compose file reads OLLAMA_URL. Using the wrong variable name causes Sim.ai to fall back to localhost:11434 which doesn't work inside Docker.
4
Connect Ollama to Sim.ai's Network

Sim.ai runs on the sim_default Docker network but Ollama runs on ai-stack. They need to be on the same network. Connect Ollama to Sim.ai's network:

bash
docker network connect sim_default ollama
💡This adds Ollama to both networks simultaneously — it stays on ai-stack for OpenClaw and is also reachable from sim_default for Sim.ai. Run this after docker compose up creates the sim_default network.
⚠️This command must be re-run after every docker compose down because that destroys the sim_default network and recreates it fresh on the next up.
5
Start All Services
bash · command 1 — start Sim.ai stack
docker compose -f docker-compose.prod.yml up -d

You should see all 6 containers start successfully:

expected output
✔ sim-redis-1      Healthy
✔ sim-db-1         Healthy
✔ sim-migrations-1 Exited   ← correct, runs once then exits
✔ sim-realtime-1   Healthy
✔ sim-simstudio-1  Started
⚠️If sim-realtime-1 shows Error — check Redis is defined in the compose file with correct 2-space indentation (Step 2) and REDIS_URL=redis://redis:6379 is in your .env (Step 3).
bash · command 2 — connect Ollama to Sim.ai network
docker network connect sim_default ollama
bash · command 3 — restart simstudio to pick up Ollama connection
docker compose -f docker-compose.prod.yml restart simstudio
bash · command 4 — watch logs to confirm ready
docker compose -f docker-compose.prod.yml logs -f
💡Press Ctrl+C to stop watching logs — it does not stop the containers.
6
Create Your Account & Log In

Open Sim.ai in your browser:

url — open in your Mac browser
http://YOUR_SERVER_IP:3000
⚠️Click Sign Up — not Sign In. No account exists yet. Signing in first causes a "User not found" error in the logs.
💡GitHub and Google login show warnings in logs — this is harmless. They're not configured. Use email + password signup instead.
7
Warnings to Ignore

These warnings appear in logs on every startup — all are harmless:

WarningMeaningAction
COPILOT_API_KEY variable is not setGitHub Copilot integration — optionalIgnore
SIM_AGENT_API_URL variable is not setOptional agent URLIgnore
Social provider github is missing clientIdGitHub OAuth not configuredIgnore — use email login
Social provider google is missing clientIdGoogle OAuth not configuredIgnore — use email login
Redis does not require authenticationRedis has no passwordFine — Redis is not exposed to internet
Memory overcommit must be enabledRedis performance warningIgnore for personal use
8
Stop & Restart Sim.ai
bash · stop all containers
docker compose -f docker-compose.prod.yml down
bash · start again
docker compose -f docker-compose.prod.yml up -d
bash · re-connect Ollama after every restart
docker network connect sim_default ollama
⚠️docker compose down destroys the sim_default network. You must run docker network connect sim_default ollama again after every restart — otherwise Ollama models won't be visible in Sim.ai.
Launch & Verify All Services
// Start every container · Check each UI in browser · Confirm working before moving on
💡Stop here and complete this entire tab before moving on to the advanced tabs (Inter-Service, Shared Storage, Databases). This is your sanity check that every service installed in Tabs 3-8 is actually running and accessible.
1
Start All Background Services on Server

Run these on your server SSH session in order. Each service must start successfully before moving to the next.

A. Start Ollama

bash · command 1 — launch Ollama container
bash ~/ai-stack/ollama/run-ollama.sh
bash · command 2 — verify Ollama running
docker ps | grep ollama

B. Start OpenClaw (Gateway Mode)

bash · command 3 — launch OpenClaw
bash ~/ai-stack/openclaw/run-openclaw.sh
bash · command 4 — verify OpenClaw running (must show Up + both ports)
docker ps | grep openclaw

C. Start n8n

bash · command 5 — launch n8n
bash ~/ai-stack/n8n/run-n8n.sh
bash · command 6 — verify n8n running
docker ps | grep n8n

D. Start Sim.ai (with Redis, PostgreSQL, Realtime)

bash · command 7 — start Sim.ai full stack
cd ~/ai-stack/sim && docker compose -f docker-compose.prod.yml up -d
bash · command 8 — connect Ollama to Sim.ai's network
docker network connect sim_default ollama 2>/dev/null || echo "Already connected — OK"
💡If you see endpoint with name ollama already exists — that's fine, Ollama is already connected. The || echo suppresses the error. Move on.
bash · command 9 — restart simstudio so it picks up Ollama
docker compose -f ~/ai-stack/sim/docker-compose.prod.yml restart simstudio

E. Final check — all containers running

bash · command 10 — list all containers
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"

Expected output — all should be Up:

expected output
NAMES              STATUS         PORTS
ollama             Up X minutes   0.0.0.0:11434->11434/tcp
openclaw           Up X minutes   0.0.0.0:18789->18789/tcp
n8n                Up X minutes   0.0.0.0:5678->5678/tcp
sim-redis-1        Up X minutes   6379/tcp
sim-db-1           Up X minutes   0.0.0.0:5432->5432/tcp
sim-realtime-1     Up X minutes   0.0.0.0:3002->3002/tcp
sim-simstudio-1    Up X minutes   0.0.0.0:3000->3000/tcp
⚠️If any container is missing or Restarting, fix that one first using its dedicated tab (Tabs 3-8) before continuing to Step 2.
2
Verify Ollama — Test the API

From server SSH, test the Ollama API. It should respond with a generation:

bash · test Ollama API on server
curl http://localhost:11434/api/generate -d '{"model":"llama3.2","prompt":"Hello!","stream":false}'
If you get a JSON response with generated text — Ollama works.
⚠️If error: model not found — pull it first: docker exec -it ollama ollama pull llama3.2

Optionally, from your Mac browser visit (it shows "Ollama is running"):

url — Mac browser
http://YOUR_SERVER_IP:11434
✏️Replace: YOUR_SERVER_IP → your Hostinger server IP
3
Verify OpenClaw — Dashboard via SSH Tunnel

OpenClaw requires SSH tunnel + auth token (see Tab 7 Step 9 for the full explanation of the bug).

A. Get auth token (on server)

bash · server — read token from config file
docker exec openclaw cat /home/node/.openclaw/openclaw.json | grep -A3 '"auth"'

Look for the gateway.auth block (first match — the second match is for OpenRouter profiles, ignore that one). It will look like:

expected output — copy the hex string in the "token" line
    "auth": {
      "mode": "token",
      "token": "5a72ec1c666424130b638942c6fbb55c17132c686391d25e"
    }

B. Open SSH tunnel from Mac (new terminal)

bash · run on your MAC
ssh -N -L 18789:127.0.0.1:18789 root@YOUR_SERVER_IP
✏️Replace: YOUR_SERVER_IP → your Hostinger server IP. Leave terminal silently open after password.

C. Open Dashboard in Mac browser

url — paste in Mac browser
http://127.0.0.1:18789/#token=YOUR_TOKEN_HERE
✏️Replace: YOUR_TOKEN_HERE → the hex string from Step A
OpenClaw Dashboard loads, lets you chat with the agent, manage skills and channels. Save this URL as a browser bookmark for daily access.
4
Verify n8n — SSH Tunnel + 127.0.0.1

n8n enforces secure cookies — accessing via http://YOUR_SERVER_IP:5678 blocks login with: "Your n8n server is configured to use a secure cookie...". Use an SSH tunnel — browsers treat 127.0.0.1 as a secure origin.

On your Mac terminal (separate from server SSH):

bash · run on Mac terminal — keep this running
ssh -N -L 5678:127.0.0.1:5678 root@YOUR_SERVER_IP
✏️Replace: YOUR_SERVER_IP → your Hostinger server IP. No prompt = tunnel is active.

Then open in your Mac browser:

url — open in Mac browser
http://127.0.0.1:5678
First time you'll see a setup wizard asking you to create an owner account. After signup, you land on the n8n canvas. The Community edition is free with unlimited workflows.
5
Verify Sim.ai — Direct Browser Access

Sim.ai binds to 0.0.0.0:3000 — no SSH tunnel needed, direct browser access works.

url — open in Mac browser
http://YOUR_SERVER_IP:3000
✏️Replace: YOUR_SERVER_IP → your Hostinger server IP
⚠️Click Sign Up (not Sign In) for the first time — no account exists yet. Signing in shows "User not found" error.
After signup, you land on the Sim.ai workflow canvas. Try creating a new workflow and dragging an AI block in to confirm models work.
6
Verify Claude Code — Test in Project

Claude Code is a CLI tool — verify by running it on a test project:

bash · command 1 — create test folder on server
mkdir -p ~/test-project && cd ~/test-project && echo "console.log('hello')" > app.js
bash · command 2 — run Claude Code
bash ~/ai-stack/claude-code/run-claude.sh
Claude Code launches interactively in your terminal. Type a question like "What does app.js do?" — if it responds correctly, Claude Code is working.
7
Verify OpenRouter — Test API Key

OpenRouter is a cloud API — no container to check. Verify your key works:

bash · test OpenRouter API on server
curl https://openrouter.ai/api/v1/chat/completions \
  -H "Authorization: Bearer sk-or-v1-xxxx" \
  -H "Content-Type: application/json" \
  -d '{"model":"openrouter/auto","messages":[{"role":"user","content":"Hello!"}]}'
✏️Replace: sk-or-v1-xxxx → your real OpenRouter API key
If you get a JSON response with an assistant message — your OpenRouter key is valid and being used by OpenClaw + Sim.ai correctly.
8
Final Checklist Before Moving On
ServiceHow to verify✓ Working?
Ollamacurl http://YOUR_IP:11434 → "Ollama is running"
OpenClaw DashboardSSH tunnel + browser http://127.0.0.1:18789/#token=...
n8nSSH tunnel + browser http://127.0.0.1:5678
Sim.aiBrowser http://YOUR_IP:3000 → signup works
Claude CodeTerminal launches, responds to prompt
OpenRoutercurl returns JSON with assistant message
All six checked? Continue to Tab 10 — Inter-Service Comms to wire services together properly.
⚠️Any failing? Go back to the specific service's tab (3-8) and fix it. Do not skip ahead — the advanced tabs assume everything here is working.
9
Daily Startup — Quick Reference

After server reboots or maintenance, restart everything in this order:

bash · command 1 — restart all (containers with --restart flag come back automatically)
docker ps -a
bash · command 2 — if Sim.ai stack is down, restart it
cd ~/ai-stack/sim && docker compose -f docker-compose.prod.yml up -d
bash · command 3 — reconnect Ollama to Sim.ai network (safe if already connected)
docker network connect sim_default ollama 2>/dev/null || echo "Already connected — OK"
bash · command 4 — verify everything is up
docker ps --format "table {{.Names}}\t{{.Status}}"
📊
Qdrant — vector database
// High-perf vector store for embeddings · Used by Open WebUI for RAG, by sim for memory nodes, and by any agent that needs similarity search
💡Qdrant runs as a single container with its own named volume (qdrant-data). The HTTP API is gated by Caddy at qdrant.pocketcode.in; an internal gRPC port stays inside the Docker network.
1
Run the container

The run script is at services/qdrant/run-qdrant.sh — already committed.

bash
bash ~/pocketcode-project/services/qdrant/run-qdrant.sh

Binds 127.0.0.1:6333 (HTTP) and 127.0.0.1:6334 (gRPC). Volume qdrant-data persists across restarts.

2
Verify
bash · expect "title":"qdrant - vector search engine"
curl -s http://127.0.0.1:6333/ | jq .title

External: https://qdrant.pocketcode.in (auth_gate).

🐘
pgAdmin — Postgres web UI
// Browse + query the 5 databases on sim-db-1 (simstudio, n8n, openclaw, ailab, ollama_results)
💡Auto-login wired: the gateway forwards a PGADMIN_DEFAULT_EMAIL + PGADMIN_DEFAULT_PASSWORD form post on first iframe load. No separate pgAdmin login.
1
Run
bash
bash ~/pocketcode-project/services/pgadmin/run-pgadmin.sh

External: https://pgadmin.pocketcode.in. The sim-db-1 server is pre-registered via servers.json.

🔑
v2 — Secrets pipeline & shared bearer token
// Everything v2 needs before you can build any MCP · `secrets.json` → regenerate-env → `secrets.env`
💡All v2 MCP servers are HTTP, fronted by Caddy on mcp-*.pocketcode.in, and authenticate inbound requests with a single shared bearer token. One token, seven (and growing) services.
1
Mint the shared bearer token

Generate a random 48-byte hex token and store it in secrets.json under mcp.bearer_token.

bash
TOKEN=$(openssl rand -hex 48)
jq --arg t "$TOKEN" '.mcp.bearer_token = $t' \
  ~/pocketcode-project/secrets/secrets.json \
  > /tmp/sj && mv /tmp/sj ~/pocketcode-project/secrets/secrets.json
chmod 600 ~/pocketcode-project/secrets/secrets.json
2
Regenerate secrets.env

operations/lib/regenerate-env.sh reads secrets.json and emits exports into secrets.env (and per-service .env files). Every run-*.sh sources this.

bash
bash ~/pocketcode-project/operations/lib/regenerate-env.sh
grep MCP_BEARER ~/pocketcode-project/secrets/secrets.env
3
Register MCPs in .mcp.json

Already committed at the project root. Lists all 7 MCPs. Any claude session started from ~/pocketcode-project picks them up automatically.

bash · sanity check
jq '.mcpServers | keys' ~/pocketcode-project/.mcp.json
# → ["airtable","n8n","postgres","sim","voice","web-search","yt-dlp"]

To launch Claude with the registry active:

bash
cd ~/pocketcode-project
source secrets/secrets.env
claude
PG
mcp-postgres — Postgres query tools
// 4 tools · list_databases · list_tables · describe_table · query · Connects as postgres superuser
💡The keystone MCP — first one built and the template every later MCP follows. Source: services/mcp-postgres/.
1
Build the image & run
bash
cd ~/pocketcode-project/services/mcp-postgres
docker build -t mcp-postgres:latest .
bash run-mcp-postgres.sh
docker logs mcp-postgres --tail 5
2
Caddy block (already in Caddyfile)

Look for the mcp-postgres.pocketcode.in site block in infrastructure/caddy/Caddyfile. Uses flush_interval -1 (for SSE) and 5-minute timeouts. NO auth_gate — bearer validated inside the MCP itself.

3
Verify end-to-end
bash · expect HTTP 200 + initialize result
TOKEN=$(jq -r .mcp.bearer_token ~/pocketcode-project/secrets/secrets.json)
curl -sk -o /dev/null -w "/health = %{http_code}\n" https://mcp-postgres.pocketcode.in/health
curl -sk -X POST https://mcp-postgres.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
  | head -c 300
AT
mcp-airtable — Airtable record CRUD
// 7 tools · list_bases · list_tables · describe_table · list/get/create/update_record · Default base: call_logs
💡Used by the voice agent (via outbox) to log each call's transcript + summary. Needs an Airtable Personal Access Token with data.records:read, data.records:write, schema.bases:read.
1
Mint an Airtable Personal Access Token

In Airtable: Builder Hub → Personal Access Tokens → Create new token. Grant:

  • data.records:read and data.records:write
  • schema.bases:read
  • Access scope: pick the base that will hold your Call Logs (or "All current and future bases" if you'll log to several)

Copy the patXXXXXXXXXXXX.YYYY… token. You only see it once.

2
Create the Call Logs table

Add a table named Call Logs with these fields (types matter — voice-playground writes with typecasting on, but malformed fields can still fail):

  • caller_id · Single line text
  • caller_name · Single line text (optional but recommended; airtable_cleanup.py can backfill this)
  • timestamp · Date — include time, ISO 8601
  • duration_sec · Number — precision 0
  • transcript · Long text
  • summary · Long text

Note the base id (starts with app…, visible in the URL) and table id (starts with tbl…, in the API docs view).

3
Add the PAT + base ID to secrets.json
bash
jq '.airtable.pat = "patXXXXX..."
    | .airtable.base_call_logs = "appXXXXX..."
    | .airtable.table_call_logs = "Call Logs"' \
  ~/pocketcode-project/secrets/secrets.json > /tmp/s && mv /tmp/s ~/pocketcode-project/secrets/secrets.json
chmod 600 ~/pocketcode-project/secrets/secrets.json
bash ~/pocketcode-project/operations/lib/regenerate-env.sh
grep AIRTABLE ~/pocketcode-project/secrets/secrets.env
4
Build & run
bash
cd ~/pocketcode-project/services/mcp-airtable
docker build -t mcp-airtable:latest .
bash run-mcp-airtable.sh
docker logs mcp-airtable --tail 5
5
Verify with a real CRUD round-trip
bash · creates and immediately deletes a test row
TOKEN=$(jq -r .mcp.bearer_token ~/pocketcode-project/secrets/secrets.json)
SID=$(curl -sk -i -X POST https://mcp-airtable.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}' \
  | grep -i 'mcp-session' | awk '{print $2}' | tr -d '\r')
curl -sk -X POST https://mcp-airtable.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" \
  -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -sk -X POST https://mcp-airtable.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" \
  -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# → list_bases / list_tables / describe_table / list_records / get_record / create_record / update_record

A new "Call Logs" row appears in your base every time the voice agent ends a call. To clean up old empty rows or backfill caller_name, run python3 ~/pocketcode-project/services/voice-playground/airtable_cleanup.py.

mcp-web-search — Brave Search wrapper
// Single tool: web_search(query, count, freshness, country, safesearch) · Free tier: 2000/month
💡The voice agent's web_search function-tool uses this MCP. Free tier doesn't need a credit card — just an email.
1
Get a Brave Search API key

Sign up at brave.com/search/apiSubscriptions → Free. Copy the API key from the dashboard (format: BSAxxxxxxxxxxxxxxxxxxxxxxxx). The 2000/month quota is plenty for one-operator use; voice-agent calls average ~5–10 searches per active call.

2
Add the key to secrets.json
bash
jq '.brave_search.api_key = "BSAxxxxxxxxxxxxxxxxxxxxxxxx"' \
  ~/pocketcode-project/secrets/secrets.json > /tmp/s && mv /tmp/s ~/pocketcode-project/secrets/secrets.json
chmod 600 ~/pocketcode-project/secrets/secrets.json
bash ~/pocketcode-project/operations/lib/regenerate-env.sh
grep BRAVE ~/pocketcode-project/secrets/secrets.env
3
Build & run
bash
cd ~/pocketcode-project/services/mcp-web-search
docker build -t mcp-web-search:latest .
bash run-mcp-web-search.sh
docker logs mcp-web-search --tail 5
4
Smoke test
bash · health endpoint should be 200
curl -sk -o /dev/null -w "%{http_code}\n" https://mcp-web-search.pocketcode.in/health

Quota visible in the Brave dashboard. If you see HTTP 429 in the tool result, the search count limit has been hit — the LLM gracefully reports it instead of crashing.

📺
mcp-yt-dlp — YouTube / 1000+ sites via yt-dlp
// 8 tools · search · subtitles · video/audio/transcript download · metadata · Image: ~970 MB (yt-dlp + ffmpeg)
💡YouTube aggressively rate-limits cloud-IP downloads. Drop a cookies.txt at ~/pocketcode-project/data/downloads/cookies.txt to bypass — search/metadata work without it.
1
Create the downloads directory
bash · shared with yt-dlp-ui
mkdir -p ~/pocketcode-project/data/downloads
chown 1000:1000 ~/pocketcode-project/data/downloads
chmod 755 ~/pocketcode-project/data/downloads
2
Build & run (~3 min first time)
bash
cd ~/pocketcode-project/services/mcp-yt-dlp
docker build -t mcp-yt-dlp:latest .
bash run-mcp-yt-dlp.sh
docker logs mcp-yt-dlp --tail 5

Container bind-mounts data/downloads/ at /downloads read-write so downloaded files persist on the host. yt-dlp-ui shares the same bind so files from either path show up in both UIs.

3
(Optional but recommended) cookies.txt for YouTube anti-bot

YouTube progressively tightens anti-bot checks on data-centre IPs. Search and metadata calls work without cookies; per-video downloads on cloud IPs often need them.

  1. Install a browser extension like "Get cookies.txt LOCALLY".
  2. Visit youtube.com while logged in, export cookies to cookies.txt (Netscape format).
  3. SCP it onto the VPS: scp cookies.txt root@VPS:/root/pocketcode-project/data/downloads/cookies.txt
  4. No container restart needed — the bind-mount is live.
⚠️cookies.txt grants access to your YouTube account session — treat it like a password file.
4
Verify the search tool works without cookies
bash · health endpoint
curl -sk -o /dev/null -w "%{http_code}\n" https://mcp-yt-dlp.pocketcode.in/health
# If the home grid card "📺 YouTube Downloader" opens and the Search tab returns results,
# the MCP layer is happy — yt-dlp-ui proxies search/metadata through this MCP.
SI
mcp-sim — sim.ai workflow CRUD
// 9 tools · list_workspaces · workflow CRUD · execute · get_logs · Auth model: BetterAuth session via auto-relogin service account
💡sim.ai's external API keys only work for per-workflow execution endpoints, NOT CRUD. mcp-sim works around this by logging in as a service account (the same SSO creds auth-gateway uses) and caching the BetterAuth session cookie — auto-refreshes on 401.
1
Confirm sim is running and SSO works

docker ps | grep sim-simstudio should show Up (healthy). Open sim.pocketcode.in from the home page — auto-SSO should log you straight in.

2
Confirm SSO creds are in secrets.json

mcp-sim reads its login creds from the same sso_creds_for_gateway.sim block the auth-gateway uses. regenerate-env.sh exposes them as SIM_SSO_EMAIL + SIM_SSO_PASSWORD.

bash · expect the email field; the password is sensitive but should print non-empty
jq '.sso_creds_for_gateway.sim | {email}' ~/pocketcode-project/secrets/secrets.json
grep SIM_SSO_EMAIL ~/pocketcode-project/secrets/secrets.env
3
Build & run
bash
cd ~/pocketcode-project/services/mcp-sim
docker build -t mcp-sim:latest .
bash run-mcp-sim.sh
docker logs mcp-sim --tail 10
# Healthy: "logged in (cookies: ['__Secure-better-auth.session_token', ...])"
4
Verify with list_workspaces

From a Claude Code session at the project root: just ask "list my sim workspaces". The session picks up .mcp.json automatically. Or from curl:

bash · expect 9 tools and your real workspace
TOKEN=$(jq -r .mcp.bearer_token ~/pocketcode-project/secrets/secrets.json)
curl -sk -o /dev/null -w "%{http_code}\n" https://mcp-sim.pocketcode.in/health
n8n
mcp-n8n — n8n workflow CRUD + execution
// 12 tools · workflow CRUD · activate/deactivate · execute · executions · credentials · tags · Auth: X-N8N-API-KEY
💡n8n's public API at /api/v1/* accepts a personal API key (X-N8N-API-KEY header). Only the POST response contains the raw JWT; subsequent GETs return a masked value, so you must capture it the first time.
1
Make sure n8n is up and you can SSO in

From the home page, click the n8n card. You should land on the n8n editor with a session — confirms the auto-SSO flow worked.

2
Mint the n8n API key (one-time, programmatic)

The simplest route: open n8n → Settings → API → Create new key, copy it (you only see the raw key once), save to secrets.json. Or do it programmatically:

bash · login → mint key with all 68 scopes → capture rawApiKey
EMAIL=$(jq -r .sso_creds_for_gateway.n8n.email ~/pocketcode-project/secrets/secrets.json)
PASSWORD=$(jq -r .sso_creds_for_gateway.n8n.password ~/pocketcode-project/secrets/secrets.json)
COOKIES=$(mktemp)
curl -sk -c "$COOKIES" -X POST https://n8n.pocketcode.in/rest/login \
  -H "Content-Type: application/json" \
  -d "{\"emailOrLdapLoginId\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" > /dev/null
# Use the scopes JSON from services/mcp-n8n/README.md (all 68 scopes).
SCOPES='[...full 68-scope array — see services/mcp-n8n/README.md...]'
RAW=$(curl -sk -b "$COOKIES" -X POST https://n8n.pocketcode.in/rest/api-keys \
  -H "Content-Type: application/json" \
  -d "{\"label\":\"mcp-n8n\",\"expiresAt\":null,\"scopes\":$SCOPES}" | jq -r '.data.rawApiKey')
jq --arg k "$RAW" '.services.n8n.api_key = $k' \
  ~/pocketcode-project/secrets/secrets.json > /tmp/s && mv /tmp/s ~/pocketcode-project/secrets/secrets.json
chmod 600 ~/pocketcode-project/secrets/secrets.json
bash ~/pocketcode-project/operations/lib/regenerate-env.sh
rm -f "$COOKIES"
3
Build & run
bash
cd ~/pocketcode-project/services/mcp-n8n
docker build -t mcp-n8n:latest .
bash run-mcp-n8n.sh
docker logs mcp-n8n --tail 5
4
Sanity check
bash · expect 200
curl -sk -o /dev/null -w "%{http_code}\n" https://mcp-n8n.pocketcode.in/health

Both mcp-sim and mcp-n8n are live together (sim was the v2 design's preference but n8n's 100+ node ecosystem is too useful to lose). Ask Claude "list my n8n workflows" once your session is launched from the project root.

📲
mcp-voice — outbound PSTN calling
// 3 tools · dial_phone(to_number, prompt?) · list_active_calls · end_call · Uses LiveKit Outbound Trunk + Twilio termination
💡Depends on the Twilio + LiveKit outbound setup from Tab 23. The MCP itself is just a thin wrapper around livekit.SIP.CreateSIPParticipant with bearer auth + room cleanup.
1
Build & run
bash
cd ~/pocketcode-project/services/mcp-voice
docker build -t mcp-voice:latest .
bash run-mcp-voice.sh

Container picks up LIVEKIT_SIP_OUTBOUND_TRUNK_ID from secrets.env — make sure Tab 23 (Outbound calling) is complete first.

2
Quick test (no actual call — just tools/list)
bash
TOKEN=$(jq -r .mcp.bearer_token ~/pocketcode-project/secrets/secrets.json)
SID=$(curl -sk -i -X POST https://mcp-voice.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json, text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}' \
  | grep -i "mcp-session" | awk '{print $2}' | tr -d '\r')
curl -sk -X POST https://mcp-voice.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" \
  -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -sk -X POST https://mcp-voice.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" \
  -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'  # → dial_phone, list_active_calls, end_call
🗄️
mcp-voice-history — read-only call history MCP
// 7 tools · list_callers · get_caller · list_calls · get_call (full transcript + tool_invocations) · search_calls_by_summary · list_callbacks · resolve_callback
💡The 8th MCP. Read-only over voice_pg (set up in Tab 23). Lets any AI client query call history with one bearer token — Claude Code on the VPS, Claude Desktop on a Mac, sim agents, even the voice agent itself mid-call (e.g. "have I spoken with this number before?").
1
Prereq: Tab 23 done (voice_pg + tables exist)

This MCP is a thin asyncpg wrapper. Tab 23 creates the voice_pg database, applies the 8-table schema, and emits the VOICE_PG_DSN env var. Confirm before continuing:

bash · should print 8 tables
docker exec sim-db-1 psql -U postgres -d voice_pg -c "\dt"
grep VOICE_PG_DSN ~/pocketcode-project/secrets/secrets.env
2
Build & run
bash
cd ~/pocketcode-project/services/mcp-voice-history
docker build -t mcp-voice-history:latest .
bash run-mcp-voice-history.sh
docker logs mcp-voice-history --tail 5

External: https://mcp-voice-history.pocketcode.in/mcp · port 127.0.0.1:8775 internally.

3
Verify via tools/list
bash · expect 7 tools enumerated
TOKEN=$(jq -r .mcp.bearer_token ~/pocketcode-project/secrets/secrets.json)
SID=$(curl -sk -i -X POST https://mcp-voice-history.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}' \
  | grep -i 'mcp-session' | awk '{print $2}' | tr -d '\r')
curl -sk -X POST https://mcp-voice-history.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" \
  -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -sk -X POST https://mcp-voice-history.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" \
  -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

Register in .mcp.json as voice-history so any claude session at the project root automatically gets the 7 read tools — already done out of the box; nothing to edit.

21. mcp-gmail — Gmail send / draft / read / search / labels

FastMCP HTTP+Bearer wrapper around the Google Gmail API. Five tools so your AI helpers (or the voice agent's send_followup_email tool) can write emails on your behalf.

💡 One-time Google Cloud Console setup (Phase 4A). The Gmail + Calendar MCPs share ONE OAuth Desktop client. Do this once on your own machine before the next steps:
  1. Go to console.cloud.google.com → New Project → name it pocketcode-mcp
  2. Enable APIs: Gmail API + Google Calendar API
  3. OAuth consent screen → External → add your email as test user → App name: PocketCodeIn MCP
  4. Credentials → Create OAuth Client ID → Desktop application → name: pocketcode-mcp-client → download JSON
  5. Open the JSON; copy client_id and client_secret into secrets/secrets.json under services.google.oauth_client_id + services.google.oauth_client_secret
  6. Also add: services.google.project_name = "pocketcode-mcp", app_name = "PocketCodeIn MCP", support_email = <your email>, desktop_client_name = "pocketcode-mcp-client"
1

Run the one-time OAuth bootstrap

The bootstrap script prints a URL you click on your laptop (browser), redirects back through a deliberately-failing http://localhost, you paste the failed URL back, it extracts the code and writes the refresh token straight into secrets.json.

bash · one-time
cd ~/pocketcode-project/services/mcp-gmail
python3 oauth_bootstrap.py
# → click the printed URL on your laptop, paste back the failed-redirect URL
~/pocketcode-project/operations/lib/regenerate-env.sh
2

Build & run

bash
cd ~/pocketcode-project/services/mcp-gmail
docker build -t mcp-gmail:latest .
bash run-mcp-gmail.sh
3

Smoke-test against the live Gmail API

bash · expect labels list
TOKEN=$(jq -r .mcp.bearer_token ~/pocketcode-project/secrets/secrets.json)
SID=$(curl -sk -i -X POST https://mcp-gmail.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}' \
  | grep -i 'mcp-session' | awk '{print $2}' | tr -d '\r')
curl -sk -X POST https://mcp-gmail.pocketcode.in/mcp -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -sk -X POST https://mcp-gmail.pocketcode.in/mcp -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_labels","arguments":{}}}'

22. mcp-gcal — Calendar availability + Meet booking

Same shape as mcp-gmail. Seven tools: list_calendars, check_availability, find_free_slots, create_event (with auto-Meet link), list_events, get_event, delete_event.

💡 mcp-gcal SHARES the OAuth refresh token with mcp-gmail (one Desktop client covers both APIs). You do not run oauth_bootstrap.py again — just build & run.
1

Build & run

bash
cd ~/pocketcode-project/services/mcp-gcal
docker build -t mcp-gcal:latest .
bash run-mcp-gcal.sh
2

Smoke-test against your real calendar

bash · expect your primary + holidays
TOKEN=$(jq -r .mcp.bearer_token ~/pocketcode-project/secrets/secrets.json)
SID=$(curl -sk -i -X POST https://mcp-gcal.pocketcode.in/mcp \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}' \
  | grep -i 'mcp-session' | awk '{print $2}' | tr -d '\r')
curl -sk -X POST https://mcp-gcal.pocketcode.in/mcp -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -sk -X POST https://mcp-gcal.pocketcode.in/mcp -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_calendars","arguments":{}}}'
The voice agent's find_demo_slots + book_demo tools automatically pick up the new MCP — no agent restart needed beyond the standard start-all.sh rebuild.
🛰️
mcp-hostinger — Hostinger VPS/DNS/domains/billing/hosting
// 118 tools across VPS · DNS · domains · billing · hosting · Wraps the official hostinger-api-mcp (Node, --http) · Host bind 127.0.0.1:8778
💡This MCP wraps the official hostinger-api-mcp Node package (run with --http) inside a small Docker image at services/mcp-hostinger/. An in-container Node bearer-proxy (proxy.mjs) sits in front of it and enforces Authorization: Bearer ${MCP_BEARER_TOKEN} exactly like the other MCPs — the upstream package has no bearer of its own. Public URL: https://mcp-hostinger.pocketcode.in/, where the MCP endpoint is the ROOT path / (NOT /mcp like the FastMCP servers).
⚠️Includes destructive operations — use with care. Across the 118 tools are VPS reinstall/delete, DNS record overwrite, domain transfer, and billing actions. The bearer protects it from the public internet, but anything that can reach it (Claude Code, sim agents) can also issue irreversible Hostinger API calls. Treat it like prod.
1
Generate a Hostinger API token

In hPanel: Account Information → API → generate a new API token. Copy it (you only see it once). This token authenticates the upstream hostinger-api-mcp package against the Hostinger REST API.

2
Add the token to secrets.json

It lives at secrets.json.services.hostinger.api_token and is emitted as HOSTINGER_API_TOKEN by regenerate-env.sh.

bash
jq '.services.hostinger.api_token = "PASTE_HOSTINGER_TOKEN"' \
  ~/pocketcode-project/secrets/secrets.json > /tmp/s && mv /tmp/s ~/pocketcode-project/secrets/secrets.json
chmod 600 ~/pocketcode-project/secrets/secrets.json
bash ~/pocketcode-project/operations/lib/regenerate-env.sh
grep HOSTINGER_API_TOKEN ~/pocketcode-project/secrets/secrets.env
3
Build & run

The container runs the upstream hostinger-api-mcp --http behind proxy.mjs; only the proxy port is bound on the host (127.0.0.1:8778).

bash
cd ~/pocketcode-project/services/mcp-hostinger
docker build -t mcp-hostinger:latest .
bash run-mcp-hostinger.sh
docker logs mcp-hostinger --tail 5
4
Verify end-to-end (note: endpoint is /, not /mcp)
bash · expect a tools/list with 118 tools
TOKEN=$(jq -r .mcp.bearer_token ~/pocketcode-project/secrets/secrets.json)
SID=$(curl -sk -i -X POST https://mcp-hostinger.pocketcode.in/ \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}' \
  | grep -i 'mcp-session' | awk '{print $2}' | tr -d '\r')
curl -sk -X POST https://mcp-hostinger.pocketcode.in/ -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -sk -X POST https://mcp-hostinger.pocketcode.in/ -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
📺
yt-dlp-ui — browser UI for the yt-dlp tool layer
// 3-tab SPA · paste URL · search YouTube · recent files · Real-time SSE progress · Companion to mcp-yt-dlp
💡The UI server runs yt-dlp itself for downloads (so it can stream progress to the browser) and proxies search/metadata/transcript calls to mcp-yt-dlp server-side. The bearer token never leaves the VPS. Same image base as mcp-yt-dlp (~920 MB with ffmpeg).
1
Prereq: mcp-yt-dlp running (Tab 16)

The UI proxies search and metadata calls to mcp-yt-dlp over the internal Docker network. Check that it's up:

bash
docker ps --filter "name=mcp-yt-dlp" --format '{{.Status}}'
2
Build & run
bash · ~3 min first build (ffmpeg again)
cd ~/pocketcode-project/services/yt-dlp-ui
docker build -t yt-dlp-ui:latest .
bash run-yt-dlp-ui.sh
docker logs yt-dlp-ui --tail 5

Healthy startup line: yt-dlp-ui starting (mcp=http://mcp-yt-dlp:8000/mcp, downloads=/downloads, cookies=...).

3
Caddy wrapper is pre-wired

The ytdl.pocketcode.in block in the Caddyfile uses the full L4 wrapper pattern (auth_gate + service-wrapper iframe + status bar) PLUS an @sse matcher for /api/download/* with 30-minute timeouts and flush_interval -1 so download progress reaches the browser in real time. No extra Caddy reload needed.

4
Use it from the home page

Log in at pocketcode.in → click 📺 YouTube Downloader. Three tabs:

  • 📎 Paste URL — fetch metadata, then download video / audio / transcript with live progress.
  • 🔎 Search YouTube — query string → result grid → click a card to load it into the URL tab.
  • 📁 Recent files — list of files in data/downloads/ with download-to-laptop links.
🎙️
voice-playground — talk + listen + watch
// Browser → voice agent (no phone needed) · OR · dial-out + listen-along + WhatsApp-style live transcripts · Per-call model + voice picker (Aura voices, Claude/Ollama LLMs) · Pending callbacks · voice_pg-backed call history
💡Mints a LiveKit access token server-side (keeps API secret off the browser), then the browser joins the room via livekit-client SDK. The Dispatch Rule auto-joins the agent worker into any new room. Same agent worker as PSTN calls — only the ingress (WebRTC vs SIP) differs.
⚠️Prereqs: Tabs 22 (livekit-agent registered) + 23 (voice data plane: voice_pg + kb_chunks). The dial-out card additionally needs Tab 25 (Outbound calling — LiveKit Outbound Trunk + Twilio termination).
🔀Voice Playground 2 (VP2) — sibling "sim-backbone" variant. A separate voice-agent variant lives at voice2.pocketcode.in with its own repo at /root/voice-playground-2/ (NOT part of pocketcode-project). Deploy it via its own operations/start-vp2.sh + diagnose-vp2.sh; its containers join both the ai-stack and sim_default networks, and it adds one additive table voice_pg.workflow_outbox. pocketcode's ai-start / ai-stop / ai-doctor already manage it. Don't reuse these VP1 tabs — see VP2's own docs in its repo.
1
Create the TTS preview cache dir

The voice picker's ▶ preview button caches each rendered MP3 keyed by sha256(voice_id + sample text). Cache hits drop click latency from ~500 ms to ~5 ms and cut the per-click TTS bill.

bash · uid 1000 because the container runs as that user
mkdir -p ~/pocketcode-project/data/tts-cache
chown 1000:1000 ~/pocketcode-project/data/tts-cache
chmod 700 ~/pocketcode-project/data/tts-cache
2
Build & run
bash
cd ~/pocketcode-project/services/voice-playground
docker build -t voice-playground:latest .
bash run-voice-playground.sh
docker logs voice-playground --tail 5

The container picks up LIVEKIT_*, DEEPGRAM_API_KEY, ELEVENLABS_API_KEY, MCP_BEARER_TOKEN, MCP_AIRTABLE_URL=http://mcp-airtable:8000/mcp, OLLAMA_BASE_URL, and VOICE_PG_DSN from secrets.env via --env-file.

3
Drive the two tabs

Open https://voice.pocketcode.in:

  • 🎤 Audio test — pick LLM + voice, hit 📞, talk to the agent through your browser. Live WhatsApp-style transcript on the right.
  • 📲 Outbound AI call — phone field + supervisor field + prompt textarea + 📲. The browser auto-joins the LiveKit room as an observer so you can listen along + watch the live transcript. Pending callbacks card lists rows from voice_pg.callbacks with status='pending'; each row has 📲 Dial-back and ✓ Resolve.

Recent Call Logs at the bottom reads /api/calls (PG, fast) instead of Airtable, so you get the call list in ~80 ms even with hundreds of rows. Click a row to expand the WhatsApp-style transcript.

4
(Optional) clean up legacy empty Airtable rows
bash · idempotent — adds caller_name field if missing + deletes empty rows
python3 ~/pocketcode-project/services/voice-playground/airtable_cleanup.py --dry-run
python3 ~/pocketcode-project/services/voice-playground/airtable_cleanup.py
🧠
Voice agent data plane — voice_pg + pgvector + Qdrant
// PG operational store + KB embeddings + cross-call memory · No new container — reuses sim-db-1 + Qdrant
💡This tab carves out a new database (voice_pg) on the existing sim-db-1, populates the product KB chunks in kb_chunks via Ollama embeddings, and lets the agent use Qdrant's voice_calls collection for cross-call caller memory. Nothing new to deploy — just SQL + a Python one-shot.
1
Apply the schema (idempotent)
bash · creates voice_pg + 8 tables + pgvector IVFFlat index
docker exec sim-db-1 psql -U postgres -c "CREATE DATABASE voice_pg;" || true
docker cp ~/pocketcode-project/services/livekit-agent/voice_pg_schema.sql \
  sim-db-1:/tmp/voice_pg_schema.sql
docker exec sim-db-1 psql -U postgres -d voice_pg -f /tmp/voice_pg_schema.sql
docker exec sim-db-1 psql -U postgres -d voice_pg -c "\dt"

Tables: callers, calls, turns, tool_invocations, events, callbacks, airtable_outbox, kb_chunks.

2
Emit VOICE_PG_DSN to secrets.env

operations/lib/regenerate-env.sh already emits VOICE_PG_DSN from the existing databases.postgres_main block.

bash
bash ~/pocketcode-project/operations/lib/regenerate-env.sh
grep VOICE_PG_DSN ~/pocketcode-project/secrets/secrets.env
3
Embed the product KB into kb_chunks

Run the chunker/embedder. Uses Ollama's free local nomic-embed-text:latest (already pulled).

bash
docker exec livekit-agent python /app/embed_kb.py
docker exec sim-db-1 psql -U postgres -d voice_pg \
  -c "SELECT product, COUNT(*) AS chunks FROM kb_chunks GROUP BY product;"

Expected: 20 chunks for pocketcode. Re-run any time the KB changes — the upsert is keyed on (product, source_file, chunk_idx).

4
Qdrant collection for cross-call memory

Created automatically on first call by voice_db.ensure_qdrant_collection() — no action needed. To verify after the first call:

bash
curl -s http://127.0.0.1:6333/collections/voice_calls | jq
5
TTS preview cache directory (host bind-mount)
bash
mkdir -p ~/pocketcode-project/data/tts-cache
chown 1000:1000 ~/pocketcode-project/data/tts-cache
chmod 700 ~/pocketcode-project/data/tts-cache

The voice-playground container runs as uid 1000; without chown it can't write the cache.

📞
livekit-agent — voice agent (inbound + outbound)
// Krisp AEC → Silero VAD → Deepgram STT (en) → Claude Haiku 4.5 → OpenRouter → Ollama → Deepgram Aura TTS → 14 MCP tools · Persona name + pronouns auto-follow the selected voice (Orpheus → he/him, Stella → she/her, …)
💡Outbound-only worker (no public port). Registers with LiveKit Cloud and joins any newly dispatched room. Reads MCP tools from the project's HTTP MCPs at runtime via the shared bearer token.
🧠Current shape (Phase 5.7q). The agent exposes 14 function-tools to the LLM. The brain (_SYSTEM_PROMPT_TEMPLATE in agent.py) was rewritten into a 10-section canonical structure (5.7q) for predictable behavior. LLM failover (5.7p) chains Claude → OpenRouter → Ollama — if Anthropic errors or times out, the agent transparently falls back down the chain so a call never dies on a provider hiccup. Branded HTML emails (5.7m): the close-loop follow-up email is now a styled HTML template with Home / Setup / Docs links, and operator notifications route to OPERATOR_EMAIL.
🔇PSTN echo handling — THREE layered defenses, full duplex preserved. Barge-in works on PSTN calls (you CAN interrupt Adrian mid-sentence). (1) livekit-plugins-noise-cancellation (Krisp, ~99 MB bundled) — BVCTelephony() for PSTN, BVC() for browser Audio Test. (2) min_interruption_duration=0.5s so brief echo bursts don't trigger false barge-ins (real interruptions are almost always longer). (3) Post-STT echo filter: PocketCodeVoiceAgent.on_user_turn_completed drops any user turn whose tokens overlap ≥60% with the immediately-prior assistant turn — the LLM never sees the bounced audio so chat context stays clean. Verify with SELECT role, text FROM turns ... ORDER BY started_at after a test call — and search agent logs for echo filter dropped user turn to see the filter firing.
🪦End-of-call cleanup survives session close. RoomInputOptions has close_on_disconnect=False so the agent session stays alive when the SIP participant hangs up. The end-of-call writer is hooked into BOTH participant_disconnected (fires immediately when the callee drops) and room.on("disconnected") (catches agent-initiated tear-down). A guard flag prevents double-write. Result: voice_pg.calls.ended_at + outcome + summary persist correctly even when the callee hangs up abruptly.
👤Observer identity is NOT the caller. The participant_connected handler in agent.py ignores identities starting with observer-. Voice-playground's browser observer joins the room AFTER the SIP dial completes — without this guard it overwrites caller_id with its observer identity and poisons voice_pg.calls.caller_number.
1
Provision a LiveKit Cloud project & Deepgram key

Sign up at livekit.io (free tier: 50 GB egress/mo) and deepgram.com ($200 free credit). Grab the API key/secret/URL from LiveKit and the API key from Deepgram. Drop them into secrets.json under livekit and deepgram, then re-run regenerate-env.sh.

2
Configure Twilio + LiveKit SIP for INBOUND (via API)

Full recipe in services/livekit-agent/README.md. High-level:

  1. Buy a Twilio number (a DID).
  2. Create a Twilio Elastic SIP Trunk via API, add an Origination URL pointing to sip:<your-livekit-project>.sip.livekit.cloud;transport=tls.
  3. Associate the DID with the trunk via API.
  4. Create a LiveKit Inbound Trunk via /twirp/livekit.SIP/CreateSIPInboundTrunk.
  5. Create a LiveKit Dispatch Rule (type: individual, room prefix call-) via /twirp/livekit.SIP/CreateSIPDispatchRule.

All SIDs land back in secrets.json under twilio.* + livekit.sip_inbound_trunk_id + livekit.sip_dispatch_rule_id.

3
Build & run the worker
bash
cd ~/pocketcode-project/services/livekit-agent
docker build -t livekit-agent:latest .
bash run-livekit-agent.sh
docker logs livekit-agent --tail 5  # → "registered worker"

Call your Twilio DID from any phone — Adrian picks up and introduces himself: "Hi, this is Adrian, a representative from Pocket Code dot IN, and this is a test call."

📲
Outbound calling — Twilio termination + LiveKit Outbound Trunk
// Configures the second leg so the agent can dial OUT, not just receive · Both legs share the same Twilio DID as CallerID
⚠️Prereq: Tab 22 (livekit-agent) — needs the inbound trunk + Twilio account already in place.
💡On voice.pocketcode.in's Outbound tab, the Supervisor (call-forward) number is optional. With one set: the agent dials with hold music + 3-attempt retry. Without: the agent promises a 24-hour callback (logged in voice_pg.callbacks with scheduled_for = now() + 24h) and hangs up cleanly. Either way the LLM is safe to call transfer_to_supervisor().
1
Set Twilio trunk termination domain + SIP digest creds
bash · sets DomainName on the trunk
ACCT=$(jq -r .twilio.account_sid ~/pocketcode-project/secrets/secrets.json)
TOK=$(jq -r .twilio.auth_token ~/pocketcode-project/secrets/secrets.json)
TRUNK=$(jq -r .twilio.elastic_sip_trunk_sid ~/pocketcode-project/secrets/secrets.json)
curl -s -u "$ACCT:$TOK" -X POST "https://trunking.twilio.com/v1/Trunks/$TRUNK" \
  --data-urlencode "DomainName=pocketcode-livekit.pstn.twilio.com" | jq .domain_name

Then mint a SIP digest user/password + Credential List and attach to the trunk:

bash · trimmed; see CHANGELOG for the full sequence
SIP_USER="lkout$(openssl rand -hex 4)"
SIP_PASS=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
CL=$(curl -s -u "$ACCT:$TOK" -X POST \
  "https://api.twilio.com/2010-04-01/Accounts/$ACCT/SIP/CredentialLists.json" \
  --data-urlencode "FriendlyName=pocketcode-livekit-outbound" | jq -r .sid)
curl -s -u "$ACCT:$TOK" -X POST \
  "https://api.twilio.com/2010-04-01/Accounts/$ACCT/SIP/CredentialLists/$CL/Credentials.json" \
  --data-urlencode "Username=$SIP_USER" --data-urlencode "Password=$SIP_PASS"
curl -s -u "$ACCT:$TOK" -X POST \
  "https://trunking.twilio.com/v1/Trunks/$TRUNK/CredentialLists" \
  --data-urlencode "CredentialListSid=$CL"
# Persist SIP_USER + SIP_PASS + CL into secrets.json → twilio.sip_outbound_{username,password,credential_list_sid}
2
Create the LiveKit Outbound Trunk
bash · mints LK JWT then calls CreateSIPOutboundTrunk
LK_KEY=$(jq -r .livekit.api_key ~/pocketcode-project/secrets/secrets.json)
LK_SEC=$(jq -r .livekit.api_secret ~/pocketcode-project/secrets/secrets.json)
LK_WS=$(jq -r .livekit.ws_url ~/pocketcode-project/secrets/secrets.json)
TOKEN=$(python3 -c "import jwt,time; print(jwt.encode({'iss':'$LK_KEY','sub':'$LK_KEY','iat':int(time.time()),'exp':int(time.time())+600,'video':{'roomCreate':True,'roomAdmin':True,'roomList':True},'sip':{'admin':True,'call':True}},'$LK_SEC',algorithm='HS256'))")
curl -s -X POST "${LK_WS/wss:/https:}/twirp/livekit.SIP/CreateSIPOutboundTrunk" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"trunk\":{\"name\":\"pocketcode-twilio-outbound\",\"address\":\"pocketcode-livekit.pstn.twilio.com\",\"numbers\":[\"+15342310196\"],\"auth_username\":\"$SIP_USER\",\"auth_password\":\"$SIP_PASS\"}}" \
  | jq '.sip_trunk_id'   # → ST_xxxxxxxx · save to secrets.json → livekit.sip_outbound_trunk_id
3
Update env + run mcp-voice + restart agent
bash
bash ~/pocketcode-project/operations/lib/regenerate-env.sh
grep LIVEKIT_SIP_OUTBOUND ~/pocketcode-project/secrets/secrets.env
bash ~/pocketcode-project/services/mcp-voice/run-mcp-voice.sh
docker restart livekit-agent

Live test: open voice.pocketcode.in, paste your phone in the dial-out card, hit 📲. The browser auto-joins the room as an observer so you can hear the conversation live and watch the chat-style transcript. Adrian greets you, converses in English, and hangs up when you say "bye" or similar.

💻
code-server — browser VS Code at code.pocketcode.in
// Host-native (NOT containerized) · Same Claude Code IDE extension as desktop VS Code · Integrated terminal has claude on PATH
💡Cursor IDE has no self-hostable server variant. code-server (by Coder.com) is the browser-VS-Code equivalent. Runs as a HOST systemd service so it has native access to the project tree, the host docker CLI, and the host claude binary.
1
Install via the official script
bash · installs the deb package + a code-server@$USER systemd template
curl -fsSL https://code-server.dev/install.sh | sh
code-server --version  # → 4.118.0 (or newer)
2
Deploy the project's systemd unit

The unit lives at operations/host/systemd/code-server.service (project-owned, version-controlled). Symlinked into /etc/systemd/system/.

bash
cp ~/pocketcode-project/operations/host/systemd/code-server.service /etc/systemd/system/code-server.service
systemctl daemon-reload
systemctl enable --now code-server.service
systemctl status code-server.service --no-pager | head -5
3
UFW: restrict port 8773 to docker bridges
bash · mirrors the ttyd posture
ufw allow from 127.0.0.1 to any port 8773 proto tcp comment 'code-server from localhost'
ufw allow from 172.17.0.0/16 to any port 8773 proto tcp comment 'code-server from docker default bridge'
ufw allow from 172.18.0.0/16 to any port 8773 proto tcp comment 'code-server from ai-stack bridge'
ufw deny 8773 comment 'block public code-server access'
4
Caddy block already in Caddyfile

Look for code.pocketcode.in { in infrastructure/caddy/Caddyfile. Uses reverse_proxy host.docker.internal:8773. Service-wrapper iframe via Sec-Fetch-Dest: document matcher. No flush_interval -1 — VS Code uses WebSockets which Caddy auto-detects.

5
Make claude universally on PATH
bash
ln -sf /root/.local/bin/claude /usr/local/bin/claude
claude --version  # → 2.x.y (Claude Code)

Now the integrated terminal in code-server picks up claude from /usr/local/bin regardless of shell login state.

6
Install the Claude IDE VS Code extension (in the IDE itself)

Open https://code.pocketcode.in, log in once, open the Extensions panel (Ctrl+Shift+X), search "Claude Code", install anthropic.claude-code. It reads ANTHROPIC_API_KEY from the env (already passed in by the systemd unit) or you can sign in via the Pro/Max OAuth flow.

Why not auto-install at boot? Open VSX (code-server's default marketplace) IP-rate-limits aggressively — VPS IPs get 429'd in batched installs. The Extensions panel handles retries naturally.

🔗
Inter-Service Communication
// How containers talk to each other · Docker network · Environment variables
💡All containers are on the ai-stack Docker network. They reach each other using the container name as hostname — no IP addresses needed. Docker handles DNS resolution automatically.
1
Container Name → URL Reference Map

Use these URLs inside any container to reach another service on the ai-stack network:

internal urls · use inside containers (container-to-container)
Ollama           → http://ollama:11434
n8n              → http://n8n:5678
PostgreSQL       → postgresql://postgres:postgres@sim-db-1:5432/DB_NAME
OpenRouter proxy → http://openrouter-proxy:4000
Redis            → redis://redis:6379

# Sim.ai's own containers use db:5432 (sim_default network only)
# Other ai-stack containers use sim-db-1:5432
# Replace DB_NAME with: ailab | simstudio | openclaw | ollama_results | n8n

For accessing services from your Mac browser, here's the access method per service:

external urls · open in Mac browser
Sim.ai           → http://YOUR_SERVER_IP:3000           (direct - binds to 0.0.0.0)
n8n              → http://127.0.0.1:5678                 (SSH tunnel required ⚠️ - secure cookie)
pgAdmin          → http://YOUR_SERVER_IP:5050           (direct - binds to 0.0.0.0)
Ollama API       → http://YOUR_SERVER_IP:11434          (direct - binds to 0.0.0.0)
OpenClaw UI      → http://127.0.0.1:18789/#token=TOKEN  (SSH tunnel + token required ⚠️)
⚠️OpenClaw Dashboard binds to 127.0.0.1 inside the container on port 18789 (known bug). It cannot be reached directly via http://YOUR_SERVER_IP:18789. You must use an SSH tunnel + auth token — covered in Step 3 and Step 7.
2
Verify Two Containers Can Talk

Test that containers on the ai-stack network can reach each other:

bash · command 1 — install ping tool in a test container
docker run -it --rm --network ai-stack alpine ping -c 3 ollama
If you see ping replies, containers can communicate. If it fails, check that ollama container is running with docker ps.
3
Connect OpenClaw → Ollama

OpenClaw connects to Ollama via the OLLAMA_BASE_URL environment variable in its run script. If the script doesn't exist yet, create it. If it does exist, just open it to verify.

bash · command 1 — create file if it doesn't exist
touch ~/ai-stack/openclaw/run-openclaw.sh
bash · command 2 — open in nano
nano ~/ai-stack/openclaw/run-openclaw.sh

Paste this entire script — confirm OLLAMA_BASE_URL points to http://ollama:11434:

bash · command 3 — paste this complete script, Ctrl+O save, Ctrl+X exit
#!/bin/bash
docker run -d \
  --name openclaw \
  --network ai-stack \
  --restart unless-stopped \
  -v ~/.openclaw:/home/node/.openclaw \
  -v ~/.openclaw/workspace:/home/node/.openclaw/workspace \
  -p 18789:18789 \
  -e ANTHROPIC_API_KEY="sk-ant-xxxx" \
  -e OPENROUTER_API_KEY="sk-or-v1-xxxx" \
  -e OLLAMA_BASE_URL="http://ollama:11434" \
  -e DATABASE_URL="postgresql://postgres:postgres@sim-db-1:5432/openclaw" \
  -e OPENCLAW_GATEWAY_BIND=lan \
  ghcr.io/phioranex/openclaw-docker:latest gateway run
✏️Replace before saving:
PlaceholderWhat to putWhere to get it
sk-ant-xxxxYour Anthropic API keyconsole.anthropic.com → API Keys
sk-or-v1-xxxxYour OpenRouter API keyopenrouter.ai/keys
Leave OLLAMA_BASE_URL, DATABASE_URL exactly as shown.
bash · command 4 — make executable
chmod +x ~/ai-stack/openclaw/run-openclaw.sh

If OpenClaw is already running, restart it to apply changes; otherwise start it fresh:

bash · command 5 — if already running, restart it
docker stop openclaw && docker rm openclaw && bash ~/ai-stack/openclaw/run-openclaw.sh
bash · command 6 — verify it is running (not Restarting)
docker ps | grep openclaw
💡Both containers must be on the ai-stack network for this to work. Verify with: docker network inspect ai-stack
⚠️The gateway run at the end of the image line is required. Without it, the container prints the help menu and crashes in a restart loop.

Access OpenClaw Dashboard from your Mac browser

OpenClaw's Control UI binds to 127.0.0.1 inside the container (known bug — issue #30990) so direct browser access won't work. Use SSH tunnel + auth token.

bash · command 7 — set bind to lan (overrides config default)
docker exec openclaw node /app/dist/index.js config set gateway.bind lan && docker restart openclaw
bash · command 8 — read config to find your auth token
docker exec openclaw cat /home/node/.openclaw/openclaw.json

Find "token": "..." inside the gateway.auth section and copy that value.

⚠️Do not use config get gateway.auth.token — it returns __OPENCLAW_REDACTED__ for security. Always read the JSON file directly.
bash · command 9 — open a NEW terminal on your Mac (not the server)
ssh -N -L 18789:127.0.0.1:18789 root@YOUR_SERVER_IP
✏️Replace: YOUR_SERVER_IP → your Hostinger server IP. Enter password when prompted, then leave the terminal silently open.

Open this URL in your Mac browser (replace token):

url — paste into Mac browser address bar
http://127.0.0.1:18789/#token=YOUR_TOKEN_HERE
✏️Replace: YOUR_TOKEN_HERE → the token value from command 8 output (just the hex string, no quotes)
💡Ctrl+C in the SSH tunnel terminal closes it. The tunnel is encrypted — no firewall changes needed, no public exposure of OpenClaw. Save the full URL as a browser bookmark for one-click access.
4
Connect Sim.ai → Ollama + PostgreSQL

Sim.ai connects to Ollama and PostgreSQL via its .env file. If the file doesn't exist yet, create it; if it does, open to verify.

bash · command 1 — create file if it doesn't exist
touch ~/ai-stack/sim/.env
bash · command 2 — open in nano
nano ~/ai-stack/sim/.env

Paste this complete .env file — the inter-service URLs are highlighted:

config · command 3 — paste this complete .env, Ctrl+O save, Ctrl+X exit
# pocketcode auth-gateway environment
JWT_SECRET=YOUR_JWT_SECRET_FROM_STEP_2
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH=YOUR_BCRYPT_HASH_FROM_STEP_2
COOKIE_DOMAIN=.pocketcode.in
PORT=7000
✏️Replace every placeholder before saving:
PlaceholderWhat to putWhere to get it
PASTE_KEY_1_HEREGenerated keyRun openssl rand -hex 32 on server
PASTE_KEY_2_HEREGenerated keyRun openssl rand -hex 32 on server
PASTE_KEY_3_HEREGenerated keyRun openssl rand -hex 32 on server
YOUR_SERVER_IPYour Hostinger server IPhPanel → VPS dashboard
sk-ant-xxxxYour Anthropic API keyconsole.anthropic.com
sk-or-v1-xxxxYour OpenRouter API keyopenrouter.ai/keys
Leave DATABASE_URL, OLLAMA_URL, REDIS_URL exactly as shown.
⚠️Use OLLAMA_URL not OLLAMA_BASE_URL — the Sim.ai compose file reads OLLAMA_URL. The wrong name causes it to fall back to localhost:11434 which doesn't work inside Docker.

After saving, connect Ollama to Sim.ai's network and restart simstudio:

bash · command 4 — connect Ollama to sim_default network
docker network connect sim_default ollama
bash · command 5 — restart simstudio to apply env changes
docker compose -f ~/ai-stack/sim/docker-compose.prod.yml restart simstudio
bash · command 6 — verify simstudio is running
docker ps | grep simstudio
💡Sim.ai runs on sim_default network, Ollama runs on ai-stack. Connecting Ollama to sim_default puts it on both networks so Sim.ai can reach it. This must be re-run after every docker compose down.
5
Add a New Container to ai-stack Network

When you want to add a new tool to your AI stack (Qdrant, Grafana, n8n, etc.), use this pattern. Every new container must include --network ai-stack to join the shared network.

📍Where do YOUR_CONTAINER_NAME and YOUR_IMAGE come from? They come from the documentation of the tool you're adding — typically the tool's GitHub README or Docker Hub page tells you the recommended container name and the image to use.
PlaceholderWhat to putWhere to find it
YOUR_CONTAINER_NAMEFriendly name you pick (no spaces, lowercase)You choose — e.g. qdrant, n8n, grafana
YOUR_IMAGEDocker image identifierTool's Docker Hub page or GitHub README — e.g. qdrant/qdrant, n8nio/n8n
YOURCONTAINER (in script name)Same as your container nameJust makes the script file easy to identify later

Concrete example — adding Qdrant (vector database)

Suppose you want to add Qdrant. Looking at its Docker Hub: image is qdrant/qdrant, default port is 6333. Container name choice: qdrant. Here's the full flow:

bash · step 1 — create script
touch ~/ai-stack/run-qdrant.sh
bash · step 2 — open in nano
nano ~/ai-stack/run-qdrant.sh
bash · step 3 — paste this, Ctrl+O save, Ctrl+X exit
#!/bin/bash
docker run -d \
  --name qdrant \
  --network ai-stack \
  --restart unless-stopped \
  -p 6333:6333 \
  -v qdrant-data:/qdrant/storage \
  qdrant/qdrant
bash · step 4 — make executable and run
chmod +x ~/ai-stack/run-qdrant.sh && bash ~/ai-stack/run-qdrant.sh

Other containers can now reach Qdrant at http://qdrant:6333 on the ai-stack network.

Generic template (for any new tool)

Replace the placeholders with values from your tool's documentation:

bash · generic template
#!/bin/bash
docker run -d \
  --name YOUR_CONTAINER_NAME \
  --network ai-stack \
  --restart unless-stopped \
  -p HOST_PORT:CONTAINER_PORT \
  -v YOUR_VOLUME:/path/inside/container \
  YOUR_IMAGE
⚠️If you forget --network ai-stack, the container lands on Docker's default bridge network and cannot reach any other service by name.

For an already-running container

If a container is already running and you forgot to add it to the network, connect it without restarting:

bash · connect existing container to ai-stack
docker network connect ai-stack YOUR_CONTAINER_NAME
6
Inspect the Network & Connected Containers
bash · command 1 — list all containers on ai-stack
docker network inspect ai-stack
bash · command 2 — see all running containers
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
7
External Browser Access — SSH Tunnel from Mac
⚠️Two reasons a service needs an SSH tunnel:
  1. Binds to 127.0.0.1 inside container — Docker port mapping cannot forward to localhost-only services. Affects OpenClaw's Control UI (issue #30990).
  2. Enforces secure cookies — browsers refuse to set auth cookies over plain HTTP unless the host is localhost/127.0.0.1. Affects n8n (default behavior; can be disabled but tunnel is more secure).
The reliable solution for both: an SSH tunnel from your Mac to the server.

For OpenClaw Dashboard (port 18789):

bash · run on your MAC terminal
ssh -N -L 18789:127.0.0.1:18789 root@YOUR_SERVER_IP

Then open in your Mac browser (replace token from ~/.openclaw/openclaw.json on server):

url — Mac browser
http://127.0.0.1:18789/#token=YOUR_TOKEN_HERE

Universal pattern for any container service:

bash · template — run on your Mac
ssh -N -L LOCAL_PORT:127.0.0.1:CONTAINER_PORT root@YOUR_SERVER_IP
ServiceSSH tunnel command (run on Mac)Then open in browser
OpenClaw Dashboardssh -N -L 18789:127.0.0.1:18789 root@IPhttp://127.0.0.1:18789/#token=TOKEN
n8n (secure cookie)ssh -N -L 5678:127.0.0.1:5678 root@IPhttp://127.0.0.1:5678
Ollama API (private)ssh -N -L 11434:127.0.0.1:11434 root@IPhttp://127.0.0.1:11434
💡Why this is safer: No ports exposed to the public internet, no firewall changes needed, and traffic is encrypted via SSH. The -N flag means "no command, just forward" so it doesn't open a shell — leave the terminal open while you use the service. Ctrl+C closes the tunnel.
💡For services that bind to 0.0.0.0 (Sim.ai on 3000, pgAdmin on 5050, OpenClaw gateway WebSocket on 8080) you don't need a tunnel — direct browser access works: http://YOUR_SERVER_IP:PORT
📁
Shared File Storage
// Docker volumes · Shared bind mounts · File access across containers
💡There are two ways to share data between containers: Named volumes (Docker manages the path) and Bind mounts (you choose the path on the server). Both are mounted at container start with -v.
1
Create a Shared Volume for All Services

Create one named volume that any container can mount to read and write shared files:

bash · command 1 — create shared volume
docker volume create ai-shared-data
bash · command 2 — verify it exists
docker volume ls
2
Mount Shared Volume Into Each Running Container

Now you'll add the shared volume to your existing containers so they can all read/write the same files. Below are explicit edit instructions for each service in your stack.

📌The flag you're adding everywhere is: -v ai-shared-data:/shared \ — place it before the image name line in each run script. The trailing \ is required for bash line continuation.

2A. Add to Ollama

bash · command 1 — open run script
nano ~/ai-stack/ollama/run-ollama.sh

Find this block and add the highlighted line right before ollama/ollama:

bash · updated run-ollama.sh (line to add highlighted)
#!/bin/bash
docker run -d \
  --name ollama \
  --network ai-stack \
  --restart unless-stopped \
  -v ollama-data:/root/.ollama \
  -p 11434:11434 \
  -v ai-shared-data:/shared \
  ollama/ollama

Save with Ctrl+O then Ctrl+X. Then redeploy:

bash · command 2 — restart container with new mount
docker stop ollama && docker rm ollama && bash ~/ai-stack/ollama/run-ollama.sh

2B. Add to OpenClaw

bash · command 1 — open run script
nano ~/ai-stack/openclaw/run-openclaw.sh

Add -v ai-shared-data:/shared \ after the other -v lines, before the image name:

bash · updated run-openclaw.sh (excerpt — line to add highlighted)
  -v ~/.openclaw:/home/node/.openclaw \
  -v ~/.openclaw/workspace:/home/node/.openclaw/workspace \
  -v ai-shared-data:/shared \
  -p 18789:18789 \
  ...
  ghcr.io/phioranex/openclaw-docker:latest gateway run

Save and redeploy:

bash · command 2 — restart container
docker stop openclaw && docker rm openclaw && bash ~/ai-stack/openclaw/run-openclaw.sh

2C. Add to OpenRouter Proxy

bash · command 1 — open run script
nano ~/ai-stack/run-openrouter-proxy.sh
bash · updated run-openrouter-proxy.sh (line to add highlighted)
#!/bin/bash
docker run -d \
  --name openrouter-proxy \
  --network ai-stack \
  --restart unless-stopped \
  -e OPENROUTER_API_KEY="sk-or-v1-xxxx" \
  -p 4000:4000 \
  -v ai-shared-data:/shared \
  ghcr.io/berriai/litellm:main-latest \
  --model openrouter/anthropic/claude-3.5-sonnet \
  --port 4000
bash · command 2 — restart container
docker stop openrouter-proxy && docker rm openrouter-proxy && bash ~/ai-stack/run-openrouter-proxy.sh

2D. Add to Qdrant

bash · command 1 — open run script
nano ~/ai-stack/run-qdrant.sh
bash · updated run-qdrant.sh (line to add highlighted)
#!/bin/bash
docker run -d \
  --name qdrant \
  --network ai-stack \
  --restart unless-stopped \
  -p 6333:6333 \
  -v qdrant-data:/qdrant/storage \
  -v ai-shared-data:/shared \
  qdrant/qdrant
bash · command 2 — restart container
docker stop qdrant && docker rm qdrant && bash ~/ai-stack/run-qdrant.sh

2E. n8n (already included in run script)

n8n's run script in Tab 7 Step 2 already includes -v ai-shared-data:/shared and -v /root/ai-stack/uploads:/uploads — no edit needed. To verify:

bash · verify n8n mounts
docker inspect n8n --format '{{range .Mounts}}{{.Source}} → {{.Destination}}{{"\n"}}{{end}}'

Expected output should include both /shared and /uploads:

expected output
/var/lib/docker/volumes/n8n-data/_data → /home/node/.n8n
/var/lib/docker/volumes/ai-shared-data/_data → /shared
/root/ai-stack/uploads → /uploads
💡If you set up n8n before creating ai-shared-data volume or /root/ai-stack/uploads folder, restart n8n now: docker stop n8n && docker rm n8n && bash ~/ai-stack/n8n/run-n8n.sh

2F. Add to Sim.ai stack (docker-compose)

Sim.ai runs via docker-compose, so the edit goes in the YAML file (not a run script). You'll add a volumes: mapping under each Sim.ai service.

bash · command 1 — open compose file
nano ~/ai-stack/sim/docker-compose.prod.yml

Inside the simstudio: service block, add this anywhere (a good place is right after ports:):

yaml · add inside simstudio service
    volumes:
      - ai-shared-data:/shared

Repeat the same edit inside the realtime: service block.

Then at the very bottom of the file, declare the external volume so docker-compose recognizes the name:

yaml · find this at bottom of file
volumes:
  postgres_data:
yaml · change it to
volumes:
  postgres_data:
  ai-shared-data:
    external: true
⚠️external: true tells compose to use the volume you already created with docker volume create ai-shared-data in Step 1 — not to create a new one with a prefixed name.

Save with Ctrl+O then Ctrl+X. Then restart the Sim.ai stack:

bash · command 2 — restart Sim.ai with new mounts
cd ~/ai-stack/sim && docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d
bash · command 3 — re-connect Ollama to sim_default network
docker network connect sim_default ollama 2>/dev/null || echo "Already connected — OK"

2G. Verify shared volume works across containers

Write a file from Ollama's container, then read it from OpenClaw to confirm they share the same volume:

bash · command 1 — write from Ollama container
docker exec ollama sh -c "echo 'Hello from Ollama' > /shared/test.txt"
bash · command 2 — read from OpenClaw container
docker exec openclaw cat /shared/test.txt
If you see Hello from Ollama printed — the shared volume is working across all containers that mount it.

Skipped services (no shared volume needed)

ServiceWhy skipped
sim-redis-1Infrastructure cache — doesn't process user files
sim-db-1Database — uses its own postgres_data volume
3
Add a Bind Mount Folder (Alternative — for SCP'd Files)

Step 2 used a named Docker volume (managed by Docker, hidden under /var/lib/docker). This step adds a bind mount at a real path on the server — useful when you want to SCP files directly from your Mac into the shared folder.

📌This is mounted at a different path (/uploads) so it lives alongside the Step 2 named volume (/shared), not replacing it. Each container can have both — use /shared for container-to-container data and /uploads for files you put there via SCP.

3A. Create the server folder

bash · command 1 — create folder
mkdir -p /root/ai-stack/uploads
bash · command 2 — open permissions for containers
chmod 777 /root/ai-stack/uploads

3B. Add the bind mount to Ollama

bash · open run-ollama.sh
nano ~/ai-stack/ollama/run-ollama.sh

Add the highlighted bind mount line before the image name:

bash · updated run-ollama.sh
#!/bin/bash
docker run -d \
  --name ollama \
  --network ai-stack \
  --restart unless-stopped \
  -v ollama-data:/root/.ollama \
  -p 11434:11434 \
  -v ai-shared-data:/shared \
  -v /root/ai-stack/uploads:/uploads \
  ollama/ollama
bash · restart container
docker stop ollama && docker rm ollama && bash ~/ai-stack/ollama/run-ollama.sh

3C. Add the bind mount to OpenClaw

bash · open run-openclaw.sh
nano ~/ai-stack/openclaw/run-openclaw.sh

Add -v /root/ai-stack/uploads:/uploads \ after the other -v lines:

bash · excerpt — line to add highlighted
  -v ~/.openclaw:/home/node/.openclaw \
  -v ~/.openclaw/workspace:/home/node/.openclaw/workspace \
  -v ai-shared-data:/shared \
  -v /root/ai-stack/uploads:/uploads \
  -p 18789:18789 \
  ...
bash · restart container
docker stop openclaw && docker rm openclaw && bash ~/ai-stack/openclaw/run-openclaw.sh

3D. Add the bind mount to Sim.ai stack (docker-compose)

bash · open compose file
nano ~/ai-stack/sim/docker-compose.prod.yml

Under simstudio: and again under realtime:, find the existing volumes: block (added in Step 2E) and add the new bind mount:

yaml · updated volumes inside simstudio and realtime services
    volumes:
      - ai-shared-data:/shared
      - /root/ai-stack/uploads:/uploads
bash · restart Sim.ai stack
cd ~/ai-stack/sim && docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d
bash · reconnect Ollama network
docker network connect sim_default ollama 2>/dev/null || echo "Already connected — OK"

3E. Test from your Mac — SCP a file

From your Mac terminal, copy any file into the uploads folder:

bash · run on your MAC (replace path to your file)
scp ~/Desktop/test.pdf root@YOUR_SERVER_IP:/root/ai-stack/uploads/

On the server, confirm it's visible in a container:

bash · server — verify file visible inside Ollama container
docker exec ollama ls /uploads
You should see test.pdf listed. Now any container with the bind mount can read files you SCP into /root/ai-stack/uploads/ from your Mac.
4
Give Other Containers Read-Only Access to Ollama Models

Ollama stores downloaded models in the ollama-data volume. Other containers can mount it read-only to inspect models without using the Ollama API (useful for debugging, custom inference, or backup tools).

📌This is only useful for tools that need to directly read model files. None of your existing services (Sim.ai, OpenClaw, OpenRouter, Qdrant) need this — they call Ollama via API instead. Use this when adding custom tools later.

4A. Quick inspection (no script needed)

Spin up a temporary Alpine container to list all downloaded models:

bash · one-liner — list Ollama's models from another container
docker run -it --rm -v ollama-data:/root/.ollama:ro alpine ls /root/.ollama/models

Or check total disk size used by models:

bash · check disk usage of Ollama models
docker run -it --rm -v ollama-data:/root/.ollama:ro alpine du -sh /root/.ollama/models

4B. Add to a future custom inference tool (example)

If you build a custom tool that reads Ollama's model files directly, here's the run script pattern:

bash · create your custom tool's run script
touch ~/ai-stack/run-mytool.sh && nano ~/ai-stack/run-mytool.sh
bash · paste this template, Ctrl+O save, Ctrl+X exit
#!/bin/bash
docker run -d \
  --name mytool \
  --network ai-stack \
  --restart unless-stopped \
  -v ollama-data:/root/.ollama:ro \
  -v ai-shared-data:/shared \
  -p 8090:8090 \
  YOUR_IMAGE
✏️Replace:
PlaceholderWhat to put
mytoolYour tool's container name
YOUR_IMAGEYour tool's Docker image (e.g. ghcr.io/yourorg/yourtool:latest)
8090:8090Your tool's port mapping (or remove if no UI)
⚠️The :ro suffix is critical — it means read-only. Without it, your custom tool could accidentally corrupt or delete Ollama's downloaded models.

Skipped services

ServiceWhy not added
OpenClaw, Sim.aiAlready use Ollama via API at http://ollama:11434 — no direct file access needed
QdrantStores vectors only, doesn't read embedding models
OpenRouter proxyForwards to cloud APIs, doesn't use local Ollama models
5
List & Inspect All Volumes
bash · command 1 — list all volumes
docker volume ls
bash · command 2 — inspect a volume (see actual path)
docker volume inspect ai-shared-data
bash · command 3 — browse bind-mount uploads folder (from Step 3)
ls -la /root/ai-stack/uploads/
🗄️
Databases — SQL · NoSQL · Vector
// Use Sim.ai's existing sim-db-1 as the shared PostgreSQL · pgvector built-in · JSONB · pgAdmin
💡Architecture decision: Sim.ai's compose file already runs pgvector/pgvector:pg17 as the sim-db-1 container — which is the exact same PostgreSQL + pgvector image we need. Running a second postgres container conflicts on port 5432 and wastes RAM. We'll use sim-db-1 as the shared database for everything.
⚠️Network note: sim-db-1 lives on the sim_default network. Containers on ai-stack (OpenClaw, Qdrant, etc.) cannot reach it by default — we'll connect sim-db-1 to ai-stack too so it's accessible from both networks.
1
Connect sim-db-1 to ai-stack Network

Add sim-db-1 to the ai-stack network so OpenClaw, Qdrant, and any future containers can reach it by name:

bash · command 1 — connect sim-db-1 to ai-stack
docker network connect ai-stack sim-db-1 2>/dev/null || echo "Already connected — OK"
bash · command 2 — verify it is on both networks
docker inspect sim-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'

Expected output:

expected output
ai-stack sim_default
💡Now sim-db-1 is reachable from both networks:
  • From ai-stack → hostname is sim-db-1
  • From sim_default → hostname is db (compose alias)
2
Create Additional Databases for Each Service

sim-db-1 already has the simstudio database (used by Sim.ai). Create the additional databases for OpenClaw, Ollama output storage, and general use:

bash · command 1 — connect to postgres as superuser
docker exec -it sim-db-1 psql -U postgres
sql · command 2 — create 4 new databases (run inside psql)
CREATE DATABASE ailab;
CREATE DATABASE openclaw;
CREATE DATABASE ollama_results;
CREATE DATABASE n8n;
\l

You should see all 5 databases listed: ailab, n8n, openclaw, ollama_results, simstudio (plus postgres internal ones).

sql · command 3 — enable pgvector extension in each database
\c ailab
CREATE EXTENSION IF NOT EXISTS vector;
\c simstudio
CREATE EXTENSION IF NOT EXISTS vector;
\c openclaw
CREATE EXTENSION IF NOT EXISTS vector;
\c ollama_results
CREATE EXTENSION IF NOT EXISTS vector;
\c n8n
CREATE EXTENSION IF NOT EXISTS vector;
\q
💡\c dbname switches to that database · \l lists all databases · \q exits psql. The vector extension is part of the pgvector/pgvector:pg17 image — no separate install needed.
3
Connection Strings for Each Service

Hostname depends on which Docker network the calling container is on:

config · connection strings per service
# Sim.ai (.env file — runs on sim_default network)
DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio

# OpenClaw (-e flag in run-openclaw.sh — runs on ai-stack network)
DATABASE_URL=postgresql://postgres:postgres@sim-db-1:5432/openclaw

# n8n (env vars in run-n8n.sh — uses split format, NOT a single URL)
DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=sim-db-1
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_USER=postgres
DB_POSTGRESDB_PASSWORD=postgres

# n8n Postgres credential (inside n8n UI — fill these fields)
Host: sim-db-1     Database: ailab (or any)     User: postgres
Pass: postgres     Port: 5432                   SSL: Disable

# Qdrant or any ai-stack container needing the ailab db
DATABASE_URL=postgresql://postgres:postgres@sim-db-1:5432/ailab

# For Ollama output storage / your own apps (on ai-stack)
DATABASE_URL=postgresql://postgres:postgres@sim-db-1:5432/ollama_results

# Custom Python/Node apps (running anywhere — pick host by network)
# Python:  psycopg2.connect("postgresql://postgres:postgres@sim-db-1:5432/ailab")
# Node:    new Pool({ host: 'sim-db-1', port: 5432, user: 'postgres', ... })

# Redis (Sim.ai realtime queue — sim_default network only)
REDIS_URL=redis://redis:6379

# From your Mac (external — for pgAdmin, DBeaver, TablePlus, etc.)
# Works for ANY database — swap the name at the end:
DATABASE_URL=postgresql://postgres:postgres@YOUR_SERVER_IP:5432/ailab
DATABASE_URL=postgresql://postgres:postgres@YOUR_SERVER_IP:5432/simstudio
DATABASE_URL=postgresql://postgres:postgres@YOUR_SERVER_IP:5432/openclaw
DATABASE_URL=postgresql://postgres:postgres@YOUR_SERVER_IP:5432/n8n
DATABASE_URL=postgresql://postgres:postgres@YOUR_SERVER_IP:5432/ollama_results
✏️Replace in the last line only:
PlaceholderWhat to putWhere to find it
YOUR_SERVER_IPYour Hostinger server's public IPhPanel → VPS dashboard → IP shown at top
⚠️Hostname rules:
  • db — works only inside sim_default network (Sim.ai's own containers)
  • sim-db-1 — works inside ai-stack network (OpenClaw, Qdrant, custom tools)
  • Default username + password are both postgres (from Sim.ai compose config)
4
SQL — Standard Relational Tables

Works exactly like standard PostgreSQL. Example using the ailab database:

bash · connect to ailab db
docker exec -it sim-db-1 psql -U postgres -d ailab
sql · create and query a table
CREATE TABLE curriculum_modules (
  id         SERIAL PRIMARY KEY,
  title      TEXT NOT NULL,
  category   TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

INSERT INTO curriculum_modules (title, category)
VALUES ('Docker Basics', 'Cloud Computing');

SELECT * FROM curriculum_modules;
5
NoSQL — Document Storage with JSONB

JSONB stores flexible JSON documents — query them with SQL operators. No schema required per document:

sql · create and query a JSONB table
CREATE TABLE ai_outputs (
  id         SERIAL PRIMARY KEY,
  source     TEXT,
  data       JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX ON ai_outputs USING GIN (data);

INSERT INTO ai_outputs (source, data) VALUES (
  'ollama',
  '{"model":"llama3.2","prompt":"Hello","response":"Hi there!","tokens":42}'
);

SELECT data->>'model'    AS model,
       data->>'response' AS response
FROM   ai_outputs
WHERE  source = 'ollama';
💡-> returns JSON · ->> returns text · @> checks if JSON contains a value · GIN index makes JSONB queries fast.
6
Vector DB — Embeddings & Semantic Search

pgvector adds a vector column type for storing AI embeddings. Used for semantic search, RAG pipelines, and similarity matching:

sql · create embeddings table
CREATE TABLE embeddings (
  id        SERIAL PRIMARY KEY,
  content   TEXT,
  source    TEXT,
  embedding vector(768)
);

CREATE INDEX ON embeddings
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);
sql · semantic similarity search
SELECT content,
       1 - (embedding <=> '[0.1,0.2,0.3,...]') AS similarity
FROM   embeddings
ORDER  BY embedding <=> '[0.1,0.2,0.3,...]'
LIMIT  5;
💡Embedding dimensions: Ollama nomic-embed-text → 768 · OpenAI text-embedding-3-small → 1536. Match vector(N) to your model.
7
Install pgAdmin — Visual Database Manager

Browser-based GUI to manage all databases visually — no command line needed for day-to-day queries:

bash · step 1 — create script
touch ~/ai-stack/run-pgadmin.sh
bash · step 2 — open in nano
nano ~/ai-stack/run-pgadmin.sh
bash · step 3 — paste this, then Ctrl+O save, Ctrl+X exit
#!/bin/bash
docker run -d \
  --name pgadmin \
  --network ai-stack \
  --restart unless-stopped \
  -e PGADMIN_DEFAULT_EMAIL=admin@ailab.com \
  -e PGADMIN_DEFAULT_PASSWORD=admin123 \
  -e PGADMIN_CONFIG_PROXY_X_FOR_COUNT=1 \
  -e PGADMIN_CONFIG_PROXY_X_PROTO_COUNT=1 \
  -e PGADMIN_CONFIG_PROXY_X_HOST_COUNT=1 \
  -e PGADMIN_CONFIG_PROXY_X_PORT_COUNT=1 \
  -e PGADMIN_CONFIG_PROXY_X_PREFIX_COUNT=1 \
  -v pgadmin-data:/var/lib/pgadmin \
  -p 5050:80 \
  dpage/pgadmin4
💡The 5 PGADMIN_CONFIG_PROXY_X_*_COUNT=1 lines tell pgAdmin's Flask backend to trust X-Forwarded-* headers from one reverse-proxy hop (Caddy). Without them, once pgAdmin is behind pgadmin.pocketcode.in in Tab 14, its CSRF token validation fails and you get stuck on the login page with no error. Harmless to include now even before HTTPS is set up.
✏️Replace before saving:
PlaceholderWhat to putNotes
admin@ailab.comAny email you want to use as loginThis is your pgAdmin login email — doesn't need to be real
admin123A strong password of your choiceUsed to log into pgAdmin at port 5050 — change this to something secure
bash · step 4 — make executable and run
chmod +x ~/ai-stack/run-pgadmin.sh && bash ~/ai-stack/run-pgadmin.sh
bash · step 5 — open firewall for pgAdmin
ufw allow 5050

Open http://YOUR_SERVER_IP:5050 in your Mac browser → login with email + password above → click Add New Server and fill in:

config · pgAdmin "Add Server" settings
General tab:
  Name:     Shared PostgreSQL

Connection tab:
  Host:     sim-db-1
  Port:     5432
  Username: postgres
  Password: postgres
  Save password: ✓
💡Once connected, you'll see all 4 databases (ailab, simstudio, openclaw, ollama_results) in pgAdmin's tree. Expand any to browse tables, run queries, manage indexes, etc.
8
Quick Reference — Service → Database Map
reference · service → database
Service          Database         Connection (from container's network)
─────────────────────────────────────────────────────────────────────
Sim.ai           simstudio        postgresql://postgres:postgres@db:5432/simstudio
OpenClaw         openclaw         postgresql://postgres:postgres@sim-db-1:5432/openclaw
n8n              n8n              postgresql://postgres:postgres@sim-db-1:5432/n8n
Ollama outputs   ollama_results   postgresql://postgres:postgres@sim-db-1:5432/ollama_results
General / lab    ailab            postgresql://postgres:postgres@sim-db-1:5432/ailab
pgAdmin (UI)     all of the above http://YOUR_SERVER_IP:5050
─────────────────────────────────────────────────────────────────────
Container hostnames:
  db          → from sim_default network only (Sim.ai's own containers)
  sim-db-1    → from ai-stack network (OpenClaw, n8n, Qdrant, custom tools)
  YOUR_IP     → from your Mac/external

Username / Password: postgres / postgres  (change in production)
⚠️Change the default password postgres to something strong in production: ALTER USER postgres WITH PASSWORD 'your_strong_password'; Then update DATABASE_URL in Sim.ai .env and OpenClaw run script.
⚙️
Service Management & Diagnostics
// 3 unified scripts to start, stop, and diagnose your entire AI stack · One-command shortcuts
💡The pattern: instead of running 10+ commands every time you want to start/stop or check your stack, you'll have 3 aliases: ai-start, ai-stop, ai-doctor. Each runs a single script that handles the whole orchestration.
1
Create the Management Folder
bash · create folder for management scripts
mkdir -p ~/ai-stack/manage

All three scripts will live in ~/ai-stack/manage/.

2
Start Script — Launch Everything in Correct Order
📋Why order matters: Sim.ai stack must start first (it provides sim-db-1 which is the shared PostgreSQL). Then network bridges (sim-db-1 → ai-stack, ollama → sim_default). Then dependent services (OpenClaw, n8n need DB). Finally restart Sim.ai so it picks up the connected Ollama.
bash · step 1 — create the script file
touch ~/ai-stack/manage/start-all.sh && nano ~/ai-stack/manage/start-all.sh
bash · step 2 — paste this, then Ctrl+O save, Ctrl+X exit
#!/bin/bash
# Start all AI Lab services in correct sequence
set +e  # don't exit on individual service failures

# Colors
G='\033[0;32m'; Y='\033[1;33m'; R='\033[0;31m'; B='\033[0;34m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'

log()  { echo -e "${B}[$(date +%H:%M:%S)]${NC} $1"; }
ok()   { echo -e "  ${G}✓${NC} $1"; }
warn() { echo -e "  ${Y}⚠${NC}  $1"; }
fail() { echo -e "  ${R}✗${NC} $1"; }

# Helper: start container if exists, else run its create script
start_or_create() {
  local name="$1"
  local script="$2"
  if docker ps --format '{{.Names}}' | grep -q "^${name}$"; then
    ok "$name already running"
  elif docker ps -a --format '{{.Names}}' | grep -q "^${name}$"; then
    docker start "$name" >/dev/null && ok "$name started" || fail "$name failed to start"
  elif [ -n "$script" ] && [ -f "$script" ]; then
    bash "$script" >/dev/null 2>&1 && ok "$name created from script" || fail "$name script failed"
  else
    warn "$name not found, no create script"
  fi
}

echo ""
echo -e "${BOLD}╔════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║   Starting AI Lab Stack                ║${NC}"
echo -e "${BOLD}╚════════════════════════════════════════╝${NC}"

# 1. Ensure ai-stack network exists
log "Checking ai-stack network..."
if docker network ls --format '{{.Name}}' | grep -q '^ai-stack$'; then
  ok "ai-stack network exists"
else
  docker network create ai-stack >/dev/null && ok "ai-stack network created"
fi

# 2. Start Sim.ai compose stack (provides sim-db-1, redis, realtime, simstudio)
log "Starting Sim.ai stack (DB, Redis, Realtime, Simstudio)..."
if [ -f ~/ai-stack/sim/docker-compose.prod.yml ]; then
  cd ~/ai-stack/sim && docker compose -f docker-compose.prod.yml up -d >/dev/null 2>&1 \
    && ok "Sim.ai compose up" || fail "Sim.ai compose failed"
fi

# 3. Connect sim-db-1 to ai-stack network (so OpenClaw, n8n, etc. can reach it)
log "Bridging networks..."
docker network connect ai-stack sim-db-1 2>/dev/null \
  && ok "sim-db-1 connected to ai-stack" || ok "sim-db-1 already on ai-stack"

# 4. Start Ollama
log "Starting Ollama..."
start_or_create ollama ~/ai-stack/ollama/run-ollama.sh

# 5. Connect Ollama to sim_default (so Sim.ai can reach it)
docker network connect sim_default ollama 2>/dev/null \
  && ok "ollama connected to sim_default" || ok "ollama already on sim_default"

# 6. Restart simstudio to pick up Ollama
log "Restarting simstudio to pick up Ollama..."
docker compose -f ~/ai-stack/sim/docker-compose.prod.yml restart simstudio >/dev/null 2>&1 \
  && ok "simstudio restarted"

# 7. Start OpenRouter proxy
log "Starting OpenRouter proxy..."
start_or_create openrouter-proxy ~/ai-stack/run-openrouter-proxy.sh

# 8. Start OpenClaw
log "Starting OpenClaw..."
start_or_create openclaw ~/ai-stack/openclaw/run-openclaw.sh

# 9. Start n8n
log "Starting n8n..."
start_or_create n8n ~/ai-stack/n8n/run-n8n.sh

# 10. Start Qdrant
log "Starting Qdrant..."
start_or_create qdrant ~/ai-stack/run-qdrant.sh

# 11. Start pgAdmin
log "Starting pgAdmin..."
start_or_create pgadmin ~/ai-stack/run-pgadmin.sh

# 12. Start Open WebUI (ChatGPT-style UI for Ollama)
log "Starting Open WebUI..."
start_or_create open-webui ~/ai-stack/open-webui/run-open-webui.sh

echo ""
log "All services launched. Status:"
echo ""
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"

# ═══ Auto-detect server IP and OpenClaw token ═══
SERVER_IP=$(curl -s -4 --max-time 3 ifconfig.me 2>/dev/null)
[ -z "$SERVER_IP" ] && SERVER_IP=$(hostname -I | awk '{print $1}')
[ -z "$SERVER_IP" ] && SERVER_IP="YOUR_SERVER_IP"

OPENCLAW_TOKEN=""
if docker ps --format '{{.Names}}' | grep -q '^openclaw$'; then
  OPENCLAW_TOKEN=$(docker exec openclaw cat /home/node/.openclaw/openclaw.json 2>/dev/null \
    | grep -oE '"token"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
    | sed -E 's/.*"([^"]+)"$/\1/')
fi

# ═══ SSH TUNNEL COMMANDS ═══
echo ""
echo -e "${BOLD}╔════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║   SSH TUNNELS — Run these on your Mac terminal         ║${NC}"
echo -e "${BOLD}╚════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BOLD}Option A — All tunnels in ONE command (recommended):${NC}"
echo -e "${DIM}Run this once, keep the terminal open, all 3 services accessible.${NC}"
echo ""
echo -e "${G}  ssh -N \\"
echo "    -L 5678:127.0.0.1:5678 \\"
echo "    -L 8080:open-webui:8080 \\"
echo -e "    -L 18789:127.0.0.1:18789 \\"
echo -e "    root@${SERVER_IP}${NC}"
echo ""
echo -e "${BOLD}Option B — Individual tunnels (one per Mac terminal):${NC}"
echo ""
echo -e "${DIM}# n8n (workflow automation)${NC}"
echo -e "${G}  ssh -N -L 5678:127.0.0.1:5678 root@${SERVER_IP}${NC}"
echo ""
echo -e "${DIM}# Open WebUI (Ollama chat)${NC}"
echo -e "${G}  ssh -N -L 8080:open-webui:8080 root@${SERVER_IP}${NC}"
echo ""
echo -e "${DIM}# OpenClaw Dashboard${NC}"
echo -e "${G}  ssh -N -L 18789:127.0.0.1:18789 root@${SERVER_IP}${NC}"

# ═══ BROWSER URLS ═══
echo ""
echo -e "${BOLD}╔════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║   BROWSER URLS — Open these in Chrome / Safari         ║${NC}"
echo -e "${BOLD}╚════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BOLD}🟢 Direct access (no tunnel needed):${NC}"
echo ""
echo -e "  ${G}Sim.ai${NC}             →  http://${SERVER_IP}:3000"
echo -e "  ${G}pgAdmin${NC}            →  http://${SERVER_IP}:5050"
echo -e "  ${G}Qdrant Dashboard${NC}   →  http://${SERVER_IP}:6333/dashboard"
echo -e "  ${G}Ollama API${NC}         →  http://${SERVER_IP}:11434  ${DIM}(API only, not a UI)${NC}"
echo ""
echo -e "${BOLD}🔒 Via SSH tunnel (start tunnels above first):${NC}"
echo ""
echo -e "  ${Y}n8n${NC}                →  http://127.0.0.1:5678"
echo -e "  ${Y}Open WebUI${NC}         →  http://127.0.0.1:8080  ${DIM}(tunnel: -L 8080:open-webui:8080)${NC}"
if [ -n "$OPENCLAW_TOKEN" ]; then
  echo -e "  ${Y}OpenClaw Dashboard${NC} →  http://127.0.0.1:18789/#token=${OPENCLAW_TOKEN}"
else
  echo -e "  ${Y}OpenClaw Dashboard${NC} →  http://127.0.0.1:18789/#token=${R}TOKEN_NOT_FOUND${NC}"
  echo -e "    ${DIM}↳ Get token: docker exec openclaw cat /home/node/.openclaw/openclaw.json | grep token${NC}"
fi
echo ""
echo -e "${BOLD}═════════════════════════════════════════════════════════${NC}"
echo -e "${G}✓ Stack ready. Run 'ai-doctor' for full diagnostics.${NC}"
echo ""

Save with Ctrl+O, Enter, then Ctrl+X.

💡The script uses start_or_create helper — if the container exists it just docker starts it (fast), if it's missing it runs the create script (slower, but only first time after a docker rm).
3
Stop Script — Graceful Shutdown in Reverse Order
bash · step 1 — create the script file
touch ~/ai-stack/manage/stop-all.sh && nano ~/ai-stack/manage/stop-all.sh
bash · step 2 — paste this, then Ctrl+O save, Ctrl+X exit
#!/bin/bash
# Stop all AI Lab services gracefully (reverse order of start)

G='\033[0;32m'; Y='\033[1;33m'; B='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'

log() { echo -e "${B}[$(date +%H:%M:%S)]${NC} $1"; }
ok()  { echo -e "  ${G}✓${NC} $1"; }
skip(){ echo -e "  ${Y}-${NC} $1 (not running)"; }

stop_if_running() {
  local name="$1"
  if docker ps --format '{{.Names}}' | grep -q "^${name}$"; then
    docker stop "$name" >/dev/null && ok "Stopped $name"
  else
    skip "$name"
  fi
}

echo ""
echo -e "${BOLD}╔════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║   Stopping AI Lab Stack                ║${NC}"
echo -e "${BOLD}╚════════════════════════════════════════╝${NC}"

# Stop dependent services first (reverse of start order)
log "Stopping dependent services..."
stop_if_running caddy
stop_if_running open-webui
stop_if_running pgadmin
stop_if_running qdrant
stop_if_running n8n
stop_if_running openclaw
stop_if_running openrouter-proxy
stop_if_running ollama

# Stop Sim.ai compose stack last (it has the shared DB)
log "Stopping Sim.ai compose stack..."
if [ -f ~/ai-stack/sim/docker-compose.prod.yml ]; then
  cd ~/ai-stack/sim && docker compose -f docker-compose.prod.yml stop >/dev/null 2>&1 \
    && ok "Sim.ai compose stack stopped"
fi

echo ""
log "All AI Lab services stopped. Remaining:"
echo ""
docker ps --format "table {{.Names}}\t{{.Status}}"
echo ""
echo -e "${G}✓ Shutdown complete. Run 'ai-start' to bring everything back.${NC}"

Save with Ctrl+O, Enter, then Ctrl+X.

⚠️This uses docker stop (graceful, sends SIGTERM with 10s timeout) — not docker kill. Containers can finish writing data before exiting. Use this instead of docker stop $(docker ps -q) which stops everything including unrelated containers.
4
Diagnose Script — Deep Health Check with Logs

This script gives a full health dashboard: container status, network membership, disk usage, recent logs per service, and an error scan.

bash · step 1 — create the script file
touch ~/ai-stack/manage/diagnose.sh && nano ~/ai-stack/manage/diagnose.sh
bash · step 2 — paste this, then Ctrl+O save, Ctrl+X exit
#!/bin/bash
# AI Lab stack diagnostics — full health check

G='\033[0;32m'; Y='\033[1;33m'; R='\033[0;31m'; B='\033[0;34m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'

header() {
  echo ""
  echo -e "${BOLD}${B}═══ $1 ═══${NC}"
}

check_status() {
  local name="$1"
  if docker ps --format '{{.Names}}' | grep -q "^${name}$"; then
    local status=$(docker ps --format '{{.Status}}' --filter "name=^${name}$")
    echo -e "  ${G}● UP${NC}       $name  ${DIM}($status)${NC}"
  elif docker ps -a --format '{{.Names}}' | grep -q "^${name}$"; then
    echo -e "  ${Y}● STOPPED${NC}  $name"
  else
    echo -e "  ${R}● MISSING${NC}  $name"
  fi
}

# ═══ HEADER ═══
echo ""
echo -e "${BOLD}╔════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║       AI Lab Stack Diagnostics                 ║${NC}"
echo -e "${BOLD}║       $(date '+%Y-%m-%d %H:%M:%S')                      ║${NC}"
echo -e "${BOLD}╚════════════════════════════════════════════════╝${NC}"

# ═══ CONTAINER STATUS ═══
header "Container Status"
services=(ollama openclaw n8n openrouter-proxy qdrant pgadmin sim-db-1 sim-redis-1 sim-realtime-1 sim-simstudio-1 open-webui)
for svc in "${services[@]}"; do
  check_status "$svc"
done

# ═══ DOCKER NETWORKS ═══
header "Docker Networks"
echo -e "${BOLD}ai-stack:${NC}"
docker network inspect ai-stack --format '{{range .Containers}}  • {{.Name}}{{"\n"}}{{end}}' 2>/dev/null | sort | uniq || echo "  ${R}Network missing!${NC}"
echo -e "${BOLD}sim_default:${NC}"
docker network inspect sim_default --format '{{range .Containers}}  • {{.Name}}{{"\n"}}{{end}}' 2>/dev/null | sort | uniq || echo "  ${R}Network missing!${NC}"

# ═══ PORT BINDINGS ═══
header "Exposed Ports"
docker ps --format "table {{.Names}}\t{{.Ports}}" | grep -v "^NAMES"

# ═══ DISK & RESOURCES ═══
header "System Resources"
echo -e "${BOLD}Disk:${NC}"
df -h / | tail -1 | awk '{print "  Root: " $3 " / " $2 "  (" $5 " used)"}'
echo -e "${BOLD}Memory:${NC}"
free -h | grep Mem | awk '{print "  RAM:  " $3 " / " $2 "  (" int($3/$2*100) "% used)"}'
echo -e "${BOLD}Docker:${NC}"
docker system df | grep -v "^TYPE" | awk '{printf "  %-12s %s used / %s total\n", $1, $4, $3}'

# ═══ RECENT LOGS ═══
header "Recent Logs (last 5 lines per service)"
for svc in ollama openclaw n8n sim-simstudio-1 sim-db-1 sim-realtime-1; do
  if docker ps --format '{{.Names}}' | grep -q "^${svc}$"; then
    echo ""
    echo -e "${BOLD}── $svc ──${NC}"
    docker logs "$svc" --tail 5 2>&1 | sed 's/^/    /'
  fi
done

# ═══ ERROR SCAN ═══
header "Error Scan (last 100 lines per service)"
for svc in ollama openclaw n8n sim-simstudio-1 sim-db-1 sim-realtime-1 qdrant openrouter-proxy; do
  if docker ps --format '{{.Names}}' | grep -q "^${svc}$"; then
    errors=$(docker logs "$svc" --tail 100 2>&1 | grep -iE "(^|[[:space:]])(error|fatal|panic|exception)[: ]" | grep -ivE "no error|0 error|errorlevel|error_log|error-level|no such file|relation \".*\" does not exist|database \".*\" does not exist|role \".*\" does not exist|duplicate key|invalid input syntax for type vector|terminating connection due to administrator command|Failed to load model catalog|getaddrinfo EAI_AGAIN" | wc -l)
    if [ "$errors" -gt 0 ]; then
      echo -e "  ${R}⚠${NC}  $svc: ${R}$errors${NC} error/fatal lines (run: docker logs $svc | grep -i error)"
    else
      echo -e "  ${G}✓${NC} $svc: clean"
    fi
  fi
done

# ═══ CONNECTIVITY TESTS ═══
header "Service Connectivity"
test_endpoint() {
  local name="$1"
  local url="$2"
  if curl -sf -o /dev/null --max-time 3 "$url"; then
    echo -e "  ${G}✓${NC} $name → $url"
  else
    echo -e "  ${R}✗${NC} $name → $url  (no response)"
  fi
}
test_endpoint "Ollama"      "http://localhost:11434"
test_endpoint "n8n"         "http://localhost:5678"
test_endpoint "Sim.ai"      "http://localhost:3000"
test_endpoint "Qdrant"      "http://localhost:6333"
test_endpoint "pgAdmin"     "http://localhost:5050"

# ═══ SUMMARY ═══
echo ""
echo -e "${BOLD}═════════════════════════════════════════════${NC}"
running=$(docker ps -q | wc -l)
# Known one-shot containers (init/migration runners) that complete and exit normally
oneshot_pattern="^(sim-migrations-1)$"
oneshot_completed=$(docker ps -a --filter "status=exited" --format '{{.Names}}' | grep -cE "$oneshot_pattern" 2>/dev/null || echo 0)
total_stopped=$(docker ps -aq --filter "status=exited" | wc -l)
unexpected_stopped=$((total_stopped - oneshot_completed))
if [ "$unexpected_stopped" -gt 0 ]; then
  echo -e "  Containers: ${G}$running running${NC}, ${R}$unexpected_stopped unexpectedly stopped${NC}, ${DIM}$oneshot_completed one-shot completed${NC}"
else
  echo -e "  Containers: ${G}$running running${NC}, ${DIM}$oneshot_completed one-shot completed${NC}"
fi
echo -e "${BOLD}═════════════════════════════════════════════${NC}"
echo ""

Save with Ctrl+O, Enter, then Ctrl+X.

5
Make All Scripts Executable
bash · make all three scripts executable
chmod +x ~/ai-stack/manage/start-all.sh ~/ai-stack/manage/stop-all.sh ~/ai-stack/manage/diagnose.sh
bash · verify they're executable
ls -la ~/ai-stack/manage/

Expected — should show -rwxr-xr-x permissions:

expected output
-rwxr-xr-x  1 root root  ...  start-all.sh
-rwxr-xr-x  1 root root  ...  stop-all.sh
-rwxr-xr-x  1 root root  ...  diagnose.sh
6
Create Shell Aliases for One-Word Access

Add three aliases to ~/.bashrc so you can run each script with a short command from anywhere:

bash · command 1 — append aliases to .bashrc
cat >> ~/.bashrc << 'ALIASES'

# === AI Lab Stack Management ===
alias ai-start='bash ~/ai-stack/manage/start-all.sh'
alias ai-stop='bash ~/ai-stack/manage/stop-all.sh'
alias ai-reboot='bash ~/ai-stack/manage/stop-all.sh && sleep 3 && bash ~/ai-stack/manage/start-all.sh'
alias ai-doctor='bash ~/ai-stack/manage/diagnose.sh'
alias ai-status='docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"'
alias ai-logs='f() { docker logs "$1" --tail 50 -f; }; f'
ALIASES
bash · command 2 — reload bashrc so aliases work in current session
source ~/.bashrc

Now you have 6 shortcuts:

AliasWhat it does
ai-startStart entire AI stack in correct order (Sim.ai → bridges → Ollama → OpenClaw → n8n → Qdrant → pgAdmin)
ai-stopStop all AI Lab services gracefully in reverse order
ai-rebootFull clean restart — stops everything, waits 3s, starts everything back up
ai-doctorFull diagnostic: status, networks, disk, logs, errors, connectivity
ai-statusQuick one-line container status (no diagnostics)
ai-logs CONTAINERTail logs of a specific container (e.g. ai-logs n8n)
7
Test the Aliases

Run all three to confirm they work:

bash · test 1 — quick status
ai-status
bash · test 2 — full diagnostic (read the output carefully)
ai-doctor
bash · test 3 — tail a specific container's logs (Ctrl+C to exit)
ai-logs n8n
If ai-doctor shows everything green: your stack is fully operational. If anything shows red (✗) or yellow (⚠️), use ai-logs CONTAINER to investigate that specific service.
⚠️Don't run ai-start right now if your stack is already running — it's safe (each helper checks if container exists), but redundant. Use it after ai-stop or after a server reboot.
8
Daily Workflow Reference
ScenarioCommand
Morning — check everything's healthyai-doctor
Quick "is it up?" checkai-status
After server rebootai-start (containers auto-restart but this re-bridges networks)
Before server maintenanceai-stop
One service acting upai-logs SERVICE_NAME
After installing new serviceEdit start-all.sh to include it, then ai-doctor to verify
Full clean restartai-reboot
💡For new services: when you add a service later (e.g. LangFlow, Weaviate), edit ~/ai-stack/manage/start-all.sh to add it to the start sequence, and the services=(...) array in ~/ai-stack/manage/diagnose.sh so it appears in the health check.
🌐
Domain & HTTPS Setup
// Map your AWS-purchased domain to Hostinger · Auto-HTTPS for every service · Let's Encrypt via Caddy
📋This tab uses pocketcode.in throughout. Swap with your own domain everywhere. The placeholder YOUR_SERVER_IP appears in many code blocks — replace each with your Hostinger server's actual IP (from hPanel).
⚠️About AWS Certificate Manager (ACM): The public SSL cert you provisioned in ACM for pocketcode.in and *.pocketcode.in cannot be exported — AWS doesn't allow extracting the private key for use outside AWS services. ACM certs only work with CloudFront, ELB, API Gateway, etc.

Solution: We'll use Let's Encrypt via Caddy — a reverse proxy that automatically requests, installs, and renews free SSL certs. Same trusted-by-browsers result, fully automated, no AWS dependency.

Keep your ACM cert — if you later put CloudFront in front of the server for CDN/DDoS protection, you can use it there. For direct HTTPS on the origin server, Let's Encrypt is the standard approach.
🔒Security warning — going public: Once your services are accessible via public domains, expect bot traffic, scanners, and brute-force attempts within minutes. Mitigations applied below:
  • HTTPS-only — Caddy auto-redirects HTTP → HTTPS
  • Basic auth at the proxy layer for services with no built-in auth (Ollama, Qdrant, OpenRouter proxy)
  • Service-level auth preserved — Sim.ai, n8n, pgAdmin, OpenClaw still require their own login/token on top
Use strong passwords. Consider IP allowlisting later if traffic gets noisy.
1
Plan Your Subdomain Map

Decide which services get a public subdomain. Recommended mapping:

SubdomainServiceInternal targetAuth layer
sim.pocketcode.inSim.aisim-simstudio-1:3000Sim.ai login
sim-realtime.pocketcode.inSim.ai WebSocket / realtime backendsim-realtime-1:3002Internal (used by browser JS only)
chat.pocketcode.inOpen WebUI (Ollama chat)open-webui:8080Open WebUI login (admin signup)
n8n.pocketcode.inn8nn8n:5678n8n login
openclaw.pocketcode.inOpenClaw Dashboardopenclaw:18789Token in URL
pgadmin.pocketcode.inpgAdmin (DB UI)pgadmin:80pgAdmin login
ollama.pocketcode.inOllama APIollama:11434Caddy basic auth ⚠️
qdrant.pocketcode.inQdrant Dashboardqdrant:6333Caddy basic auth ⚠️
openrouter.pocketcode.inOpenRouter Proxy (LiteLLM)openrouter-proxy:4000Caddy basic auth ⚠️
💡About direct PostgreSQL access: The database itself (port 5432) uses its own native TLS protocol, not HTTPS. To connect from DBeaver/TablePlus on your Mac, use pocketcode.in:5432 directly — no Caddy involved. Use pgadmin.pocketcode.in for the web UI.
2
⚠️ Disable DNSSEC in Route 53 (Critical First Step)
⚠️If your domain has DNSSEC enabled in Route 53, you MUST disable it before proceeding. AWS Route 53 occasionally has stale/expired DNSSEC signatures that block Let's Encrypt cert acquisition with cryptic errors like DNSSEC: Signature Expired or DNSSEC: Bogus. There's no workaround — every cert-issuing path validates DNSSEC, including DNS-01 challenges. Disabling DNSSEC takes 5-30 minutes; trying to work around it can waste days.

Step 1 — Check if DNSSEC is enabled:

bash · check DNSSEC status
dig +dnssec pocketcode.in @8.8.8.8 | grep -iE "rrsig|EDE"

If output contains RRSIG records or EDE: 7 (Signature Expired) errors → DNSSEC is enabled and likely broken. Continue below. If output is empty → DNSSEC is already off, skip to Step 3.

Step 2 — Remove the DS record at the registrar (parent zone):

  1. Open Route 53 → Registered domains
  2. Click pocketcode.in
  3. Scroll to DNSSEC keys section
  4. Click the existing key → Delete DS record from the registry
  5. Confirm deletion
💡AWS handles the registry communication automatically since they're your registrar. You just click delete.

Step 3 — Wait for DNS propagation:

AWS will warn about 48-hour TTL. In practice with AWS-registered domains, propagation completes in 5-30 minutes. Monitor with:

bash · monitor — run every few minutes
dig DS pocketcode.in @8.8.8.8 +short
dig +dnssec pocketcode.in @8.8.8.8 | grep -i "rrsig"
dig +dnssec pocketcode.in @8.8.8.8 | grep -i "EDE"

When all three return empty → safe to proceed.

Step 4 — Disable DNSSEC signing in Route 53:

  1. Open Route 53 → Hosted zones → click pocketcode.in
  2. Click the DNSSEC signing tab
  3. Click Disable DNSSEC signing
  4. Select Parent zone (since the DS was at the .in registry)
  5. Check the affirmation box, type disable, click Disable
DNSSEC is now fully off. You won't need it back — for personal/dev setups, the only thing it protects against is DNS cache poisoning, which TLS already mitigates at the application layer.
3
Configure DNS in AWS Route 53

Point your domain at your Hostinger server. Since you bought the domain through AWS, DNS is managed in Route 53.

  1. Sign in to AWS Route 53 console
  2. Click Hosted zones → click pocketcode.in
  3. Click Create record, fill in:

Record 1 — root domain:

FieldValue
Record name(leave empty)
Record typeA
ValueYOUR_SERVER_IP
TTL300 (5 minutes — short for testing, raise later)

Record 2 — wildcard for all subdomains:

FieldValue
Record name*
Record typeA
ValueYOUR_SERVER_IP
TTL300

Click Create records. The wildcard *.pocketcode.in means every subdomain (sim, n8n, ollama, etc.) automatically points to your server — no need to create individual records.

✏️Replace: YOUR_SERVER_IP with your Hostinger server's actual IP address.
4
Verify DNS Propagation

DNS changes typically propagate within 5–30 minutes. Test from your Mac (or server) before moving on:

bash · command 1 — check root domain
dig +short pocketcode.in
bash · command 2 — check a subdomain (wildcard)
dig +short sim.pocketcode.in

Both should return your server IP:

expected output
YOUR_SERVER_IP
⚠️If you get an empty response or different IP, wait a few more minutes and retry. You can also check propagation worldwide at dnschecker.org.
💡Don't skip this step! Caddy will fail to issue SSL certs if DNS isn't pointing at your server yet (Let's Encrypt does an HTTP challenge that requires reaching your server via the domain).
5
Open Firewall Ports 80 and 443
bash · open HTTP and HTTPS ports
ufw allow 80 && ufw allow 443

Port 80 is required for Let's Encrypt's HTTP-01 challenge (cert acquisition). Port 443 is HTTPS.

bash · verify both are open
ufw status | grep -E "^(80|443)"

Expected — both ports listed as ALLOW:

expected output
80                         ALLOW       Anywhere
443                        ALLOW       Anywhere
80 (v6)                    ALLOW       Anywhere (v6)
443 (v6)                   ALLOW       Anywhere (v6)
6
Create AWS IAM User for Route 53 DNS Challenge
💡Why DNS-01 instead of HTTP-01? Let's Encrypt offers two ways to prove you own a domain: serve a file via HTTP (HTTP-01), or create a DNS TXT record (DNS-01). We use DNS-01 because it:
  • Works without an HTTP listener (firewall-friendly)
  • Can issue wildcard certs like *.pocketcode.in if needed later
  • Doesn't depend on inbound port 80 reachability from Let's Encrypt's servers
Caddy needs API access to Route 53 to programmatically create/delete TXT records during cert acquisition. We'll create a dedicated IAM user with minimal permissions.

Step 1 — Create the IAM policy:

  1. Open IAM → Policies → Create policy
  2. Click the JSON tab
  3. Replace contents with:
json · IAM policy for Caddy Route 53 access
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Route53Caddy",
      "Effect": "Allow",
      "Action": [
        "route53:ListHostedZones",
        "route53:ListHostedZonesByName",
        "route53:GetChange",
        "route53:GetHostedZone",
        "route53:ChangeResourceRecordSets",
        "route53:ListResourceRecordSets"
      ],
      "Resource": "*"
    }
  ]
}
📜Why 6 actions and not 4. The caddy-route53 plugin needs read-and-write access to the _acme-challenge TXT records during DNS-01 challenges. ListHostedZones + ListHostedZonesByName let it find which zone owns your domain. GetHostedZone fetches zone metadata. ChangeResourceRecordSets creates and deletes the TXT records. ListResourceRecordSets is the critical one that lets Caddy clean up after itself — without it, stale TXT records accumulate after every renewal and eventually block all new challenges. GetChange polls until propagation completes. Earlier minimal-policy guides omitted ListHostedZones + GetHostedZone, which works initially but breaks under certain failure modes — Tab 19 (TLS Troubleshooting) covers what happens.
  1. Click Next
  2. Policy name: CaddyRoute53DNSChallenge
  3. Click Create policy

Step 2 — Create the IAM user:

  1. Open IAM → Users → Create user
  2. Username: caddy-route53-dns
  3. Click Next
  4. Select Attach policies directly
  5. Search for and check CaddyRoute53DNSChallenge
  6. Click NextCreate user

Step 3 — Generate access keys:

  1. Click into the user you just created
  2. Click the Security credentials tab
  3. Scroll to Access keysCreate access key
  4. Select Application running outside AWS
  5. Click NextCreate access key
  6. COPY BOTH values immediately — Access key ID + Secret access key. The secret is only shown ONCE.
🔒Treat these credentials like passwords. Save them in a password manager. If exposed, anyone could modify your Route 53 TXT records (though the impact is limited thanks to the minimal policy).
7
Generate Basic Auth Hashes

Services without built-in auth (Ollama, Qdrant, OpenRouter Proxy) need a password layer at Caddy. Generate a bcrypt hash for your chosen password:

bash · generate password hash (replace PASSWORD with your chosen password)
docker run --rm caddy:latest caddy hash-password --plaintext "PASSWORD"
✏️Replace: PASSWORD with a strong password (12+ chars, mix of letters/numbers/symbols). Use the same password for all three services or generate three hashes for different passwords.

Expected output — a bcrypt hash starting with $2a$14$:

expected output
$2a$14$HASHEDPASSWORDLOOKSLIKETHISLONGSTRING.OfNumbersAndLetters/AbCdEfG
⚠️Copy the hash — you'll paste it into the Caddyfile in the next step. The hash is safe to share (you can't reverse it to the password), but the password itself goes nowhere — only the hash.
8
Build Custom Caddy Image with Route 53 Plugin

The official Caddy Docker image doesn't include the Route 53 DNS plugin (needed for ACME DNS-01 challenges) or the replace-response plugin (used in v1.6+ to inject a session-poll script into Qdrant's HTML responses — see Tab 18 Step 16). We build a custom image with both baked in using Caddy's official xcaddy tool. Takes ~2 minutes.

bash · step 1 — create the Dockerfile
mkdir -p ~/ai-stack/caddy && nano ~/ai-stack/caddy/Dockerfile
dockerfile · step 2 — paste this, then Ctrl+O save, Ctrl+X exit
FROM caddy:builder AS builder
RUN xcaddy build \
    --with github.com/caddy-dns/route53 \
    --with github.com/caddyserver/replace-response

FROM caddy:latest
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
bash · step 3 — build the custom image (takes ~2 min)
cd ~/ai-stack/caddy && docker build -t caddy-route53:latest .

Expected output ends with:

expected output
Successfully tagged caddy-route53:latest

Verify the plugin is included:

bash · check plugin is registered
docker run --rm caddy-route53:latest caddy list-modules | grep -E 'route53|replace_response'

Should print two lines: dns.providers.route53 and http.handlers.replace_response. Both plugins are now baked into the image.

9
Create the Caddyfile
bash · open Caddyfile in nano
nano ~/ai-stack/caddy/Caddyfile
caddyfile · paste this, then Ctrl+O save, Ctrl+X exit
{
    email your-email@example.com
    acme_ca https://acme-v02.api.letsencrypt.org/directory
    acme_dns route53 {
        max_retries 10
    }
}

# Landing page (optional)
pocketcode.in {
    respond "AI Lab — services: sim, n8n, openclaw, pgadmin, ollama, qdrant, openrouter" 200
}

# Sim.ai — workflow canvas
sim.pocketcode.in {
    reverse_proxy sim-simstudio-1:3000
}

# Sim.ai realtime — WebSocket backend (used by /workspace for live collaboration)
sim-realtime.pocketcode.in {
    reverse_proxy sim-realtime-1:3002
}

# Open WebUI — ChatGPT-style frontend for Ollama
chat.pocketcode.in {
    reverse_proxy open-webui:8080
}

# n8n — workflow automation
n8n.pocketcode.in {
    reverse_proxy n8n:5678
}

# OpenClaw — Control UI
openclaw.pocketcode.in {
    reverse_proxy openclaw:18789
}

# pgAdmin — PostgreSQL UI
pgadmin.pocketcode.in {
    reverse_proxy pgadmin:80
}

# Ollama — gated with basic auth
ollama.pocketcode.in {
    basic_auth {
        admin PASTE_BCRYPT_HASH_HERE
    }
    reverse_proxy ollama:11434
}

# Qdrant — gated with basic auth
qdrant.pocketcode.in {
    basic_auth {
        admin PASTE_BCRYPT_HASH_HERE
    }
    reverse_proxy qdrant:6333
}

# OpenRouter proxy (LiteLLM) — gated with basic auth
openrouter.pocketcode.in {
    basic_auth {
        admin PASTE_BCRYPT_HASH_HERE
    }
    reverse_proxy openrouter-proxy:4000
}
💡The acme_dns route53 directive in the global block tells Caddy to use DNS-01 challenge (Route 53 API) for all certs instead of HTTP-01. This works with broken/disabled DNSSEC and produces wildcard-capable certs. The acme_ca line above it pins Caddy to Let's Encrypt only — without it, Caddy maintains a fallback chain (LE → ZeroSSL) where a single failed challenge triggers a parallel attempt from a second issuer, and both issuers race to write TXT records at _acme-challenge. The collision causes both to fail, leaving stale TXT records that block all future attempts. Pinning to one issuer eliminates this entire failure mode. Tab 19 (TLS Troubleshooting) covers symptoms and recovery.
✏️Replace before saving:
PlaceholderWhat to putWhere to get it
your-email@example.comYour emailUsed for Let's Encrypt notifications (expiry warnings)
pocketcode.inYour domainAppears 8 times — replace ALL if different
PASTE_BCRYPT_HASH_HEREThe bcrypt hash from Step 7Output of caddy hash-password (appears 3 times — same hash OR different ones)
adminUsername for basic authPick any username — appears 3 times
💡Unify the 3 basic-auth hashes to remember one password instead of three. Generate one hash, paste it in all three places. The 3 services (ollama, qdrant, openrouter) then share admin / your-password.
10
Create the Run Script
bash · step 1 — create the script
touch ~/ai-stack/caddy/run-caddy.sh && nano ~/ai-stack/caddy/run-caddy.sh
bash · step 2 — paste this, then Ctrl+O save, Ctrl+X exit
#!/bin/bash
# Auto-detect ai-stack bridge gateway — that's the host's IP on Caddy's network,
# used by host.docker.internal so Caddy can reverse_proxy to host-native
# services (e.g. ttyd in Tab 18).
AI_STACK_GW=$(docker network inspect ai-stack --format '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null)
[ -z "$AI_STACK_GW" ] && AI_STACK_GW="172.18.0.1"   # fallback
echo "Caddy reaches host via host.docker.internal → $AI_STACK_GW"

docker run -d \
  --name caddy \
  --network ai-stack \
  --restart unless-stopped \
  -p 80:80 \
  -p 443:443 \
  -p 443:443/udp \
  --add-host=host.docker.internal:${AI_STACK_GW} \
  -e AWS_ACCESS_KEY_ID="PASTE_ACCESS_KEY_ID" \
  -e AWS_SECRET_ACCESS_KEY="PASTE_SECRET_ACCESS_KEY" \
  -e AWS_REGION="us-east-1" \
  -v ~/ai-stack/caddy/Caddyfile:/etc/caddy/Caddyfile \
  -v caddy_data:/data \
  -v caddy_config:/config \
  caddy-route53:latest
✏️Replace before saving:
PlaceholderWhat to put
PASTE_ACCESS_KEY_IDAccess key ID from Step 6
PASTE_SECRET_ACCESS_KEYSecret access key from Step 6
💡Port 443/udp enables HTTP/3 (QUIC) — faster page loads. Image caddy-route53:latest is the custom image you built in Step 8. AWS credentials are needed by the Route 53 plugin for DNS-01 challenge. The --add-host=host.docker.internal:<gw> flag lets Caddy reach host-native services (used in Tab 17 to proxy terminal.pocketcode.in to the host's ttyd systemd service).

Save with Ctrl+O, Enter, Ctrl+X.

11
Launch Caddy & Watch Cert Acquisition
bash · command 1 — make executable and run
chmod +x ~/ai-stack/caddy/run-caddy.sh && bash ~/ai-stack/caddy/run-caddy.sh
bash · command 2 — connect Caddy to Sim.ai's network
docker network connect sim_default caddy 2>/dev/null || echo "Already connected — OK"

Caddy needs to reach sim-simstudio-1 which is on the sim_default network. The rest of the services (n8n, openclaw, pgadmin, ollama, qdrant, openrouter-proxy) are on ai-stack.

bash · command 3 — watch Caddy acquire SSL certs (Ctrl+C to exit)
docker logs caddy -f

You should see Caddy requesting and obtaining certs for each subdomain — takes ~30-60 seconds:

expected log output (excerpt)
"trying to solve challenge" identifier=sim.pocketcode.in challenge_type=dns-01
"certificate obtained successfully" identifier=sim.pocketcode.in
"certificate obtained successfully" identifier=n8n.pocketcode.in
...

Press Ctrl+C to stop tailing logs once you see all certs obtained.

💡Don't panic if you see early "Incorrect TXT record" errors. If Caddy left stale TXT records from a previous attempt in Route 53, Let's Encrypt will reject the first try → Caddy creates fresh records → second attempt succeeds within ~30 seconds. Self-healing behavior.
🔄ZeroSSL fallback: Caddy tries Let's Encrypt first. If LE has rate-limited your account from prior failed attempts, Caddy automatically falls back to ZeroSSL (also free, fully trusted). You may see some certs from LE and others from ZeroSSL — both are equally valid.
⚠️If cert acquisition fails: Verify DNSSEC is fully off (Step 2 verification commands), check AWS credentials in the run script, and check the IAM user has the policy attached.
12
Test Each Subdomain in Browser

Open each in Chrome — you should get a green padlock 🔒 and the service should load:

URLWhat you should see
https://sim.pocketcode.inSim.ai login page
https://sim-realtime.pocketcode.in/socket.io/Should return JSON error (Socket.IO expects WS handshake — HTTP 400 means reachable ✓)
https://chat.pocketcode.inOpen WebUI sign-up / sign-in
https://n8n.pocketcode.inn8n workflow canvas (or login)
https://openclaw.pocketcode.in/#token=YOUR_TOKEN"Device pairing required" — handled in Step 13
https://pgadmin.pocketcode.inpgAdmin login
https://ollama.pocketcode.inBrowser prompts for username/password → "Ollama is running"
https://qdrant.pocketcode.in/dashboardBrowser prompts for password → Qdrant UI
https://openrouter.pocketcode.in/healthBrowser prompts for password → JSON health response

Or quick CLI test for all subdomains at once:

bash · quick HTTPS test from server
for sub in sim sim-realtime chat n8n openclaw pgadmin ollama qdrant openrouter; do
  echo -n "$sub.pocketcode.in: "
  curl -sI -o /dev/null -w "%{http_code}\n" "https://$sub.pocketcode.in"
done

Expected — all should return 200, 302, or 401 (auth required is also OK):

expected output
sim.pocketcode.in: 200
sim-realtime.pocketcode.in: 400
chat.pocketcode.in: 200
n8n.pocketcode.in: 200
openclaw.pocketcode.in: 200
pgadmin.pocketcode.in: 302
ollama.pocketcode.in: 401
qdrant.pocketcode.in: 401
openrouter.pocketcode.in: 401
🔁Browser caches basic auth aggressively. If you mistyped a password earlier and Chrome cached it, you'll keep failing silently. Two fixes:
  • Test in incognito — clean slate, fresh auth prompt
  • Verify password from CLI: curl -u username:password https://ollama.pocketcode.in/ — should return "Ollama is running"
If CLI works but browser doesn't, clear site data for the subdomain in chrome://settings/clearBrowserData.
13
First Visit to OpenClaw — Approve Device Pairing
🔐OpenClaw has built-in device pairing — each new origin (browser + URL combination) requires explicit approval from the Gateway host. When you visit https://openclaw.pocketcode.in for the first time, you'll see "Device pairing required" with a request ID. This is normal security behavior, not an error.

What you'll see in browser:

openclaw error message
Device pairing required
This browser needs one-time approval from the Gateway host before it can use the Control UI.

1. Run openclaw devices list on the Gateway host.
2. Approve this request: openclaw devices approve REQUEST_ID
3. Reconnect after the approval completes.

Step 1 — Get the pending request ID:

bash · list pending pairing requests
docker exec -it openclaw node /app/openclaw.mjs devices list

Look at the Pending table — note the Request ID (long UUID like a9f96054-a981-408d-87c4-e33214fb5a37).

💡The request ID shown in your browser might be stale if the browser tab reconnected after the original error. Always use the ID from devices list — that's the live one.

Step 2 — Approve the request:

bash · approve device (replace UUID with yours)
docker exec -it openclaw node /app/openclaw.mjs devices approve a9f96054-a981-408d-87c4-e33214fb5a37
✏️Replace: a9f96054-a981-408d-87c4-e33214fb5a37 with the request ID from your devices list output.

Step 3 — Reload the OpenClaw page:

Refresh https://openclaw.pocketcode.in/#token=YOUR_TOKEN in your browser. The Control UI loads.

🔁One-time per browser, per origin. Future logins from the same browser won't re-prompt. New device (phone, another laptop) → new pairing required. To inspect paired devices later: docker exec openclaw node /app/openclaw.mjs devices list
🔖Bookmark this for one-click access: https://openclaw.pocketcode.in/chat#token=YOUR_TOKEN — the URL fragment auto-fills the token and lands you directly in chat.
14
Update n8n to Use the New Domain

n8n's N8N_HOST, WEBHOOK_URL, and N8N_PROTOCOL are baked into the container at creation. Update the run script:

bash · step 1 — edit run-n8n.sh
nano ~/ai-stack/n8n/run-n8n.sh

Find and update these 3 environment variables:

Old valueNew value
-e N8N_HOST=YOUR_SERVER_IP \-e N8N_HOST=n8n.pocketcode.in \
-e N8N_PROTOCOL=http \-e N8N_PROTOCOL=https \
-e WEBHOOK_URL=http://YOUR_SERVER_IP:5678/ \-e WEBHOOK_URL=https://n8n.pocketcode.in/ \

Also add these 3 new flags just after N8N_RUNNERS_ENABLED=true. They tell n8n it's behind a reverse proxy, so it trusts the X-Forwarded-Proto header and issues secure cookies correctly. Without them, manual login on the n8n page loops back to login with no visible error:

bash · new flags to add
  -e N8N_PROXY_HOPS=1 \
  -e N8N_SECURE_COOKIE=true \
  -e N8N_EDITOR_BASE_URL=https://n8n.pocketcode.in/ \

Save (Ctrl+O, Enter, Ctrl+X), then recreate the container:

bash · step 2 — restart n8n with new env
docker stop n8n && docker rm n8n && bash ~/ai-stack/n8n/run-n8n.sh
💡n8n's encryption key is preserved (it's stored in the n8n-data volume, not the container). All your workflows and credentials remain intact.
15
Update Sim.ai to Use the New Domain

Sim.ai needs several env vars updated: BETTER_AUTH_URL for auth callbacks, and NEXT_PUBLIC_SOCKET_URL for the workspace's WebSocket connection.

⚠️Critical for /workspace to load: Sim.ai's workspace page uses a separate realtime container (sim-realtime-1 on port 3002) for live collaboration. Without NEXT_PUBLIC_SOCKET_URL pointing to a publicly accessible HTTPS endpoint, the page loads completely blank with no visible error — diagnostics still pass since containers are healthy. The fix is the sim-realtime.pocketcode.in subdomain you added in Step 9.
bash · step 1 — edit .env
nano ~/ai-stack/sim/.env

Update these lines (add them if missing):

env · update or add
BETTER_AUTH_URL=https://sim.pocketcode.in
NEXT_PUBLIC_APP_URL=https://sim.pocketcode.in
NEXTAUTH_URL=https://sim.pocketcode.in

# BetterAuth requires the public origin to be in its allow-list, or every
# cross-origin POST (including the SSO login) gets silently rejected.
# Without this you get "stuck on login page" with no visible error.
TRUSTED_ORIGINS=https://sim.pocketcode.in,https://sim-realtime.pocketcode.in

# WebSocket realtime — two different URLs for two different audiences:
SOCKET_SERVER_URL=http://realtime:3002
NEXT_PUBLIC_SOCKET_URL=https://sim-realtime.pocketcode.in
💡Why two SOCKET URLs?
  • SOCKET_SERVER_URL — used by the Sim.ai backend to talk to the realtime container. They're both in the same Docker network, so internal Docker DNS (http://realtime:3002) is the fastest path. No TLS overhead, no external hop.
  • NEXT_PUBLIC_SOCKET_URL — baked into the browser JS bundle. Browsers can't resolve internal Docker hostnames, so they need a public HTTPS URL. The NEXT_PUBLIC_* prefix tells Next.js to expose this env var to client-side code.

Save (Ctrl+O, Enter, Ctrl+X), then recompose Sim.ai (full down + up required since NEXT_PUBLIC_* vars are read at startup):

bash · step 2 — recompose Sim.ai stack
cd ~/ai-stack/sim && docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d

# IMPORTANT: compose down/up creates fresh containers attached only to
# Sim's own network. Reconnect ALL four to ai-stack so Caddy, n8n,
# and the auth-gateway can reach them by name again.
docker network connect ai-stack sim-db-1        2>/dev/null
docker network connect ai-stack sim-simstudio-1 2>/dev/null
docker network connect ai-stack sim-realtime-1  2>/dev/null
docker network connect ai-stack sim-redis-1     2>/dev/null

# n8n holds open a connection pool to sim-db-1; restart it so it
# re-resolves DNS on the (now reachable again) ai-stack network.
docker restart n8n

Verify the env made it in:

bash · verify
docker exec sim-simstudio-1 env | grep SOCKET

Expected:

expected output
SOCKET_SERVER_URL=http://realtime:3002
NEXT_PUBLIC_SOCKET_URL=https://sim-realtime.pocketcode.in
🔁Browser cache warning: If you previously accessed Sim.ai via IP (http://YOUR_IP:3000) or SSH tunnel (http://127.0.0.1:3000), the browser has stale cookies, localStorage, and an old JS bundle cached with NEXT_PUBLIC_SOCKET_URL="". After this update, you'll likely see a blank /workspace page in your regular browser even though everything is configured correctly. Two fixes: (a) test in incognito window to confirm the server is working, then (b) in your regular browser, F12 → Application → Storage → Clear site data, then refresh and log in again. One-time cleanup.
⚠️The down && up sequence may take 30-60 seconds. Don't refresh sim.pocketcode.in until all containers show healthy.
16
Update Management Scripts for HTTPS

Now that Caddy is in the picture, the management scripts need three updates:

  1. start-all.sh — launch Caddy, reconnect networks, print HTTPS URLs as primary access
  2. diagnose.sh — include Caddy in container/error scans, filter transient DNS errors
  3. Both — handle the sim-db-1 network drop that happens on Sim.ai recreate

Update 1 — Add Caddy launch + network bridge to start-all.sh:

bash · edit start-all.sh
nano ~/ai-stack/manage/start-all.sh

Find this block:

existing code — find this
# 11. Start pgAdmin
log "Starting pgAdmin..."
start_or_create pgadmin ~/ai-stack/run-pgadmin.sh

Add this block right after it:

bash · paste after Step 11
# 12. Start Caddy (HTTPS reverse proxy)
log "Starting Caddy..."
start_or_create caddy ~/ai-stack/caddy/run-caddy.sh

# Reconnect Caddy to sim_default after Sim.ai compose may have recreated network
docker network connect sim_default caddy 2>/dev/null \
  && ok "caddy connected to sim_default" || ok "caddy already on sim_default"

Update 2 — Replace SSH tunnel sections with HTTPS-only output:

Find the section starting with # ═══ SSH TUNNEL COMMANDS ═══ at the bottom of the script. Delete everything from that line to the end of the file, then paste this clean HTTPS-only replacement:

bash · paste at end of start-all.sh (replaces old SSH section)
# ═══ HTTPS URLS (single login at pocketcode.in unlocks all) ═══
echo ""
echo -e "${BOLD}╔════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║   pocketcode.in — All Services Online                  ║${NC}"
echo -e "${BOLD}╚════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BOLD}🌐 Start here:${NC}"
echo -e "  ${G}Gateway / Login${NC}    →  https://pocketcode.in"
echo ""
echo -e "${BOLD}📖 Public (no login):${NC}"
echo -e "  ${G}Setup Guide${NC}        →  https://setup.pocketcode.in"
echo -e "  ${G}Service Docs${NC}       →  https://docs.pocketcode.in"
echo ""
echo -e "${BOLD}🔐 Gated by pocketcode.in cookie:${NC}"
echo -e "  ${G}Open WebUI${NC}         →  https://chat.pocketcode.in"
echo -e "  ${G}Sim.ai${NC}             →  https://sim.pocketcode.in"
echo -e "  ${G}n8n${NC}                →  https://n8n.pocketcode.in"
echo -e "  ${G}OpenClaw${NC}           →  https://openclaw.pocketcode.in"
echo -e "  ${G}pgAdmin${NC}            →  https://pgadmin.pocketcode.in"
echo -e "  ${G}Web Terminal${NC}       →  https://terminal.pocketcode.in"
echo -e "  ${G}Ollama API${NC}         →  https://ollama.pocketcode.in"
echo -e "  ${G}Qdrant${NC}             →  https://qdrant.pocketcode.in/dashboard"
echo -e "  ${G}OpenRouter${NC}         →  https://openrouter.pocketcode.in"
echo ""
echo -e "${BOLD}═════════════════════════════════════════════════════════${NC}"
echo -e "${G}✓ Stack ready. Sign in once at pocketcode.in, click any card.${NC}"
echo -e "${DIM}  Run 'ai-doctor' for diagnostics.${NC}"
echo ""

Save (Ctrl+O, Enter, Ctrl+X).

Why SSH tunnels are gone: Before HTTPS, services that bind to 127.0.0.1 only (n8n, OpenClaw dashboard) needed SSH tunnels to reach. Now Caddy proxies them via HTTPS subdomains using the internal Docker network — the loopback binding is no longer a barrier. After Tab 18 (Auth Gateway), one login at pocketcode.in unlocks every service via the shared .pocketcode.in cookie. No more juggling tunnel terminals.

Update 3 — Add Caddy to diagnose.sh services array:

bash · edit diagnose.sh
nano ~/ai-stack/manage/diagnose.sh

Find this line:

existing — find this
services=(ollama openclaw n8n openrouter-proxy qdrant pgadmin sim-db-1 sim-redis-1 sim-realtime-1 sim-simstudio-1 open-webui)

Replace with (add caddy at the end):

bash · replace with
services=(ollama openclaw n8n openrouter-proxy qdrant pgadmin sim-db-1 sim-redis-1 sim-realtime-1 sim-simstudio-1 open-webui caddy)

Update 4 — Filter transient DNS errors in diagnose.sh error scan:

💡Why this filter is needed: When Sim.ai compose recreates sim-db-1, there's a brief window where n8n can't resolve the hostname → throws Error: getaddrinfo EAI_AGAIN sim-db-1 → log line lingers in the 100-line buffer. This is a transient race condition that recovers automatically once the network bridge re-establishes (handled in start-all.sh Step 3). The error is benign and should be filtered.

Still in diagnose.sh, Ctrl+W, type errors=, Enter. Find this long line:

existing — find this (one long line)
    errors=$(docker logs "$svc" --tail 100 2>&1 | grep -iE "(^|[[:space:]])(error|fatal|panic|exception)[: ]" | grep -ivE "no error|0 error|errorlevel|error_log|error-level|no such file|relation \".*\" does not exist|database \".*\" does not exist|role \".*\" does not exist|duplicate key|invalid input syntax for type vector|terminating connection due to administrator command|Failed to load model catalog" | wc -l)

Replace with (adds |getaddrinfo EAI_AGAIN to the filter):

bash · replace with
    errors=$(docker logs "$svc" --tail 100 2>&1 | grep -iE "(^|[[:space:]])(error|fatal|panic|exception)[: ]" | grep -ivE "no error|0 error|errorlevel|error_log|error-level|no such file|relation \".*\" does not exist|database \".*\" does not exist|role \".*\" does not exist|duplicate key|invalid input syntax for type vector|terminating connection due to administrator command|Failed to load model catalog|getaddrinfo EAI_AGAIN" | wc -l)

Save (Ctrl+O, Enter, Ctrl+X).

Update 5 — Test everything:

bash · verify everything is green
ai-doctor

You should see:

  • Caddy listed in Container Status as UP
  • Caddy on both ai-stack AND sim_default networks
  • All services with green ✓ clean in Error Scan
  • All connectivity checks green
⚠️Manual Sim.ai restarts still drop sim-db-1 from ai-stack. Whenever you run docker compose -f docker-compose.prod.yml down/up directly (instead of ai-start), follow up with: docker network connect ai-stack sim-db-1 && docker restart n8n. ai-start handles this automatically via Step 3 of the script.
17
Final URL Reference & Daily Use

Your services are now accessible from anywhere with proper HTTPS. Bookmark these:

ServicePublic URL (HTTPS)
Sim.aihttps://sim.pocketcode.in
Open WebUI (Ollama chat)https://chat.pocketcode.in
n8nhttps://n8n.pocketcode.in
OpenClaw Dashboardhttps://openclaw.pocketcode.in/#token=YOUR_TOKEN
pgAdminhttps://pgadmin.pocketcode.in
Ollama APIhttps://ollama.pocketcode.in (basic auth)
Qdrant Dashboardhttps://qdrant.pocketcode.in/dashboard (basic auth)
OpenRouter Proxyhttps://openrouter.pocketcode.in (basic auth)
💡SSH tunnels no longer required. The previous setup needed tunnels for n8n (secure cookie) and OpenClaw (Control UI binding). With HTTPS now in place, both work directly via their public URLs.
🔄Cert renewal: Caddy auto-renews Let's Encrypt certs 30 days before expiry. No manual action needed. You'll get email notifications from Let's Encrypt 20 days before expiry if anything goes wrong.
⚠️Adding a new service later? Edit ~/ai-stack/caddy/Caddyfile to add a new subdomain block, then docker exec caddy caddy reload --config /etc/caddy/Caddyfile — no container restart needed, zero downtime.
📖
Host This Guide
// Serve this guide at setup.pocketcode.in · Caddy static file server · ~5 min setup
💡Why host it? Having this guide accessible at a stable URL means you can reference it from any device (phone, tablet, another laptop) without copying files around. It's also handy for sharing with teammates or future-you when you rebuild the stack 6 months from now. Total setup: 5 minutes.
📦One bundle, three services. Tab 15 deploys pocketcode-bundle.zip which contains three things: the setup guide (this tab), the docs page (Tab 16), and the auth gateway (Tab 18). Step 1 below uploads and extracts the whole bundle once — Tabs 15 and 17 then just reference the already-extracted folders.
1
Deploy the Bundle (one-time, covers all 3 services)

What this step does: uploads pocketcode-bundle.zip from your Mac to the server, extracts it, copies the three folders to ~/ai-stack/, and cleans up. After this single step, Tabs 15 (this one), 16 (docs page), and 18 (auth gateway) all have the files they need.

Part A — Download the bundle. The chat where you built this stack has a download for pocketcode-bundle.zip. Save it to your Mac:

expected location · on Mac
ls -lh ~/Downloads/pocketcode-bundle.zip   # confirm ~170KB

Quick peek at contents (optional):

bash · on Mac
unzip -l ~/Downloads/pocketcode-bundle.zip

You should see setup-page/index.html, docs-page/index.html, auth-gateway/ (folder with server.js etc.), and README.md.

Part B — Upload the ZIP to your server (single scp):

bash · on Mac
scp ~/Downloads/pocketcode-bundle.zip root@YOUR_SERVER_IP:~/
✏️Replace: YOUR_SERVER_IP with your server's actual IP address.

Part C — Extract and distribute on the server (one combined command block):

bash · on server
# Make sure ai-stack exists
mkdir -p ~/ai-stack

# Extract to a temp location, then copy each folder to its final home
cd /tmp
unzip -o ~/pocketcode-bundle.zip
cp -rf pocketcode-bundle/setup-page  ~/ai-stack/
cp -rf pocketcode-bundle/docs-page   ~/ai-stack/
cp -rf pocketcode-bundle/auth-gateway ~/ai-stack/

# Make run-auth.sh executable
chmod +x ~/ai-stack/auth-gateway/run-auth.sh

# Clean up temp + the uploaded ZIP
rm -rf /tmp/pocketcode-bundle
rm ~/pocketcode-bundle.zip

Part D — Verify everything is in place:

bash · on server
ls -lh ~/ai-stack/setup-page/index.html
ls -lh ~/ai-stack/docs-page/index.html
ls -lh ~/ai-stack/auth-gateway/

Expected: setup-page ~550KB, docs-page ~150KB, auth-gateway shows 5 files (server.js, package.json, Dockerfile, run-auth.sh, .env.example) plus a public/ subdirectory.

🔄Updating later: When you get a newer pocketcode-bundle.zip from chat, repeat this entire step. The cp -rf flag overwrites existing files, so it's safe to run multiple times. Your edits to auth-gateway/.env are preserved (that file isn't in the bundle).

Folder structure after Step 1:

on server · ~/ai-stack/
~/ai-stack/
├── setup-page/
│   └── index.html         # served by Caddy in Tab 17 (this tab)
├── docs-page/
│   └── index.html         # served by Caddy in Tab 16
└── auth-gateway/          # built & run in Tab 18
    ├── server.js
    ├── package.json
    ├── Dockerfile
    ├── run-auth.sh
    ├── .env.example
    └── public/
        ├── login.html
        └── home.html
2
Add Volume Mount to Caddy Run Script

Caddy needs read access to the folder. Edit the run script to add a volume mount:

bash · edit run-caddy.sh
nano ~/ai-stack/caddy/run-caddy.sh

Find this line:

existing — find this
  -v ~/ai-stack/caddy/Caddyfile:/etc/caddy/Caddyfile \

Add this line right after it (between the Caddyfile mount and caddy_data):

bash · add this line
  -v ~/ai-stack/setup-page:/srv/setup-page:ro \

The :ro flag makes the mount read-only — Caddy can serve files but can't modify them. Defense in depth.

Save (Ctrl+O, Enter, Ctrl+X).

3
Add setup.pocketcode.in Block to Caddyfile
bash · edit Caddyfile
nano ~/ai-stack/caddy/Caddyfile

Add this block at the end (or anywhere — order doesn't matter in Caddy):

caddyfile · public, no auth
# Setup guide — this very page
setup.pocketcode.in {
    root * /srv/setup-page
    file_server
    encode gzip
}
✏️Replace setup.pocketcode.in with your own subdomain if different.
💡What each directive does:
  • root * /srv/setup-page — serve files from the mounted folder
  • file_server — enable static file serving (auto-serves index.html on the root path)
  • encode gzip — compress responses (~280KB HTML → ~50KB over the wire)
🔒Want to add a password later? Step 8 shows how. For most users this guide is fine to keep public — it's been scrubbed of personal data and only contains generic setup instructions plus your own domain (already public via DNS).

Save (Ctrl+O, Enter, Ctrl+X).

4
Add DNS Record for setup.pocketcode.in
Already done if you have a wildcard A record. Tab 14 Step 3 set up *.pocketcode.in → server IP, which automatically covers setup.pocketcode.in. Skip to Step 7.

If you only created individual A records, add one more:

  1. Open Route 53 → Hosted zones → pocketcode.in
  2. Click Create record
  3. Record name: setup
  4. Record type: A
  5. Value: YOUR_SERVER_IP (your server IP)
  6. TTL: 300
  7. Click Create records

Wait ~5 minutes for DNS propagation, then verify:

bash · verify DNS
dig +short setup.pocketcode.in

Should return your server IP.

5
Restart Caddy with New Volume Mount

The volume mount change requires a container recreate (not just a config reload):

bash · recreate Caddy with new mount
docker stop caddy && docker rm caddy
bash ~/ai-stack/caddy/run-caddy.sh
docker network connect sim_default caddy 2>/dev/null

Watch Caddy acquire the new cert:

bash · watch logs
docker logs caddy -f 2>&1 | grep -E "setup.pocketcode|certificate obtained"

Within ~30-60 seconds you should see:

expected output
"certificate obtained successfully" identifier=setup.pocketcode.in

Press Ctrl+C to exit the log tail.

💡Existing certs (sim, n8n, openclaw, etc.) persist in the caddy_data volume — they're not re-issued. Only the new setup.pocketcode.in cert is acquired.
6
Visit setup.pocketcode.in

Open in your browser:

URL
https://setup.pocketcode.in

Expected flow:

  1. Browser shows green padlock 🔒
  2. The guide loads immediately — same interactive tabs as the local file

Quick CLI test from server:

bash · CLI test
curl -sI https://setup.pocketcode.in/ | head -5

Should return HTTP/2 200.

7
Updating the Guide Later

When you get an updated pocketcode-bundle.zip from chat, just re-run Step 1 in this tab. The cp -rf overwrites everything safely. No Caddy restart needed — the volume mount picks up the new file immediately.

If you want a quick one-liner that does the whole update (Mac side):

bash · on Mac
scp ~/Downloads/pocketcode-bundle.zip root@YOUR_SERVER_IP:~/ && \
ssh root@YOUR_SERVER_IP 'cd /tmp && unzip -o ~/pocketcode-bundle.zip && \
  cp -rf pocketcode-bundle/setup-page  ~/ai-stack/ && \
  cp -rf pocketcode-bundle/docs-page   ~/ai-stack/ && \
  cp -rf pocketcode-bundle/auth-gateway ~/ai-stack/ && \
  rm -rf /tmp/pocketcode-bundle ~/pocketcode-bundle.zip'

Hard-refresh your browser (Cmd+Shift+R on Mac, Ctrl+Shift+R on Windows) to bypass cache. New version appears immediately.

💡Pro move — alias the whole update on your Mac. Add to ~/.zshrc:
alias push-bundle='scp ~/Downloads/pocketcode-bundle.zip root@YOUR_SERVER_IP:~/ && ssh root@YOUR_SERVER_IP "cd /tmp && unzip -o ~/pocketcode-bundle.zip && cp -rf pocketcode-bundle/* ~/ai-stack/ && rm -rf /tmp/pocketcode-bundle ~/pocketcode-bundle.zip"'
Then just run push-bundle any time.
8
(Optional) Add a Password Later

If you change your mind and want to gate the page behind basic auth, generate a password hash:

bash · generate hash (replace PASSWORD)
docker run --rm caddy:latest caddy hash-password --plaintext "PASSWORD"

Copy the hash starting with $2a$14$..., then edit the Caddyfile:

bash · edit Caddyfile
nano ~/ai-stack/caddy/Caddyfile

Update the setup.pocketcode.in block to look like this:

caddyfile · with basic auth
setup.pocketcode.in {
    basic_auth {
        admin PASTE_BCRYPT_HASH_HERE
    }
    root * /srv/setup-page
    file_server
    encode gzip
}
✏️Replace: admin with any username, PASTE_BCRYPT_HASH_HERE with the hash from the command above.

Save, then reload (no container restart needed):

bash
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
💡Reuse an existing password. If you already set a password for ollama/qdrant/openrouter in Tab 14, you can paste the same hash here instead of generating a new one — fewer passwords to remember.
📚
Host Docs Page
// Serve service usage docs at docs.pocketcode.in · Public, no auth · Same pattern as setup.pocketcode.in
📖What this hosts: A separate single-HTML site that documents how to use each service in your stack (Open WebUI, Sim.ai, n8n, OpenClaw, Claude Code, OpenRouter, Ollama, Qdrant, pgAdmin) plus integration recipes. Public, no auth required — same as the setup guide. The companion docs-page/index.html file is in your bundle.
💡Why split docs from setup? Setup is "how to install" (sequential, one-time). Docs is "how to use" (random-access, returned to often). Splitting keeps each focused. Same hosting pattern — only difference is which folder is mounted into Caddy.
1
Verify docs-page Folder Exists (from Tab 15 bundle)
Already deployed. Tab 15 Step 1 extracted the bundle and placed ~/ai-stack/docs-page/index.html on your server. This tab just adds Caddy config to serve it. If you skipped Tab 14, go back and run Step 1 of Tab 15 first.

Quick sanity check:

bash · on server
ls -lh ~/ai-stack/docs-page/index.html

Should show the file at ~150KB. If you see "No such file or directory", revisit Tab 15 Step 1.

2
Add Volume Mount to Caddy Run Script

Caddy needs read-only access to the docs folder. Edit the run script:

bash · edit run-caddy.sh
nano ~/ai-stack/caddy/run-caddy.sh

Find the existing setup-page mount line (added in Tab 14):

existing — find this
  -v ~/ai-stack/setup-page:/srv/setup-page:ro \

Add this line right after it:

bash · add this line
  -v ~/ai-stack/docs-page:/srv/docs-page:ro \

Save (Ctrl+O, Enter, Ctrl+X).

3
Add docs.pocketcode.in Block to Caddyfile
bash · edit Caddyfile
nano ~/ai-stack/caddy/Caddyfile

Find the setup.pocketcode.in block (added in Tab 15) and add a docs block right after it:

caddyfile · add this block
# Setup guide — public, no auth
setup.pocketcode.in {
    root * /srv/setup-page
    file_server
    encode gzip
}

# Service usage docs — public, no auth (same pattern as setup)
docs.pocketcode.in {
    root * /srv/docs-page
    file_server
    encode gzip
}

Save.

💡No import auth_gate here — these pages are intentionally public, like the setup guide. The docs site is meant to be referenceable from any browser/device without first authenticating.
4
DNS Record
Auto-covered by your wildcard A record from Tab 13. *.pocketcode.in → server IP matches docs.pocketcode.in as a single-label subdomain. Skip to Step 5.

If you didn't use wildcard DNS, add an A record in Route 53:

  1. Record name: docs
  2. Type: A
  3. Value: your server IP
  4. TTL: 300
5
Restart Caddy with New Mount

Volume mount change requires a container recreate (not just config reload):

bash · recreate Caddy
docker stop caddy && docker rm caddy
bash ~/ai-stack/caddy/run-caddy.sh
docker network connect sim_default caddy 2>/dev/null

Watch the new cert get acquired:

bash · watch logs
docker logs caddy -f 2>&1 | grep -E "docs.pocketcode|certificate obtained"

~30-60 seconds: certificate obtained successfully identifier=docs.pocketcode.in. Ctrl+C.

6
Visit docs.pocketcode.in

Open in browser:

URL
https://docs.pocketcode.in

The docs site loads immediately — no login, green padlock. Navigate the tabs to read usage docs for each service.

CLI sanity check:

bash · CLI test
curl -sI https://docs.pocketcode.in/ | head -3

Returns HTTP/2 200.

7
Updating Docs Later

When you get a new pocketcode-bundle.zip from chat, re-run Tab 14 Step 1 — that's a single command block that updates all three pages (setup, docs, auth-gateway) at once. No Caddy restart needed (volume mounts serve the live files).

If you only edited the docs locally and want to push just that file:

bash · on Mac
scp docs-page/index.html root@YOUR_SERVER_IP:~/ai-stack/docs-page/index.html

Hard-refresh browser.

8
(Optional) Add Docs Card to Gateway Homepage

If you've already deployed the auth gateway (Tab 18), the bundled home.html already includes a Docs card. If you customized your home.html, add this card block inside the .grid div:

html · card snippet
<a href="https://docs.pocketcode.in" target="_blank" rel="noopener" class="card c-docs">
  <div class="card-icon">📚</div>
  <div class="card-name">Service Docs</div>
  <div class="card-desc">How to use each tool in the stack. Public — accessible without login.</div>
  <div class="card-foot"><span>docs.pocketcode.in</span><span class="arrow">→</span></div>
</a>

Also add the color class to the CSS:

css · add to home.html style block
.c-docs::before { background: var(--cyan); }

Rebuild the gateway: bash ~/ai-stack/auth-gateway/run-auth.sh

🖥️
Web Terminal
// Real host shell in your browser · ttyd as systemd service · terminal.pocketcode.in · gated by Auth Gateway
🖥️What this gives you: Your actual server's bash shell, in a browser tab, at https://terminal.pocketcode.in. Prompt shows the real hostname (root@srv1234:~#). Full host access — you can apt install packages, run systemctl, edit any file, and of course docker ps / docker exec any container. Useful for managing the stack from any device (phone, iPad, work laptop) without SSH client setup.
🔒One-time login via the Auth Gateway. Like every gated service, terminal.pocketcode.in is protected by the gateway. Sign in once at pocketcode.in, click the terminal card, you're in. The terminal session itself runs without per-action auth — the gateway cookie protects the initial WebSocket upgrade.
⚠️Security implication worth pausing on. Anyone who gets past the gateway login has full root shell on your VPS. Make your master password (Tab 18 Step 2) strong and don't reuse it. Compared to the alternative — running ttyd inside a sandboxed container — this trades isolation for convenience. Fine for a personal lab on your own VPS. Reconsider if you ever share access.
1
Install ttyd on the Host

ttyd is a single-binary program that exposes any shell over WebSocket. Install via apt:

bash · on server
apt update
apt install -y ttyd
ttyd --version
which ttyd

Expected: ttyd version 1.7.x or higher, and /usr/bin/ttyd.

📦If apt doesn't find it (older Ubuntu releases, before 24.04, didn't ship ttyd in default repos), grab the standalone binary instead:
bash · fallback install
ARCH=$(uname -m)
wget "https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.${ARCH}" -O /usr/local/bin/ttyd
chmod +x /usr/local/bin/ttyd
ttyd --version
If you used this fallback, the binary path is /usr/local/bin/ttyd instead of /usr/bin/ttyd — adjust the systemd unit in Step 2 accordingly.
2
Create a systemd Service for ttyd

Auto-start on boot, restart on crash, control via systemctl:

bash · create unit file
cat > /etc/systemd/system/ttyd.service << 'EOF'
[Unit]
Description=ttyd web terminal (host bash)
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/root
# --writable    allow input (not read-only viewer)
# -p 7681       listen on port 7681
# -t fontSize   bigger font for browser readability
# -t titleFixed set browser tab title
# bash          shell to expose
ExecStart=/usr/bin/ttyd --writable -p 7681 -t fontSize=14 -t titleFixed=pocketcode.in bash
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

Enable and start it:

bash
systemctl daemon-reload
systemctl enable --now ttyd
systemctl status ttyd | head -5

Expected status: Active: active (running).

Quick local test:

bash · sanity check
ss -ltnp | grep 7681      # should show LISTEN 0.0.0.0:7681
curl -sI http://127.0.0.1:7681 | head -1     # expected: HTTP/1.1 200 OK
💡Why bind to 0.0.0.0 not 127.0.0.1? Caddy runs in a Docker container on the ai-stack bridge network. When it reaches out to the host, its source IP is the bridge gateway, not 127.0.0.1. Binding only to loopback would reject those connections. We lock down external access via UFW in Step 3 instead.
3
Open UFW for Docker Bridges (and Block Everyone Else)

Now that ttyd listens on every interface, we need UFW rules that (a) let our two Docker bridges reach it and (b) block public internet access on port 7681.

Part A — Detect your bridge subnets (Docker assigns these dynamically — they vary per server):

bash · find Docker subnets
DOCKER_BRIDGE_SUBNET=$(docker network inspect bridge --format '{{(index .IPAM.Config 0).Subnet}}')
AI_STACK_SUBNET=$(docker network inspect ai-stack --format '{{(index .IPAM.Config 0).Subnet}}')
echo "docker0:  $DOCKER_BRIDGE_SUBNET"
echo "ai-stack: $AI_STACK_SUBNET"

Typical output: docker0: 172.17.0.0/16, ai-stack: 172.18.0.0/16. Caddy lives on ai-stack so that's the rule that actually matters.

Part B — Add the rules (uses the variables from Part A):

bash
ufw allow from "$DOCKER_BRIDGE_SUBNET" to any port 7681 proto tcp comment 'ttyd from docker0'
ufw allow from "$AI_STACK_SUBNET"      to any port 7681 proto tcp comment 'ttyd from ai-stack'
ufw allow from 127.0.0.1               to any port 7681 proto tcp comment 'ttyd from localhost'
ufw deny  7681 comment 'block public ttyd access'
ufw reload

Part C — Verify rule order (deny MUST be last, otherwise it matches first and swallows the allowed traffic):

bash
ufw status numbered | grep 7681

Expected order:

expected output
[N]   7681/tcp  ALLOW IN  172.17.0.0/16    # ttyd from docker0
[N+1] 7681/tcp  ALLOW IN  172.18.0.0/16    # ttyd from ai-stack
[N+2] 7681/tcp  ALLOW IN  127.0.0.1        # ttyd from localhost
[N+3] 7681      DENY IN   Anywhere         # block public ttyd access
[N+4] 7681 (v6) DENY IN   Anywhere (v6)    # block public ttyd access
⚠️If a DENY rule shows up before the ALLOWs (because you added them in the wrong order earlier), UFW evaluates top-to-bottom and stops at the first match — the DENY swallows your traffic. Fix: ufw delete <deny-line-number>, then ufw deny 7681 ... again — that re-adds it at the bottom.
4
Update Caddy to Reach the Host on the Right Bridge

Caddy needs the magic hostname host.docker.internal defined and pointing at the ai-stack bridge gateway (not Docker's default docker0 bridge). Docker's host-gateway shortcut defaults to docker0 — which doesn't work here because Caddy is on a different bridge.

Edit run-caddy.sh:

bash · edit Caddy run script
nano ~/ai-stack/caddy/run-caddy.sh

Replace the entire file with this version. The preamble auto-detects the ai-stack gateway IP so the value stays correct even if Docker ever reassigns the subnet:

bash · ~/ai-stack/caddy/run-caddy.sh
#!/bin/bash
# Auto-detect ai-stack bridge gateway IP — that's the host's address on the
# network Caddy actually lives on, so reverse_proxy can reach host services.
AI_STACK_GW=$(docker network inspect ai-stack --format '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null)
[ -z "$AI_STACK_GW" ] && AI_STACK_GW="172.18.0.1"   # fallback if network missing
echo "Caddy reaches host via host.docker.internal → $AI_STACK_GW"

docker run -d \
  --name caddy \
  --network ai-stack \
  --restart unless-stopped \
  -p 80:80 \
  -p 443:443 \
  -p 443:443/udp \
  --add-host=host.docker.internal:${AI_STACK_GW} \
  -e AWS_ACCESS_KEY_ID="PASTE_ACCESS_KEY_ID" \
  -e AWS_SECRET_ACCESS_KEY="PASTE_SECRET_ACCESS_KEY" \
  -e AWS_REGION="us-east-1" \
  -v ~/ai-stack/caddy/Caddyfile:/etc/caddy/Caddyfile \
  -v ~/ai-stack/setup-page:/srv/setup-page:ro \
  -v ~/ai-stack/docs-page:/srv/docs-page:ro \
  -v caddy_data:/data \
  -v caddy_config:/config \
  caddy-route53:latest
✏️Preserve your AWS credentials: the PASTE_ACCESS_KEY_ID / PASTE_SECRET_ACCESS_KEY placeholders should be replaced with the values you used in Tab 14 Step 10. If you don't remember them, run cat ~/ai-stack/caddy/run-caddy.sh.bak first (or check your existing running script before overwriting).

Save: Ctrl+O, Enter, Ctrl+X.

Recreate Caddy — the --add-host flag is set at container creation time, so a plain caddy reload won't pick it up:

bash
docker stop caddy && docker rm caddy
bash ~/ai-stack/caddy/run-caddy.sh

Verify the mapping landed:

bash · sanity check from inside Caddy
docker exec caddy getent hosts host.docker.internal
# expected: 172.18.0.1   host.docker.internal   (your ai-stack gateway)

docker exec caddy wget -qO- --timeout=3 http://host.docker.internal:7681 -O /dev/null && echo "✓ Caddy can reach host ttyd"

If the second command prints ✓ Caddy can reach host ttyd, the networking is correct. Move on to Step 5.

5
Add Caddyfile Block for terminal.pocketcode.in
bash · edit Caddyfile
nano ~/ai-stack/caddy/Caddyfile

Add this block anywhere (the auth_gate snippet from Tab 18 protects it):

caddyfile · add this block
# Web terminal — host-native ttyd via systemd
terminal.pocketcode.in {
    import auth_gate
    reverse_proxy host.docker.internal:7681
}

Save, then reload Caddy (zero downtime — config-only change):

bash
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
💡Caddy handles WebSocket automatically. ttyd uses WebSocket for the live terminal stream — Caddy's reverse_proxy handles the protocol upgrade natively. auth_gate only runs on the initial HTTP request; the persistent WebSocket then streams without re-auth.
6
Wait for Cert Acquisition
bash · watch cert acquisition
docker logs caddy -f 2>&1 | grep -E "terminal.pocketcode|certificate obtained"

~30-60 seconds: certificate obtained successfully identifier=terminal.pocketcode.in. Press Ctrl+C to exit the log tail.

7
Visit terminal.pocketcode.in

Open in browser (sign in at pocketcode.in first if you're not already):

URL
https://terminal.pocketcode.in

You should land on a terminal showing your actual VPS hostname:

expected prompt
root@srv1234:~#

Try a host-only thing to confirm you're really on the host (these would fail inside a container):

bash · inside web terminal
hostname                # your VPS hostname, not a container ID
systemctl status ttyd   # systemd works
apt install -y htop     # can install host packages
htop                    # see ALL processes, not just one container's
⌨️Useful shortcuts inside ttyd:
  • Ctrl+Shift+C / Ctrl+Shift+V — copy/paste (browsers reserve plain Ctrl+C/V)
  • Right-click — context menu with paste option
  • Ctrl+C inside the terminal still works for interrupting commands
  • ttyd auto-reconnects on brief connection drops
📦Recommended packages to install once (you're root, no sudo needed):
bash · convenience tools
apt install -y htop btop ncdu jq tree tmux net-tools dnsutils
Persists across reboots since they're installed on the host, not in a container.
8
Update Management Scripts (systemd-aware)

Since ttyd is a host service, your management scripts should check it via systemctl instead of docker. The most important detail: don't stop ttyd as part of ai-stop — you'll use the web terminal to manage the rest of the stack, so it should stay up.

start-all.sh — replace any Docker-based terminal block with this systemd check, placed near the end (after Caddy):

bash · in start-all.sh
# Verify host-native ttyd is running (terminal.pocketcode.in)
log "Checking ttyd systemd service..."
if systemctl is-active --quiet ttyd; then
  ok "ttyd is active"
else
  systemctl start ttyd && ok "ttyd started"
fi

stop-all.sh — make sure there is NO stop_if_running terminal line. ttyd should keep running so you can use the web terminal to bring the stack back up. If you want a way to stop it for maintenance, do it explicitly: systemctl stop ttyd.

diagnose.sh — remove terminal from the Docker services array (it's no longer a container), then add a "Host Services" section at the end:

bash · in diagnose.sh
header "Host Services"
if systemctl is-active --quiet ttyd; then
  echo -e "  ${G}● UP${NC}       ttyd (web terminal)"
else
  echo -e "  ${R}● DOWN${NC}     ttyd (web terminal)"
fi

Also add a connectivity test alongside the existing test_endpoint calls:

bash · in diagnose.sh
test_endpoint "Web Terminal" "https://terminal.pocketcode.in"

Caddy's auth_gate returns a 302 redirect to the login page for unauthenticated requests — curl -sf treats that as success, so the test passes when the route is wired correctly.

💡Day-to-day ttyd commands (run from SSH or the web terminal itself):
CommandWhat it does
systemctl status ttydShow if it's running and recent log lines
systemctl restart ttydRestart — clears any stuck sessions
systemctl stop ttydStop (terminal.pocketcode.in will return 502 until restart)
journalctl -u ttyd -fTail logs in real time, Ctrl+C to exit
9
(Optional) Add Terminal Card to Gateway Homepage

The bundled home.html already includes a Terminal card. If you customized your own, add this inside the .grid div:

html · card snippet
<a href="https://terminal.pocketcode.in" target="_blank" rel="noopener" class="card c-terminal">
  <div class="card-icon">🖥️</div>
  <div class="card-name">Web Terminal</div>
  <div class="card-desc">Browser-based host shell. Full root, full Docker, full apt. Manage the stack from any device.</div>
  <div class="card-foot"><span>terminal.pocketcode.in</span><span class="arrow">→</span></div>
</a>

CSS class:

css
.c-terminal::before { background: var(--amber); }

Rebuild gateway to pick up the change: bash ~/ai-stack/auth-gateway/run-auth.sh

🔐
Auth Gateway
// Single-sign-on style login for all services · Node.js + Caddy forward_auth · pocketcode.in homepage
💡What this builds: A small Node.js service running at pocketcode.in that serves a login page + service-card homepage. Caddy's forward_auth calls the gateway's /verify endpoint on every request to a protected subdomain. Missing/expired cookie → user is redirected to pocketcode.in/login. The setup.pocketcode.in subdomain stays public.
⚠️Honest scope caveat: Services with their own login (Sim.ai, n8n, Open WebUI, OpenClaw, pgAdmin) will still ask for their individual login once behind the gateway. Their session cookies persist for ~30 days, so it feels close to true SSO after the first visit to each. Services without their own auth (Ollama, OpenRouter, Qdrant) become fully gated by the gateway alone. Step 8 (Optional) shows how to disable per-service auth where possible for a more seamless experience.
📦Bundle: The auth-gateway/ folder in your downloaded ZIP contains all the source files (server.js, Dockerfile, package.json, public/login.html, public/home.html, .env.example, run-auth.sh). You'll upload that folder to your server and build the image there.
🔀Two paths to the same destination:
Path A — This tab (faster): Upload the bundled auth-gateway/ folder via scp, build the image, configure, launch. Steps 1-10 below.
Path B — Tab 20 (no download): Create each file directly on the server with nano, paste the code from inline blocks. Same end result, slower but no need for the ZIP. Useful if you want to read/customize before deploying.
Both tabs land you at the same Step 5 (Caddyfile updates) after the gateway is running.
1
Verify auth-gateway Folder Exists (from Tab 15 bundle)
Already deployed. Tab 15 Step 1 extracted the bundle and placed ~/ai-stack/auth-gateway/ on your server with run-auth.sh already executable. This tab configures it and brings it up. If you skipped Tab 14, go back and run Step 1 of Tab 15 first — OR use Tab 20 (Source Code) to create each file manually with nano.

Verify the folder is in place:

bash · on server
ls -la ~/ai-stack/auth-gateway/
ls -la ~/ai-stack/auth-gateway/public/

Expected files: server.js, package.json, Dockerfile, run-auth.sh (executable), .env.example, public/login.html, public/home.html.

If anything is missing, go back to Tab 15 Step 1 and re-run the extraction commands.

2
Generate JWT Secret & Password Hash

Generate a random JWT secret (used to sign session cookies):

bash · generate JWT secret
openssl rand -hex 32

Copy the output (64-char hex string) — you'll paste it in Step 3.

Generate a bcrypt hash of your password:

bash · generate password hash (replace YOUR_PASSWORD)
docker run --rm node:20-alpine sh -c \
  "npm i bcryptjs >/dev/null 2>&1 && node -e \"console.log(require('bcryptjs').hashSync('YOUR_PASSWORD', 12))\""

Output looks like: $2a$12$N7K8.... Copy that whole string — you'll paste it in Step 3.

🔒Pick a strong password — this is the single gate to your entire AI stack. 16+ characters, mix of types. The bcrypt hash protects it from disk-read attacks (only the hash is stored, never the plaintext).
3
Create the .env File
bash · on server
cd ~/ai-stack/auth-gateway
cp .env.example .env
nano .env

Fill in your values (paste the secret and hash from Step 2):

env
JWT_SECRET=PASTE_64_CHAR_HEX_HERE
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH=$2a$12$PASTE_BCRYPT_HASH_HERE
COOKIE_DOMAIN=.pocketcode.in
PORT=7000
✏️Replace:
PlaceholderWhat to put
JWT_SECRET64-char hex from openssl rand -hex 32
AUTH_USERNAMEPick any username (defaults to admin)
AUTH_PASSWORD_HASHFull bcrypt hash starting with $2a$12$
COOKIE_DOMAINLeading dot is intentional — shares cookie across all subdomains

Save (Ctrl+O, Enter, Ctrl+X). Then lock down permissions since this file holds the hash:

bash
chmod 600 ~/ai-stack/auth-gateway/.env
4
Build & Launch the Gateway Container
bash · make script executable, then run
chmod +x ~/ai-stack/auth-gateway/run-auth.sh
bash ~/ai-stack/auth-gateway/run-auth.sh

First run builds the image (~30 seconds — npm install + node:20-alpine layers). Subsequent runs use cached layers and start in <5 seconds.

Verify the container is healthy:

bash · check status
docker ps --filter "name=auth-gateway" --format "{{.Status}}\t{{.Names}}"
docker logs auth-gateway --tail 10

Expected log output:

expected output
pocketcode auth gateway listening on :7000
  username:       admin
  cookie domain:  .pocketcode.in
  session TTL:    7 days

Test the /verify endpoint locally (no cookie → 401, that's correct):

bash · test endpoint
docker exec auth-gateway wget -qO- -S http://127.0.0.1:7000/verify 2>&1 | head -3
docker exec auth-gateway wget -qO- http://127.0.0.1:7000/health

First returns 401 (no cookie, correct). Second returns OK.

5
Update Caddyfile — Add Gateway & Auth Snippet
bash · edit Caddyfile
nano ~/ai-stack/caddy/Caddyfile

Part A: At the very top of the file (before any subdomain blocks), add the reusable auth snippet:

caddyfile · snippet (add at top)
# Reusable auth gate — every protected subdomain imports this.
# Flow:
#   1. Caddy calls auth-gateway:7000/verify with the user's cookies
#   2. If 200 → request continues to the upstream service
#   3. If 401 → @denied matcher fires, browser is redirected to login
#      with ?return= set to the original URL so post-login lands there
(auth_gate) {
    forward_auth auth-gateway:7000 {
        uri /verify
        @denied status 401
        handle_response @denied {
            redir https://pocketcode.in/login?return={scheme}://{host}{uri} 302
        }
    }
}
💡The (auth_gate) snippet is reusable — each protected subdomain block imports it with one line instead of repeating the forward_auth config. The 302 (vs 301) on the login redirect is intentional: it's temporary (the user might log in and come back), so browsers won't cache it.
⚠️Do NOT add copy_headers Cookie here. It looks like a useful directive but in forward_auth context it deletes the Cookie header from the upstream request and only re-sets it if the auth response returned a Cookie header (which /verify doesn't). Net effect: every protected service receives cookie-less requests, can't see its own session token, and infinite-loops users back to its login page. We learned this the hard way — leave it off.

Part B: Add the apex domain and www redirect at the top of the file too:

caddyfile · apex + www redirect
# Apex — the gateway homepage and login
pocketcode.in {
    reverse_proxy auth-gateway:7000
}

# www → apex redirect (permanent 301)
# Browser cache hint: clients will remember this redirect for ~1 year
www.pocketcode.in {
    redir https://pocketcode.in{uri} permanent
}
💡Why 301 (permanent) for www? Browsers cache 301s aggressively (often for a year+). After the first visit, the browser short-circuits the redirect locally — typing www.pocketcode.in goes straight to pocketcode.in without a server roundtrip. This is the same SEO/UX best practice every major site uses.
🌐Wildcard cert handles www automatically. The Let's Encrypt wildcard cert *.pocketcode.in from Tab 14 already covers www.pocketcode.in (single-level subdomain). No extra cert acquisition needed. Two-level www variants (e.g. www.sim.pocketcode.in) are not covered by the wildcard and would require additional DNS records + certs — but those aren't typical user input, so no need to plan for them.

Part C: Update each existing service block to import auth_gate. Find each block and add the import line right after the opening brace:

caddyfile · pattern for each protected service
sim.pocketcode.in {
    import auth_gate
    reverse_proxy sim-simstudio-1:3000
}

sim-realtime.pocketcode.in {
    import auth_gate
    reverse_proxy sim-realtime-1:3002
}

chat.pocketcode.in {
    import auth_gate
    reverse_proxy open-webui:8080
}

n8n.pocketcode.in {
    import auth_gate
    reverse_proxy n8n:5678
}

openclaw.pocketcode.in {
    import auth_gate
    reverse_proxy openclaw:8080
}

pgadmin.pocketcode.in {
    import auth_gate
    reverse_proxy pgadmin:80
}

# Web Terminal — host-native ttyd (systemd), via host.docker.internal
terminal.pocketcode.in {
    import auth_gate
    reverse_proxy host.docker.internal:7681
}

# Ollama, Qdrant, OpenRouter — REMOVE old basic_auth blocks
# Replace them with these clean versions (the gateway now handles auth):
ollama.pocketcode.in {
    import auth_gate
    reverse_proxy ollama:11434
}

qdrant.pocketcode.in {
    import auth_gate
    reverse_proxy qdrant:6333
}

openrouter.pocketcode.in {
    import auth_gate
    reverse_proxy openrouter-proxy:4000
}
⚠️Important: For ollama, qdrant, and openrouter, remove the old basic_auth blocks from Tab 14. The gateway replaces that layer — keeping both means double login.

Part D: Leave setup.pocketcode.in AND docs.pocketcode.in alone — both stay PUBLIC (no import auth_gate):

caddyfile · public pages (unchanged)
setup.pocketcode.in {
    root * /srv/setup-page
    file_server
    encode gzip
}

docs.pocketcode.in {
    root * /srv/docs-page
    file_server
    encode gzip
}

Save (Ctrl+O, Enter, Ctrl+X).

6
Reload Caddy & Acquire Apex Certificate
bash · reload Caddy (no restart needed)
docker exec caddy caddy reload --config /etc/caddy/Caddyfile

Watch Caddy obtain certs for pocketcode.in and www.pocketcode.in:

bash · watch cert acquisition
docker logs caddy -f 2>&1 | grep -E "pocketcode.in|certificate obtained"

Within ~60 seconds you'll see certificate obtained successfully for both. Press Ctrl+C to exit the log tail.

Confirm the wildcard DNS handles both:

bash · DNS check
dig +short pocketcode.in
dig +short www.pocketcode.in

Both should return your server IP. (If pocketcode.in is empty — your apex needs its own A record at Route 53, separate from the wildcard *.pocketcode.in.)

7
Test the Full Login Flow

Open in your browser (use incognito for a clean cookie test):

URLs to test in order
https://pocketcode.in       # should show login form
https://www.pocketcode.in   # should 301 → pocketcode.in
https://sim.pocketcode.in   # should redirect to login (no session)
https://setup.pocketcode.in # should load directly (public)

After entering valid credentials at pocketcode.in:

  1. You land on the homepage with the service-card grid
  2. Click any card → opens the service in a new tab
  3. The cookie travels with each request (cookie domain .pocketcode.in)
  4. Services without their own login (Ollama, Qdrant, OpenRouter) work immediately
  5. Services with their own login (Sim.ai, n8n, Open WebUI, OpenClaw, pgAdmin) show their login page once — that session persists separately
🔁If a service still shows a basic_auth prompt after the Caddyfile update, you forgot to remove its basic_auth block in Step 5 Part C. Re-edit the Caddyfile and reload.
8
Configure Auto-SSO to Underlying Services
🔐What this gives you: after you log in at pocketcode.in, hidden iframes silently log you in to Sim.ai, Open WebUI, and n8n using stored credentials. Click any service card → you're already inside, no second login. A watchdog refreshes the iframes every 2 minutes and on tab focus, so any accidental logout (clicking a service's own "Logout" button, session expiry, etc.) gets undone within seconds of returning to pocketcode.in.
⚠️Trade-off: Your service passwords are stored in .env on the server. That's fine for a single-user lab on your own VPS, but means a server compromise = those passwords exposed. Make sure your VPS root password is strong and SSH key auth is enforced. Skip this step if you'd rather keep credentials out of the file system.
💡Three services are SSO-able: Sim.ai, Open WebUI, and n8n — these have JSON login APIs that the gateway can drive programmatically. OpenClaw uses device pairing (not credential-based) and pgAdmin requires CSRF tokens; those stay manual-login but their own sessions persist 30+ days so it's a one-time annoyance.

Part A — Gather your service credentials. These are the admin email/password you set when first creating accounts in each service (during Tabs 3, 7, 8 respectively). If you forgot any, log in to the service once and reset the password from its own UI.

ServiceWhere you set the passwordWhat we need
Sim.aiTab 8 Step 6 — Sign up at sim.pocketcode.in (after HTTPS) or 127.0.0.1:3000 (before)email + password
Open WebUITab 3 Step 5 — first signup at chat.pocketcode.in becomes adminemail + password
n8nTab 7 Step 4 — owner account created on first n8n visitemail + password

Part B — Add credentials to .env:

bash · edit gateway env
nano ~/ai-stack/auth-gateway/.env

Add a single-line JSON value with each service's credentials. Replace the email + password placeholders with your real values:

env · add this line
SERVICE_CREDS_JSON={"sim":{"email":"admin@example.com","password":"YOUR_SIM_PW"},"chat":{"email":"admin@example.com","password":"YOUR_OPENWEBUI_PW"},"n8n":{"email":"admin@example.com","password":"YOUR_N8N_PW"}}
✏️Replace: three email addresses + three passwords with the actual values you used when creating each account. They can be the same or different per service. Keep it as a single JSON line — no trailing comma, single-line value, double quotes only.

Part C — Rebuild the gateway with new env:

bash · restart gateway
bash ~/ai-stack/auth-gateway/run-auth.sh

Verify SSO is enabled in the startup log:

bash · check log
docker logs auth-gateway --tail 10

Expected line: auto-SSO: enabled for sim, chat, n8n. If you see disabled (set SERVICE_CREDS_JSON in .env to enable), the JSON was malformed — re-check Part B.

Part D — Quick test from the terminal:

bash · server-side login test
# From the host, ping each service's login API directly to confirm
# your credentials are correct (this is what the gateway does internally)
docker exec auth-gateway sh -c 'wget -qO- --post-data "{\"email\":\"admin@example.com\",\"password\":\"YOUR_SIM_PW\"}" \
  --header "Content-Type: application/json" \
  http://sim-simstudio-1:3000/api/auth/sign-in/email' | head -c 200

Expected: a JSON response like {"redirect":false,"token":"..."}. If you get an error or 401, the credentials in SERVICE_CREDS_JSON don't match what's in the service's database. Either fix the env value or reset the service password.

9
Add /_sso_init Route to Each SSO Subdomain in Caddyfile

For each SSO-enabled service, Caddy needs a route at /_sso_init that proxies back to the gateway with a path rewrite. The gateway recognizes the service from the path and runs the login.

bash · edit Caddyfile
nano ~/ai-stack/caddy/Caddyfile

Find the existing blocks for sim.pocketcode.in, chat.pocketcode.in, and n8n.pocketcode.in. Replace each with the version below. The structure is important: /_sso_init must be in its own handle block without import auth_gate (the gateway does its own session check on /sso-init/:svc), and the catch-all handle { } must contain the import auth_gate so it only gates real service traffic — not the SSO bootstrap.

caddyfile · replace the 3 sub-blocks
sim.pocketcode.in {
    handle /_sso_init {
        reverse_proxy auth-gateway:7000 {
            rewrite /sso-init/sim
        }
    }
    handle {
        import auth_gate
        reverse_proxy sim-simstudio-1:3000
    }
}

chat.pocketcode.in {
    handle /_sso_init {
        reverse_proxy auth-gateway:7000 {
            rewrite /sso-init/chat
        }
    }
    handle {
        import auth_gate
        reverse_proxy open-webui:8080
    }
}

n8n.pocketcode.in {
    handle /_sso_init {
        reverse_proxy auth-gateway:7000 {
            rewrite /sso-init/n8n
        }
    }
    handle {
        import auth_gate
        reverse_proxy n8n:5678
    }
}
💡How this works: The browser loads https://sim.pocketcode.in/_sso_init from a hidden iframe on the home page. Caddy matches handle /_sso_init first and proxies straight to auth-gateway:7000/sso-init/sim with the original Cookie header intact (no auth_gate stripping). The gateway verifies the pocketcode_session JWT itself, then makes a server-side POST to sim-simstudio-1:3000/api/auth/sign-in/email with the stored credentials, captures the response's Set-Cookie, and forwards it back. Since the response is served by Caddy at sim.pocketcode.in, the browser stores the cookie scoped to that subdomain. Everything else (the actual app pages) hits the catch-all handle { } which runs auth_gate normally.

Save (Ctrl+O, Enter, Ctrl+X), then reload Caddy:

bash · reload Caddy
docker exec caddy caddy reload --config /etc/caddy/Caddyfile

Visual test: Sign out of pocketcode.in, then sign back in. Open browser DevTools → Application → Cookies (or Storage panel). You should see cookies set for each of: sim.pocketcode.in, chat.pocketcode.in, n8n.pocketcode.in within a second or two of the homepage loading. The status pill in the top right of the gateway homepage shows ✓ 3 services synced when all succeed.

Functional test: Click the Sim.ai card. You should land directly on the dashboard, no login prompt. Same for the Open WebUI and n8n cards. Click each service's own "Logout" button. Return to the pocketcode homepage and wait ~2 minutes (or just click anywhere to trigger the focus event). The iframes refresh, you're silently re-logged in everywhere. Click the cards again → still logged in.

🐛Troubleshooting:
  • Status pill shows "sync failed": open one of the SSO iframes directly in a new tab — go to https://sim.pocketcode.in/_sso_init. Check the response body. Usually it's a credential mismatch (fix in .env) or the service isn't reachable from the gateway container (check docker network inspect ai-stack).
  • Pill shows "2/3 services synced": one specific service failed. Check docker logs auth-gateway --tail 20 for which one and why.
  • Pill never appears: SERVICE_CREDS_JSON probably empty. Check docker logs auth-gateway --tail 5 for the boot line.
10
Service Launcher — Iframe Wrapper for Session Monitoring

Why this exists: HTTP is request-response. Caddy's forward_auth only fires on new HTTP requests. For pages already loaded — SPAs holding state in memory, long-lived WebSockets (like the terminal's ttyd) — no fresh HTTP requests happen until the user clicks something. So if you log out at pocketcode.in in tab B, tab A keeps working until tab A itself makes a new request. The terminal is the worst case: once the ttyd WebSocket is established, you can type forever after logout.

The fix: launch every service via pocketcode.in/app/<svc> — a thin wrapper page that embeds the service in a full-window iframe and polls /verify every 15 seconds (and on every tab focus). When the gateway returns 401, the wrapper navigates the whole window to /login?return=<current launcher URL>. Whole-window navigation destroys the iframe, killing any open WebSocket. After re-login, returnTo brings the user back to the same launcher and the iframe reloads from scratch. This is what commercial SSO portals (Okta dashboard, Salesforce app tabs, Citrix Workspace) do.

Part A: Soften iframe-blocking headers in Caddy. Some services send X-Frame-Options: SAMEORIGIN or CSP frame-ancestors 'self', which blocks the iframe. Edit each gated subdomain block in ~/ai-stack/caddy/Caddyfile to strip those headers. For the three SSO-enabled services (sim, chat, n8n), update the catch-all handle { } from Step 9 to include two header directives:

caddyfile · update sim, chat, n8n catch-all blocks
sim.pocketcode.in {
    handle /_sso_init {
        reverse_proxy auth-gateway:7000 {
            rewrite /sso-init/sim
        }
    }
    handle {
        import auth_gate
        header -X-Frame-Options
        header Content-Security-Policy "frame-ancestors [^;]+" "frame-ancestors 'self' https://pocketcode.in"
        reverse_proxy sim-simstudio-1:3000
    }
}

chat.pocketcode.in {
    handle /_sso_init {
        reverse_proxy auth-gateway:7000 {
            rewrite /sso-init/chat
        }
    }
    handle {
        import auth_gate
        header -X-Frame-Options
        header Content-Security-Policy "frame-ancestors [^;]+" "frame-ancestors 'self' https://pocketcode.in"
        reverse_proxy open-webui:8080
    }
}

n8n.pocketcode.in {
    handle /_sso_init {
        reverse_proxy auth-gateway:7000 {
            rewrite /sso-init/n8n
        }
    }
    handle {
        import auth_gate
        header -X-Frame-Options
        header Content-Security-Policy "frame-ancestors [^;]+" "frame-ancestors 'self' https://pocketcode.in"
        reverse_proxy n8n:5678
    }
}

For the simpler non-SSO blocks (openclaw, pgadmin, qdrant, terminal, ollama, openrouter), one line is enough — most don't send these headers anyway, but it's defensive:

caddyfile · add header -X-Frame-Options to remaining gated blocks
openclaw.pocketcode.in {
    import auth_gate
    header -X-Frame-Options
    reverse_proxy openclaw:18789
}

pgadmin.pocketcode.in {
    import auth_gate
    header -X-Frame-Options
    reverse_proxy pgadmin:80
}

qdrant.pocketcode.in {
    import auth_gate
    header -X-Frame-Options
    reverse_proxy qdrant:6333
}

terminal.pocketcode.in {
    import auth_gate
    header -X-Frame-Options
    reverse_proxy host.docker.internal:7681
}

ollama.pocketcode.in {
    import auth_gate
    header -X-Frame-Options
    reverse_proxy ollama:11434
}

openrouter.pocketcode.in {
    import auth_gate
    header -X-Frame-Options
    reverse_proxy openrouter-proxy:4000
}

Save and reload Caddy:

bash · reload Caddy
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile

Part B: Rebuild the auth-gateway with the launcher route + app.html. The gateway bundle includes both — they were already extracted in Tab 15's Step 1. A normal rebuild picks them up:

bash · rebuild gateway image
bash ~/ai-stack/auth-gateway/run-auth.sh
sleep 4
docker logs auth-gateway --tail 10

The boot log should print the standard auto-SSO: enabled for sim, chat, n8n line. The launcher itself is silent — no log line at boot, it just registers the route.

Part C: Verify the launcher. First without a cookie (should redirect to login), then with one (should serve the wrapper):

bash · smoke test
curl -sI https://pocketcode.in/app/terminal | head -3
# Expect: HTTP/2 302 (redirect to /login)

curl -sI -H "Cookie: pocketcode_session=$(echo paste-real-jwt-here)" https://pocketcode.in/app/terminal | head -3
# Expect: HTTP/2 200

Functional test in browser:

  1. Open https://pocketcode.in/, log in
  2. Click the Terminal card. URL bar should read pocketcode.in/app/terminal. Terminal renders inside the wrapper.
  3. In a separate tab, go to https://pocketcode.in/logout
  4. Switch back to the terminal tab. Within ~15 seconds (or instantly if you focus it), a "Session expired" overlay appears with a 3-second countdown.
  5. You're redirected to /login?return=.... Log in. You land back on /app/terminal, fresh shell.

Repeat with Sim, n8n, Open WebUI, pgAdmin — same behavior on all.

📍Qdrant is the exception. v1.6 moves Qdrant out of the launcher iframe because its Web UI makes cross-origin subresource fetches that fail inside the wrapper. Step 16 below replaces this section's simple qdrant.pocketcode.in Caddyfile block (and the home.html Qdrant tile href) with the v1.6 direct-subdomain + response-injection pattern. If you're building fresh from this guide, skip Qdrant testing here — it'll work end-to-end after Step 16.
💡What this trades off: the URL bar shows pocketcode.in/app/<svc> instead of the service's own subdomain. Direct subdomain URLs (e.g. https://sim.pocketcode.in/workspace) still work for bookmarks and deep-links, but those tabs don't have the session-monitoring overlay — you'd only notice a logout once you click something. For most users, always going through the home-page cards is the right pattern.
⚠️Pure-API services (Ollama, OpenRouter) inside the launcher: their root endpoints return plain text or JSON, so the iframe just shows that one response — not very useful, but harmless. If you'd rather keep these as direct links, remove them from LAUNCH_SERVICES in server.js and edit home.html to point those cards back at the subdomain URL.

Part D: Update ai-doctor to verify the launcher. Open the diagnose script:

bash · edit diagnose.sh
nano ~/ai-stack/manage/diagnose.sh

Press Ctrl+W, type Host Services, Enter. You'll land on the existing "Host Services" header. Paste this new block immediately above the # Host services (not in Docker) comment:

bash · insert above # Host services line
# ═══ AUTH GATEWAY ═══
header "Auth Gateway"

# /verify without a cookie should return 401 (proves the endpoint is wired)
verify_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 https://pocketcode.in/verify 2>/dev/null)
if [ "$verify_status" = "401" ]; then
  echo -e "  ${G}✓${NC} /verify rejects unauthenticated requests (HTTP 401)"
else
  echo -e "  ${R}✗${NC} /verify returned HTTP $verify_status (expected 401)"
fi

# /app/ without a cookie should redirect to /login (HTTP 302)
launch_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 https://pocketcode.in/app/sim 2>/dev/null)
if [ "$launch_status" = "302" ]; then
  echo -e "  ${G}✓${NC} /app/:svc redirects unauthenticated to login (HTTP 302)"
elif [ "$launch_status" = "404" ]; then
  echo -e "  ${R}✗${NC} /app/:svc returns 404 — gateway image is pre-launcher, rebuild it:"
  echo -e "       ${DIM}bash ~/ai-stack/auth-gateway/run-auth.sh${NC}"
else
  echo -e "  ${Y}⚠${NC}  /app/:svc returned HTTP $launch_status (expected 302)"
fi

# app.html must be inside the container
if docker exec auth-gateway test -f /app/public/app.html 2>/dev/null; then
  echo -e "  ${G}✓${NC} app.html present in gateway container"
else
  echo -e "  ${R}✗${NC} app.html MISSING from gateway container — rebuild needed"
fi

# /sso-services should require auth (401 without cookie)
sso_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 https://pocketcode.in/sso-services 2>/dev/null)
if [ "$sso_status" = "401" ]; then
  echo -e "  ${G}✓${NC} /sso-services requires authentication (HTTP 401)"
else
  echo -e "  ${Y}⚠${NC}  /sso-services returned HTTP $sso_status (expected 401)"
fi

# Confirm SSO is enabled in the gateway env
sso_enabled=$(docker logs auth-gateway 2>&1 | grep -c "auto-SSO:       enabled for")
if [ "$sso_enabled" -gt 0 ]; then
  enabled_svcs=$(docker logs auth-gateway 2>&1 | grep "auto-SSO:       enabled for" | tail -1 | sed 's/.*enabled for //')
  echo -e "  ${G}✓${NC} Auto-SSO enabled for: ${DIM}${enabled_svcs}${NC}"
else
  echo -e "  ${Y}⚠${NC}  Auto-SSO disabled (SERVICE_CREDS_JSON empty or invalid)"
fi

Save (Ctrl+O, Enter, Ctrl+X) and run ai-doctor — you should see a new "Auth Gateway" section with five ✓ lines.

Part E: Annotate start-all.sh with the launcher tip (optional but informative). Open the start script:

bash · edit start-all.sh
nano ~/ai-stack/manage/start-all.sh

Press Ctrl+W, type pocketcode.in once, Enter. You'll land on a line like "Sign in at pocketcode.in once, then these all work:". Replace that single line with these two lines:

bash · replacement
echo -e "${DIM}  Tip: open services from pocketcode.in cards for session monitoring.${NC}"
echo -e "${DIM}  Direct URLs below also work (no auto-logout-on-other-tab):${NC}"

Save (Ctrl+O, Enter, Ctrl+X). Next ai-start run will show the new tip.

⚠️Careful: only replace the one "Sign in at pocketcode.in once..." line. If you append the new lines without removing the old one, your URL block will have stale duplicate text. After saving, run grep 'Tip: open services' ~/ai-stack/manage/start-all.sh — it should return exactly one line.
11
(Optional) Reduce Per-Service Logins

For a more seamless experience, you can disable some services' built-in auth since the gateway already protects access. Trade-off: anyone past the gateway has full access — fine for personal use, less so for shared instances.

ServiceHow to disable native authRecommendation
Open WebUIChange run script env to WEBUI_AUTH=false, then recreate container👍 Safe — personal use
n8nRemove N8N_BASIC_AUTH_* env vars (already gone if using cookie auth), or set N8N_BASIC_AUTH_ACTIVE=false👍 Safe
pgAdminSet PGADMIN_CONFIG_SERVER_MODE=False and PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED=False⚠️ Test thoroughly — may break server connection storage
Sim.aiNo clean way — better-auth is required for user accounts/workspaces❌ Keep its login
OpenClawToken-based, embedded in the launch URL; could pre-set via env but breaks isolation❌ Keep token

Example — disable Open WebUI auth:

bash · edit run script
nano ~/ai-stack/open-webui/run-open-webui.sh
# Change: -e WEBUI_AUTH=true → -e WEBUI_AUTH=false

docker stop open-webui && docker rm open-webui
bash ~/ai-stack/open-webui/run-open-webui.sh
⚠️Before disabling Open WebUI auth: Back up its chat history first — switching auth modes can sometimes invalidate existing users. docker volume inspect open-webui-data shows the volume path you can copy.
12
Update Management Scripts

Add auth-gateway to the start/stop/diagnose scripts so it's part of the normal lifecycle.

start-all.sh — add a launch block before Caddy:

bash · edit and add this block
nano ~/ai-stack/manage/start-all.sh
# Find # 12. Start Open WebUI ... block. After it (before Caddy), add:
bash · paste this
# 13. Start Auth Gateway (must be up before Caddy reloads)
log "Starting Auth Gateway..."
if docker ps -a --format '{{.Names}}' | grep -q '^auth-gateway$'; then
  docker start auth-gateway >/dev/null && ok "auth-gateway started"
else
  bash ~/ai-stack/auth-gateway/run-auth.sh >/dev/null && ok "auth-gateway created"
fi

stop-all.sh — add to the stop sequence after caddy:

bash · add this line in stop-all.sh after stop_if_running caddy
stop_if_running auth-gateway

diagnose.sh — add auth-gateway to the services array:

bash · find and replace the services array
services=(ollama openclaw n8n openrouter-proxy qdrant pgadmin sim-db-1 sim-redis-1 sim-realtime-1 sim-simstudio-1 open-webui caddy auth-gateway)

Test the full cycle:

bash · test management lifecycle
ai-stop && sleep 5 && ai-start
ai-doctor

All services including auth-gateway should show as healthy.

13
Updating the Homepage Later

To change the homepage (add a service card, tweak styling, etc.):

  1. Edit ~/ai-stack/auth-gateway/public/home.html on the server (or edit locally and scp up)
  2. Recreate the container so it picks up the change: bash ~/ai-stack/auth-gateway/run-auth.sh
  3. Hard-refresh browser (Cmd+Shift+R)
💡Why recreate? The HTML is COPYed into the image at build time. Editing the file on the host doesn't affect the running container's /app/public/ until the image is rebuilt. The run-auth.sh script does both (build + run).

Forgot your password? Just generate a new bcrypt hash (Step 2) and update AUTH_PASSWORD_HASH in ~/ai-stack/auth-gateway/.env, then restart: docker restart auth-gateway. The container reads env vars on each start.

Want a longer session? Edit the expiresIn: '7d' string in server.js (line with jwt.sign(...)). Use '30d' for a month, '1y' for a year. Then rebuild with run-auth.sh.

14
Custom Error Page — Friendly "Down" & "Lost" Pages

Out of the box, when a container is down (Sim crashed, Ollama OOM'd, etc.) Caddy returns a bare 502 page. When you typo a URL on the apex (pocketcode.in/dahsboard), the gateway 404s. Neither is helpful or fun. v1.4 adds a single themed error page that handles both, with the service's logo, a humor line, and a live console log of what Caddy actually saw.

🪄One page, two roles. auth-gateway/public/error.html reads its query string client-side and switches between two modes: /error?lost=1&from=<path> (friendly 404) and /error?service=<name>&code=<n> (service-down splash with that service's logo + color). The page is served by the gateway's /error route, which is public (no auth gate) so it's reachable even before login.

Part A: Verify the route & page exist.

bash · on server
# Both files were extracted by Tab 15 Step 1:
ls -la ~/ai-stack/auth-gateway/public/error.html
grep "app.get('/error'" ~/ai-stack/auth-gateway/server.js
grep "app.use((req, res) => {" ~/ai-stack/auth-gateway/server.js  # the catch-all

# Rebuild gateway so the route + page land in the image:
bash ~/ai-stack/auth-gateway/run-auth.sh

# Smoke test the public /error route directly (no auth needed):
curl -sI https://pocketcode.in/error?lost=1 | head -3
# Expect: HTTP/2 404 (lost mode sets a 404 status)

curl -sI "https://pocketcode.in/error?service=sim&code=502" | head -3
# Expect: HTTP/2 502 (service-down sets the service's status)

# And the catch-all → unknown apex paths bounce to /error:
curl -sI https://pocketcode.in/this-route-does-not-exist | head -3
# Expect: HTTP/2 302  Location: /error?lost=1&from=%2Fthis-route-...

Part B: Add handle_errors to each gated subdomain in the Caddyfile. This is what catches a container being down and serves the friendly page instead of Caddy's bare 502. Open the Caddyfile once and make 9 edits — same shape each time, only the service name changes.

bash · open Caddyfile
nano /root/ai-stack/caddy/Caddyfile

For each of the 9 sites below, do this 4-step loop in nano:

  1. Press Ctrl+W, paste the Find string, press Enter — nano jumps to the opening line of that site block.
  2. Press Down arrow repeatedly until you hit a line that's just } at column 0 (no leading spaces). That's the outermost closing brace. Inner }s for nested handle blocks are indented with 4 spaces, so they don't count.
  3. Once cursor is on that } line, press Home, then Enter — this pushes } down one line and leaves a blank line above. Press Up to land on that blank line.
  4. Paste the block for that site, exactly as shown (indentation is part of it).

When all 9 are done, save with Ctrl+O, Enter, Ctrl+X.

Edit 1 — sim

find string
sim.pocketcode.in {
block to paste
    handle_errors {
        rewrite * /error?service=sim&code={err.status_code}
        reverse_proxy auth-gateway:7000
    }

Edit 2 — chat

find string
chat.pocketcode.in {
block to paste
    handle_errors {
        rewrite * /error?service=chat&code={err.status_code}
        reverse_proxy auth-gateway:7000
    }

Edit 3 — n8n

find string
n8n.pocketcode.in {
block to paste
    handle_errors {
        rewrite * /error?service=n8n&code={err.status_code}
        reverse_proxy auth-gateway:7000
    }

Edit 4 — openclaw

find string
openclaw.pocketcode.in {
block to paste
    handle_errors {
        rewrite * /error?service=openclaw&code={err.status_code}
        reverse_proxy auth-gateway:7000
    }

Edit 5 — ollama

find string
ollama.pocketcode.in {
block to paste
    handle_errors {
        rewrite * /error?service=ollama&code={err.status_code}
        reverse_proxy auth-gateway:7000
    }

Edit 6 — openrouter

find string
openrouter.pocketcode.in {
block to paste
    handle_errors {
        rewrite * /error?service=openrouter&code={err.status_code}
        reverse_proxy auth-gateway:7000
    }

Edit 7 — qdrant

find string
qdrant.pocketcode.in {
block to paste
    handle_errors {
        rewrite * /error?service=qdrant&code={err.status_code}
        reverse_proxy auth-gateway:7000
    }

Edit 8 — pgadmin

find string
pgadmin.pocketcode.in {
block to paste
    handle_errors {
        rewrite * /error?service=pgadmin&code={err.status_code}
        reverse_proxy auth-gateway:7000
    }

Edit 9 — terminal

find string
terminal.pocketcode.in {
block to paste
    handle_errors {
        rewrite * /error?service=terminal&code={err.status_code}
        reverse_proxy auth-gateway:7000
    }

Sanity-check before reloading. Don't reload yet — first confirm the file is syntactically valid and that the new block landed in the right place 9 times:

bash · count handle_errors blocks
grep -c 'handle_errors {' /root/ai-stack/caddy/Caddyfile

Expected output: 9. If you see anything else, you missed a site or pasted twice into one — reopen and fix before continuing.

bash · validate Caddyfile syntax
docker exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile

Expected last line: Valid configuration. If validation fails, the error message names the line number — fix in nano and re-validate.

Reload Caddy. Zero downtime; running connections are not affected.

bash · reload Caddy
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile

A successful reload prints nothing and returns to the prompt.

⚠️Do NOT add handle_errors to the apex (pocketcode.in) block. The apex reverse-proxies to the auth-gateway itself, and if the gateway is down, the handle_errors rewrite would try to proxy to the same dead gateway — infinite failure. The gateway's own Express catch-all already handles unknown apex paths via /error?lost=1. If the gateway itself is down, you get a bare Caddy 502 on the apex — at which point ai-doctor on the host will tell you exactly what to fix.
📍Why each block needs its own line, not a snippet: Caddyfile's handle_errors directive uses placeholders that resolve at request time, but the service=<name> param is fixed per site block. You could DRY this up with a Caddy snippet that takes an argument, but the explicit form makes the routing obvious when debugging.

Part C: Test the service-down splash. Stop a container and verify the friendly page renders. The easiest test is in your browser — you're already logged in there, so the cookie travels automatically and Caddy actually reaches the (now-down) upstream, triggering handle_errors.

bash · browser test — works because your browser has the session cookie
# Stop sim-simstudio-1 temporarily:
docker stop sim-simstudio-1

# In a browser, visit https://sim.pocketcode.in/
# Expected: purple Sim.ai-themed splash with "Sim.ai is taking a nap.",
# console log of the failed connection, URL stays sim.pocketcode.in.

# Bring sim back up:
docker start sim-simstudio-1
⚠️Naked curl will return 302, not 502 — and that's correct behavior, not a bug. Without the session cookie, auth_gate fires first (sees no cookie → 401 → Caddy 302s to /login?return=...). The request never reaches the reverse_proxy line, so the upstream-down condition that triggers handle_errors never happens. To curl-test the error page, you need to send the gateway cookie too.

Curl test loop with session cookie. First, log in via curl and save the cookie to a temp file. Run this on the server:

bash · capture session cookie
# 1. Look up your gateway username (in case it isn't "admin")
USERNAME=$(grep '^AUTH_USERNAME=' ~/ai-stack/auth-gateway/.env | cut -d= -f2)
echo "username: $USERNAME"

# 2. Prompt for your gateway password without echoing it to the terminal
read -rs -p "Gateway password for ${USERNAME}: " PW; echo

# 3. Log in and capture the cookie to /tmp/pc.cookies
curl -s -c /tmp/pc.cookies \
  -X POST https://pocketcode.in/login \
  --data-urlencode "username=${USERNAME}" \
  --data-urlencode "password=${PW}" \
  -o /dev/null

# 4. Verify we got the cookie
if grep -q pocketcode_session /tmp/pc.cookies; then
  echo "✓ session cookie captured"
else
  echo "✗ login failed — check username/password and retry"
fi

unset PW

Now the test loop — stops each service, hits its subdomain with the cookie, asserts the error page rendered, restarts the service:

bash · automated test for 8 of 9 sites
for svc in sim:sim-simstudio-1 chat:open-webui n8n:n8n openclaw:openclaw \
           ollama:ollama openrouter:openrouter-proxy qdrant:qdrant \
           pgadmin:pgadmin; do
  sub="${svc%%:*}"
  cont="${svc##*:}"
  echo "── testing $sub (container: $cont) ──"
  docker stop "$cont" >/dev/null
  sleep 2
  code=$(curl -s -b /tmp/pc.cookies -o /dev/null -w "%{http_code}" "https://${sub}.pocketcode.in/")
  body=$(curl -s -b /tmp/pc.cookies "https://${sub}.pocketcode.in/" | grep -o 'service=[a-z]*' | head -1)
  echo "  HTTP code: $code   ·   error.html params: $body"
  docker start "$cont" >/dev/null
  sleep 3
done

rm -f /tmp/pc.cookies

Expected per site: HTTP code: 502 (or 503 for transient unhealthy state) and error.html params: service=<sub>. Both prove handle_errors caught the upstream failure and rewrote to the gateway's /error page with the right service param.

The terminal site is skipped from the loop because ttyd is a host systemd service, not a container. Test it manually:

bash · terminal site test
sudo systemctl stop ttyd
sleep 2
curl -s -b /tmp/pc.cookies -o /dev/null -w "%{http_code}\n" https://terminal.pocketcode.in/
# Expected: 502
sudo systemctl start ttyd

Part D: Test the lost (404) page. Just hit any nonexistent apex path:

browser / curl
# Browser:
# Visit https://pocketcode.in/whoknows — you should see the 404
# compass icon, "You are lost." headline, and one of the random
# humor lines. Press R to retry (goes home), H to home directly.

curl -sIL https://pocketcode.in/whoknows | grep -E '^(HTTP|location)'
# Expect: HTTP/2 302 → Location: /error?lost=1&from=%2Fwhoknows
#         HTTP/2 404 (the error page itself)

Part E: Add error-page checks to diagnose.sh. Open ~/ai-stack/manage/diagnose.sh and find the "Auth Gateway" section. Add two lines after the existing /sso-services check:

bash · add to diagnose.sh
# /error?lost=1 should serve the page with 404 status
err_lost=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "https://pocketcode.in/error?lost=1" 2>/dev/null)
if [ "$err_lost" = "404" ]; then
  echo -e "  ${G}✓${NC} /error?lost=1 serves friendly 404 (HTTP 404)"
else
  echo -e "  ${Y}⚠${NC}  /error?lost=1 returned HTTP $err_lost (expected 404) — rebuild gateway"
fi

# Catch-all → unknown apex path redirects to /error?lost=1
catchall=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "https://pocketcode.in/this-does-not-exist" 2>/dev/null)
if [ "$catchall" = "302" ]; then
  echo -e "  ${G}✓${NC} unknown apex path redirects to /error (HTTP 302)"
else
  echo -e "  ${Y}⚠${NC}  unknown apex path returned HTTP $catchall (expected 302)"
fi

# error.html present in container
if docker exec auth-gateway test -f /app/public/error.html 2>/dev/null; then
  echo -e "  ${G}✓${NC} error.html present in gateway container"
else
  echo -e "  ${R}✗${NC} error.html MISSING — rebuild gateway:"
  echo -e "      bash ~/ai-stack/auth-gateway/run-auth.sh"
fi
🎨Customizing the humor. Open ~/ai-stack/auth-gateway/public/error.html and find the SERVICES JS object near the bottom — each service has a humor string and the LOST_LINES array right below holds the 404 jokes. Tweak to taste, then bash ~/ai-stack/auth-gateway/run-auth.sh to rebuild. The page also responds to keyboard shortcuts: R = retry, H = home.
15
Wildcard Catch-All for Unknown Subdomains

The gap this closes. Step 14 added a friendly page for two cases: (1) unknown paths on the apex (e.g. pocketcode.in/whoknows) via the gateway's Express catch-all, and (2) down services on known subdomains via Caddy's handle_errors. But a third case still falls through with no friendly handling: an entirely unknown subdomain, e.g. error.pocketcode.in, dashboard.pocketcode.in, anything that hits your server but matches no site block in the Caddyfile.

When that happens today, Caddy responds with whatever its default behavior is — usually a TLS handshake error or a generic 421 Misdirected Request, neither of which is friendly. This step adds a wildcard site block that catches all unknown subdomains and 302s them to the friendly lost page.

💡Why Caddy's longest-match makes this safe. Caddy matches more-specific hostnames before less-specific ones, regardless of order in the file. A request for sim.pocketcode.in matches the explicit sim.pocketcode.in block (more specific) before the wildcard *.pocketcode.in block (less specific). So adding the wildcard does not affect any of the explicit sites — only the previously-unhandled ones.

Part A: Confirm wildcard DNS resolves. The wildcard Caddy block only helps if random subdomains actually reach your server. Run on the server:

bash · check DNS
dig +short error.pocketcode.in
dig +short anything.pocketcode.in
dig +short random.pocketcode.in

All three should return your server IP. They will if your Route 53 has a wildcard * A record pointing at your server — which Tab 13 Step 3 sets up.

If dig returns empty for any of them, add a wildcard A record in Route 53 before continuing: open the pocketcode.in hosted zone, click Create record, record name = *, type = A, value = your server IP, TTL = 300. Wait ~2 minutes for propagation, then re-run the digs.

Part B: Add the wildcard catch-all to the Caddyfile.

bash · edit Caddyfile
nano /root/ai-stack/caddy/Caddyfile

Press Ctrl+End to jump to the bottom of the file (or repeatedly press Page Down). After the last existing site block (the openrouter.pocketcode.in { ... } closing brace), press Enter twice for breathing room, then paste this block:

caddyfile · wildcard catch-all (append at end of file)
# ─── Wildcard catch-all ───────────────────────────────────────────────────
# Any *.pocketcode.in hostname not declared above lands here. Caddy uses
# longest-match, so the explicit blocks (sim, chat, etc.) always win. Only
# truly unknown subdomains (random.pocketcode.in, error.pocketcode.in, etc.)
# reach this. 302 → friendly "you are lost" page on the apex.
#
# The wildcard cert *.pocketcode.in is acquired once via the global
# acme_dns route53 directive — no separate TLS config needed.
*.pocketcode.in {
    redir https://pocketcode.in/error?lost=1&from={scheme}://{host}{uri} 302
}

Save: Ctrl+O, Enter, Ctrl+X.

Part C: Validate and reload.

bash · validate
docker exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile

Expected last line: Valid configuration. If invalid, the error names the line — fix and re-validate.

bash · reload (zero downtime)
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile

Silent success on the reload. Now watch the cert acquisition — Caddy needs a wildcard *.pocketcode.in cert (which it'll request via your existing Route 53 DNS-01 setup; takes 30-60 seconds):

bash · watch cert acquisition
docker logs caddy -f 2>&1 | grep -E '(\*.pocketcode|certificate obtained|wildcard)'

Within ~60 seconds you should see certificate obtained successfully for *.pocketcode.in. Press Ctrl+C to exit the log tail.

📜Let's Encrypt rate limits. This adds one new cert (the wildcard). You're well below LE's 50/week certs-per-domain limit even with your existing per-subdomain certs. Future visits to unknown subdomains reuse the same wildcard cert — no new acquisitions per random name.

Part D: Verify it works.

bash · 4-test verification
# Test 1 — unknown subdomain should 302
echo "=== Test 1: unknown subdomain → 302 to /error ==="
curl -sI -m 5 https://error.pocketcode.in/ | grep -iE '^(HTTP|location)'

# Test 2 — another unknown subdomain
echo ""
echo "=== Test 2: another unknown subdomain ==="
curl -sI -m 5 https://random.pocketcode.in/ | grep -iE '^(HTTP|location)'

# Test 3 — follow the redirect chain
echo ""
echo "=== Test 3: follow redirect — should end on 404 ==="
curl -sIL -m 10 https://error.pocketcode.in/foo/bar | grep -iE '^(HTTP|location)'

# Test 4 — known subdomain still works correctly
echo ""
echo "=== Test 4: known subdomain still goes to login (NOT to /error) ==="
curl -sI -m 5 https://sim.pocketcode.in/ | grep -iE '^(HTTP|location)'

Expected results:

TestExpected output
1, 2HTTP/2 302 + location: https://pocketcode.in/error?lost=1&from=https://name.pocketcode.in/
3First HTTP/2 302 (the wildcard redirect), then HTTP/2 404 (the friendly lost page on apex)
4HTTP/2 302 + location: https://pocketcode.in/login?return=... — auth_gate fires correctly; the wildcard did NOT shadow the explicit sim.pocketcode.in block

Browser test: visit https://anything.pocketcode.in/. You should land on the friendly compass-icon "You are lost." page with Requested: https://anything.pocketcode.in/ in the detail line.

⚠️What this does NOT catch. Two-label-deep subdomains like xyz.abc.pocketcode.in — your wildcard DNS record matches only one label deep, so these return NXDOMAIN at the DNS level and never reach your server. Garbage like pocketcode.in.attacker.com — different domain entirely, not under your DNS control. Both are correct behavior; nothing to do.
📍Why redir instead of reverse_proxy? Two reasons. First, the friendly page lives on the gateway at pocketcode.in/error; redirecting (rather than reverse-proxying through the wildcard) means the user's URL bar updates to the apex, which is the right place for "you're lost — go home" guidance. Second, reverse-proxying every unknown subdomain through the gateway opens it up to abuse (someone could send millions of requests to aaaa.pocketcode.in, bbbb.pocketcode.in, etc.); the redir form is cheaper for Caddy to serve and is cached by browsers.
16
v1.6 — Qdrant Direct-Subdomain + Caddy Response Injection

Why this exists. After Step 10 was deployed, Qdrant's Web UI was found to be iframe-incompatible. When loaded inside the launcher's iframe at pocketcode.in/app/qdrant/..., the React app makes cross-origin subresource fetches during sidebar mount (e.g. /dashboard/manifest.json). Browser cookie partitioning prevents the auth cookie from going on these fetches, so auth_gate redirects them to /login, the cross-origin redirect gets CORS-blocked, and the sidebar component never finishes rendering — the user sees only the inner Welcome content.

The fix. Move Qdrant out of the launcher iframe (open it at its own subdomain directly), and re-create the session-expiry overlay UX by injecting a poll script into Qdrant's HTML response at the Caddy layer. This uses the caddyserver/replace-response plugin which is already baked into your Caddy image (Tab 14 Step 8 of this guide builds it in from v1.6). The 8 other services keep their iframe wrappers — they don't have this issue.

Part A: Replace the qdrant.pocketcode.in Caddyfile block

bash · open the Caddyfile
nano ~/ai-stack/caddy/Caddyfile

Press Ctrl+W, type qdrant.pocketcode.in {, Enter. Delete the entire block — from qdrant.pocketcode.in { down through its matching closing } (the simple version added in Step 10 Part A plus the handle_errors block added in Step 14 Part B Edit 7). Paste this replacement:

caddyfile · ~/ai-stack/caddy/Caddyfile · qdrant block v1.6
qdrant.pocketcode.in {
    # Direct passthrough for /verify so the injected poll script gets
    # a clean 401 instead of being 302'd to /login by auth_gate.
    handle /verify {
        reverse_proxy auth-gateway:7000
    }

    handle {
        import auth_gate
        header -X-Frame-Options

        # Inject session-poll script before </body> in HTML responses only.
        # match {} block here is a RESPONSE matcher (filters by response
        # Content-Type) — route-level matchers check requests instead.
        replace {
            match {
                header Content-Type text/html*
            }
            "</body>" `<script>
(function(){if(window.__pcSession)return;window.__pcSession=1;var OVERLAY='<div id="__pcov" style="position:fixed;inset:0;background:rgba(10,14,26,.95);display:none;align-items:center;justify-content:center;flex-direction:column;color:#e2e8f0;z-index:2147483647;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);font-family:ui-monospace,monospace"><div style="background:linear-gradient(180deg,#141926,#1a2030);border:1px solid rgba(255,255,255,.1);border-radius:14px;padding:36px 44px;text-align:center;max-width:420px;box-shadow:0 30px 80px rgba(0,0,0,.5)"><h1 style="margin:0 0 8px;background:linear-gradient(135deg,#7c3aed,#00d4ff);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;font-size:22px;letter-spacing:-.3px">Session expired</h1><p style="color:#94a3b8;font-size:13px;margin:8px 0 18px;line-height:1.6">You have been signed out of pocketcode.in. Sign in again to return.</p><a id="__pcsi" style="display:inline-block;padding:11px 22px;background:linear-gradient(135deg,#7c3aed,#5b21b6);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:13px;letter-spacing:1px;text-transform:uppercase">Sign in</a><small id="__pccd" style="display:block;margin-top:14px;color:#475569;font-size:11px">Redirecting in 3...</small></div></div>';function init(){document.body.insertAdjacentHTML('beforeend',OVERLAY);var ov=document.getElementById('__pcov');var si=document.getElementById('__pcsi');var cd=document.getElementById('__pccd');var loginUrl='https://pocketcode.in/login?return='+encodeURIComponent(window.location.href);si.href=loginUrl;var triggered=false;function expire(){if(triggered)return;triggered=true;ov.style.display='flex';var n=3;var t=setInterval(function(){n-=1;if(n<=0){clearInterval(t);window.location.replace(loginUrl);}else cd.textContent='Redirecting in '+n+'...';},1000);}function check(){if(triggered)return;fetch('/verify',{credentials:'same-origin',cache:'no-store'}).then(function(r){if(r.status===401)expire();}).catch(function(){});}setInterval(check,15000);window.addEventListener('focus',check);document.addEventListener('visibilitychange',function(){if(!document.hidden)check();});setTimeout(check,1000);}if(document.body)init();else document.addEventListener('DOMContentLoaded',init);})();
</script>
</body>`
        }

        # Strip Accept-Encoding so qdrant returns uncompressed HTML
        # (replace cannot operate on gzipped response bodies).
        reverse_proxy qdrant:6333 {
            header_up -Accept-Encoding
        }
    }

    handle_errors {
        rewrite * /error?service=qdrant&code={err.status_code}
        reverse_proxy auth-gateway:7000
    }
}

Save (Ctrl+O, Enter, Ctrl+X). Validate and reload Caddy:

bash · validate + reload
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
📜What each piece does. The handle /verify { reverse_proxy auth-gateway:7000 } block bypasses auth_gate entirely for that one path, so the injected fetch sees a real 401 from the gateway instead of getting 302'd to the login page (which the iframe-fetch can't follow due to CORS). The replace {} directive's inner match {} block is a RESPONSE matcher (filters by Content-Type: text/html* on the response, not the request) — this is intentional and a common confusion: route-level matchers in Caddy check requests, while matchers inside replace check responses. The header_up -Accept-Encoding on the upstream proxy strips that header from the request going to Qdrant, so Qdrant responds with uncompressed HTML — replace_response cannot operate on gzipped bodies.

Part B: Update home.html — Qdrant tile to direct subdomain

If you deployed via the v1.6 bundle, this is already correct. To verify or apply manually:

bash · check current Qdrant tile href
grep -A 1 'c-qdrant' ~/ai-stack/auth-gateway/public/home.html | head -3

If the href shows /app/qdrant (old launcher path) or https://pocketcode.in/app/qdrant/dashboard, update it to point at the direct subdomain. Open home.html:

bash · edit home.html
nano ~/ai-stack/auth-gateway/public/home.html

Press Ctrl+W, type c-qdrant, Enter. Find the line:

find this line (or similar)
<a href="/app/qdrant" target="_blank" rel="noopener" class="card c-qdrant">

Change the href to point at the direct subdomain:

replacement
<a href="https://qdrant.pocketcode.in/dashboard" target="_blank" rel="noopener" class="card c-qdrant">

Save (Ctrl+O, Enter, Ctrl+X). No rebuild needed — Step 5's v1.6 run-auth.sh bind-mounts public/ read-only into the container, so changes go live immediately on the next page load.

Part C: Verify the injection works end-to-end

bash · grab a session cookie + check injection
COOKIES=$(mktemp)

# Get a session cookie by POSTing valid credentials
curl -s -c "$COOKIES" -X POST https://pocketcode.in/login \
  -d "username=admin" -d "password=YOUR_ADMIN_PASSWORD" -o /dev/null

# Fetch Qdrant dashboard, look for the injected marker
HITS=$(curl -s -b "$COOKIES" https://qdrant.pocketcode.in/dashboard | grep -c '__pcSession')
if [ "$HITS" -ge 1 ]; then
  echo "✓ Injection confirmed: __pcSession marker present"
else
  echo "✗ Injection missing — check 'docker exec caddy caddy list-modules | grep replace_response'"
fi

rm -f "$COOKIES"

Expected: ✓ Injection confirmed: __pcSession marker present.

Browser test. Open https://pocketcode.in/, log in, click the Qdrant tile. URL bar should read qdrant.pocketcode.in/dashboard (not pocketcode.in/app/qdrant/...). You should see the full Qdrant Web UI with its left sidebar (Welcome / Console / Collections / Tutorial / Datasets / Access Tokens) loaded normally. In a separate tab, go to pocketcode.in/logout. Switch back to the Qdrant tab — within ~15 seconds (or instantly if you focus it), the same "Session expired" overlay you see on iframe-wrapped services appears with a 3-second countdown.

Part D: Extend ai-doctor with v1.6 checks

Edit the diagnose script and add two more checks to the "Auth Gateway" section (created in Step 10 Part D):

bash · edit diagnose.sh
nano ~/ai-stack/manage/diagnose.sh

Press Ctrl+W, type Auto-SSO enabled for, Enter. You'll land on the SSO confirmation block from Step 10. Move the cursor to the end of the fi that closes that block, then paste these two new checks directly below:

bash · append after the Auto-SSO check
# v1.6: replace-response plugin must be loaded in Caddy
if docker exec caddy caddy list-modules 2>/dev/null | grep -q '^http.handlers.replace_response$'; then
  echo -e "  ${G}✓${NC} Caddy replace_response module loaded (v1.6+)"
else
  echo -e "  ${R}✗${NC} Caddy replace_response module MISSING — rebuild caddy image:"
  echo -e "       ${DIM}cd ~/ai-stack/caddy && docker build -t caddy-route53:latest .${NC}"
fi

# v1.6: Qdrant session-poll script must be injected into dashboard HTML
qdrant_html=$(curl -s --max-time 3 -H "Host: qdrant.pocketcode.in" http://127.0.0.1/dashboard 2>/dev/null)
if echo "$qdrant_html" | grep -q '__pcSession'; then
  echo -e "  ${G}✓${NC} Qdrant injection working (__pcSession found in dashboard HTML)"
else
  echo -e "  ${Y}⚠${NC}  Qdrant injection not detected — verify Caddyfile qdrant block has 'replace' directive"
fi

Save (Ctrl+O, Enter, Ctrl+X) and run ai-doctor — the "Auth Gateway" section should now show 7 ✓ lines.

📚Full architectural reasoning is in the docs-page at docs.pocketcode.in → Qdrant tab → This Setup's Access Pattern section. That tab is also where Qdrant's API surface (v1.10 Query API, filtering, payload indexes, Python client) is documented in depth for day-to-day use.
🔧
TLS Troubleshooting
// What to do when Caddy stops serving valid certs · diagnose, clean, recover
📖When you need this tab. Your stack works fine for months at a time — until cert renewal day, when one subtle failure mode propagates into a broken TLS handshake for every gated subdomain. The symptoms are confusing because browsers cache valid cert sessions for hours; you only notice when a private window or `curl` reveals the problem. This tab is the diagnostic and recovery playbook for the most common cert-acquisition failure: stale TXT records at _acme-challenge.<your-domain> that block Let's Encrypt from validating new challenges.
🚨Symptoms that should send you here. Any of: (1) ai-doctor reports services up but you can't reach them in a private browser window. (2) curl -sI https://<sub>.pocketcode.in/ returns tlsv1 alert internal error or empty output. (3) openssl s_client shows "no certificate" for subdomains but works for the apex. (4) docker logs caddy | grep -i error shows repeated "challenge failed","detail":"Incorrect TXT record ... (and N more) found". (5) dig +short TXT _acme-challenge.pocketcode.in returns more than 1 record.
1
Install AWS CLI on the Server (Diagnostic Tool)

The recovery scripts in this tab need to talk to AWS Route 53 to inspect and clean up the _acme-challenge TXT records. Caddy already does this internally via the route53 plugin, but to debug and recover you need the same access from the command line. Install AWS CLI v2:

bash · install AWS CLI v2
cd /tmp
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zip
unzip -q awscliv2.zip
sudo ./aws/install
rm -rf awscliv2.zip aws/
aws --version

Expected last line: aws-cli/2.x.x Python/3.x.x Linux/...

Configure with the Caddy IAM credentials. Use the same AWS keys that are already in ~/ai-stack/caddy/run-caddy.sh — this proves whether those credentials actually have the permissions Caddy needs, and avoids reinventing a second credential set:

bash · configure AWS CLI from Caddy's existing keys
ak=$(grep -E 'AWS_ACCESS_KEY_ID' ~/ai-stack/caddy/run-caddy.sh | head -1 | sed -E 's/.*AWS_ACCESS_KEY_ID[="]*([^"\\ ]+).*/\1/')
sk=$(grep -E 'AWS_SECRET_ACCESS_KEY' ~/ai-stack/caddy/run-caddy.sh | head -1 | sed -E 's/.*AWS_SECRET_ACCESS_KEY[="]*([^"\\ ]+).*/\1/')
mkdir -p ~/.aws
cat > ~/.aws/credentials <<EOF
[default]
aws_access_key_id = $ak
aws_secret_access_key = $sk
EOF
cat > ~/.aws/config <<EOF
[default]
region = us-east-1
output = json
EOF
chmod 600 ~/.aws/credentials
unset ak sk
aws sts get-caller-identity

Expected output: a JSON block with UserId, Account, and Arn ending in :user/caddy-route53-dns (or whatever your IAM user is named).

⚠️If aws sts get-caller-identity fails with InvalidClientTokenId, your Caddy IAM credentials are bad. Re-check the keys in run-caddy.sh. If you rotated AWS keys recently and didn't update run-caddy.sh, that's likely the root cause of any cert acquisition failure — Caddy itself is using stale credentials.
2
Diagnose — TLS, Certs, Stale Records

A single read-only script that snapshots everything cert-related: which certs Caddy has on disk, what cert each hostname actually presents, what Caddy is logging about acquisition, and whether stale TXT records are present in Route 53. Changes nothing.

bash · create the diagnose script
nano ~/diagnose-tls.sh

Paste:

bash · diagnose-tls.sh
#!/bin/bash
# diagnose-tls.sh — read-only TLS investigation
set +e
BOLD=$'\033[1m'; DIM=$'\033[2m'; G=$'\033[0;32m'
R=$'\033[0;31m'; NC=$'\033[0m'
header() { echo; echo "${BOLD}=== $1 ===${NC}"; }

header "1. Caddy cert inventory"
docker exec caddy find /data/caddy/certificates -name '*.crt' 2>/dev/null | sort

header "2. Recent Caddy log lines about TLS / certs / errors"
docker logs caddy --tail 60 2>&1 \
  | grep -iE '(tls|cert|acme|challenge|error|alert|obtain)' \
  | tail -25

header "3. What cert does Caddy present for each hostname?"
for host in pocketcode.in sim.pocketcode.in chat.pocketcode.in n8n.pocketcode.in \
            openclaw.pocketcode.in pgadmin.pocketcode.in terminal.pocketcode.in \
            qdrant.pocketcode.in ollama.pocketcode.in openrouter.pocketcode.in; do
  echo
  echo "${BOLD}-- $host --${NC}"
  result=$(echo Q | timeout 5 openssl s_client \
    -servername "$host" -connect 127.0.0.1:443 2>/dev/null \
    | openssl x509 -noout -subject -enddate -issuer 2>/dev/null)
  if [ -z "$result" ]; then
    echo "  ${R}TLS handshake failed - no cert presented${NC}"
  else
    echo "$result" | sed 's/^/  /'
  fi
done

header "4. Stale ACME challenge TXT records"
dig +short TXT _acme-challenge.pocketcode.in
count=$(dig +short TXT _acme-challenge.pocketcode.in | wc -l)
if [ "$count" -gt 1 ]; then
  echo
  echo "${R}WARNING: $count TXT records found at _acme-challenge.pocketcode.in${NC}"
  echo "${R}Let's Encrypt expects exactly 1 during validation.${NC}"
fi

header "5. Caddyfile wildcard block check"
grep -A2 '^\*\.pocketcode\.in' /root/ai-stack/caddy/Caddyfile 2>/dev/null \
  || echo "  no *.pocketcode.in block found"

echo
echo "${BOLD}=== Diagnostic complete ===${NC}"

Save with Ctrl+O, Enter, Ctrl+X. Run:

bash · run diagnose
chmod +x ~/diagnose-tls.sh && ~/diagnose-tls.sh

What the output tells you:

SectionWhat "healthy" looks like
1One *.pocketcode.in wildcard cert + the apex cert. No per-subdomain certs (or they're harmless leftovers from before the wildcard was acquired).
2No recent "challenge failed" or "could not get certificate" entries.
3Each hostname presents a cert with valid notAfter date and a recognizable issuer (Let's Encrypt's R10/R11/E5/E8).
4Empty output, or exactly 1 TXT record (a current in-flight challenge).
5The wildcard catch-all block exists (added in Tab 18 Step 15).

Sections to scrutinize: Section 3 — if subdomains show TLS handshake failed - no cert presented but the apex works, that's the signal that wildcard cert acquisition failed and per-subdomain certs are stale or missing. Section 4 — if multiple TXT records are present, that's almost certainly your root cause. Both findings call for Step 3 of this tab.

3
Verify IAM Permissions Are Sufficient

Before cleaning up stale records, confirm the Caddy IAM user has all required permissions. Run:

bash · verify IAM
echo "=== ListHostedZones (cleanup needs this) ==="
aws route53 list-hosted-zones --query 'HostedZones[?Name==`pocketcode.in.`].Id' --output text

echo ""
echo "=== ListResourceRecordSets on the zone (also needed) ==="
ZONE_ID=$(aws route53 list-hosted-zones --query 'HostedZones[?Name==`pocketcode.in.`].Id' --output text | sed 's|/hostedzone/||')
echo "Zone ID: $ZONE_ID"

aws route53 list-resource-record-sets \
  --hosted-zone-id "$ZONE_ID" \
  --query "ResourceRecordSets[?Name=='_acme-challenge.pocketcode.in.']" \
  --output json

Expected output: a zone ID like /hostedzone/Z01234567890ABCDEFGHI, then a JSON array of TXT records.

If either command errors with AccessDenied, your IAM policy is missing permissions. Open the AWS Console → IAM → Users → caddy-route53-dnsPermissions tab → click your inline policy → Edit, switch to JSON view, and replace with:

json · full IAM policy (replace existing)
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Route53Caddy",
            "Effect": "Allow",
            "Action": [
                "route53:ListHostedZones",
                "route53:ListHostedZonesByName",
                "route53:GetChange",
                "route53:GetHostedZone",
                "route53:ChangeResourceRecordSets",
                "route53:ListResourceRecordSets"
            ],
            "Resource": "*"
        }
    ]
}

Save changes in the console, wait ~15 seconds for IAM propagation, then re-run the verify commands above. Both should now succeed.

📜The two-permission upgrade matters. Earlier minimal IAM policies for the caddy-route53 plugin omitted ListHostedZones and GetHostedZone. Those omissions don't break initial cert acquisition (only ListHostedZonesByName is used for the first lookup), but they break parts of Caddy's automatic TXT-record cleanup pathway — which is why stale records accumulate. The 6-action policy in Tab 14 Step 6 is now the production-correct baseline. If your install predates that update, this is the fix.
4
Clean Up Stale TXT Records + Pin Caddy to Single Issuer

This is the destructive step. Run it only if Step 2's section 4 showed multiple TXT records at _acme-challenge.pocketcode.in and Step 3 confirmed your IAM policy is the 6-action version.

The script does six things, in order: (1) backs up the current TXT record set to JSON so you can restore if needed, (2) prompts you to confirm before deleting, (3) atomically deletes the entire _acme-challenge record set via Route 53's ChangeBatch API, (4) verifies deletion via both AWS API and public DNS query, (5) adds acme_ca https://acme-v02.api.letsencrypt.org/directory to your Caddyfile global block (pins Caddy to Let's Encrypt only, preventing ZeroSSL fallback race conditions), and (6) reloads Caddy and watches the logs for fresh cert acquisition.

bash · create cleanup script
nano ~/cleanup-and-fix.sh

Paste:

bash · cleanup-and-fix.sh
#!/bin/bash
# cleanup-and-fix.sh
set -e
BOLD=$'\033[1m'; DIM=$'\033[2m'; G=$'\033[0;32m'
Y=$'\033[1;33m'; R=$'\033[0;31m'; NC=$'\033[0m'

ZONE_ID=$(aws route53 list-hosted-zones --query \
  'HostedZones[?Name==`pocketcode.in.`].Id' --output text \
  | sed 's|/hostedzone/||')
CADDYFILE="/root/ai-stack/caddy/Caddyfile"
BACKUP_DIR="$HOME/cleanup-backups"
TS=$(date +%Y%m%d-%H%M%S)
BACKUP_FILE="$BACKUP_DIR/acme-records-${TS}.json"
CADDYFILE_BACKUP="$BACKUP_DIR/Caddyfile-${TS}.bak"

header() { echo; echo "${BOLD}=== $1 ===${NC}"; }

header "1. Backup current state"
mkdir -p "$BACKUP_DIR"
aws route53 list-resource-record-sets --hosted-zone-id "$ZONE_ID" \
  --query "ResourceRecordSets[?Name=='_acme-challenge.pocketcode.in.']" \
  --output json > "$BACKUP_FILE"
cp "$CADDYFILE" "$CADDYFILE_BACKUP"
echo "${G}Backups in: $BACKUP_DIR${NC}"

header "2. Confirm before destructive action"
txt_count=$(grep -c '"Value"' "$BACKUP_FILE" || echo 0)
echo "About to delete _acme-challenge record (${txt_count} TXT values)"
echo "and add 'acme_ca' directive to the Caddyfile."
read -r -p "Proceed? Type 'yes': " confirm
[ "$confirm" = "yes" ] || { echo "Cancelled."; exit 0; }

header "3. Delete the stale TXT record"
if [ "$txt_count" -gt 0 ]; then
  CHANGE_BATCH=$(jq -n --slurpfile rs "$BACKUP_FILE" \
    '{Changes:[{Action:"DELETE",ResourceRecordSet:$rs[0][0]}]}')
  echo "$CHANGE_BATCH" > "$BACKUP_DIR/change-batch-${TS}.json"
  CHANGE_ID=$(aws route53 change-resource-record-sets \
    --hosted-zone-id "$ZONE_ID" \
    --change-batch "file://$BACKUP_DIR/change-batch-${TS}.json" \
    --query 'ChangeInfo.Id' --output text)
  echo "Submitted: $CHANGE_ID"
  for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
    STATUS=$(aws route53 get-change --id "$CHANGE_ID" \
      --query 'ChangeInfo.Status' --output text)
    [ "$STATUS" = "INSYNC" ] && { echo "INSYNC after ${i}x5s"; break; }
    echo "  poll $i: $STATUS"
    sleep 5
  done
fi

header "4. Verify deletion"
aws route53 list-resource-record-sets --hosted-zone-id "$ZONE_ID" \
  --query "ResourceRecordSets[?Name=='_acme-challenge.pocketcode.in.']" \
  --output text | grep -q . && \
  { echo "${R}Records still present${NC}"; exit 1; } \
  || echo "${G}Records gone from AWS${NC}"
dig +short @1.1.1.1 TXT _acme-challenge.pocketcode.in. | grep -q . && \
  echo "${Y}DNS cache still shows records (will clear in ~1 min)${NC}" \
  || echo "${G}DNS clean${NC}"

header "5. Pin Caddy to Let's Encrypt"
if grep -q '^[[:space:]]*acme_ca' "$CADDYFILE"; then
  echo "${Y}acme_ca already in Caddyfile${NC}"
else
  sed -i '/^[[:space:]]*email[[:space:]]\+/a\    acme_ca https://acme-v02.api.letsencrypt.org/directory' "$CADDYFILE"
  echo "${G}Added acme_ca directive${NC}"
fi

header "6. Validate, reload, watch cert acquisition (up to 120s)"
docker exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "${G}Reloaded.${NC} Restarting container to flush in-memory retry cache..."
docker restart caddy
sleep 10

WATCH_LOG=/tmp/cleanup-watch.txt
> "$WATCH_LOG"
( timeout 120 docker logs caddy --tail 0 -f 2>&1 \
   | grep --line-buffered -iE '(\*\.pocketcode|wildcard|obtain|certificate)' \
   | tee -a "$WATCH_LOG" ) &
WATCHER_PID=$!
while kill -0 "$WATCHER_PID" 2>/dev/null; do
  if grep -q '"certificate obtained successfully".*\*\.pocketcode' "$WATCH_LOG"; then
    kill "$WATCHER_PID" 2>/dev/null; break
  fi
  sleep 2
done

grep -q '"certificate obtained successfully".*\*\.pocketcode' "$WATCH_LOG" \
  && echo "${G}✓ WILDCARD CERT ACQUIRED${NC}" \
  || echo "${Y}No clear success in 120s — see logs${NC}"
echo "Rollback: cp \"$CADDYFILE_BACKUP\" \"$CADDYFILE\""

Save with Ctrl+O, Enter, Ctrl+X. Run:

bash · run cleanup
chmod +x ~/cleanup-and-fix.sh && ~/cleanup-and-fix.sh
🔄Why the script restarts Caddy at the end. A config-only reload doesn't always flush Caddy's internal cert-acquisition retry timers. After repeated failures, Caddy enters a backoff state that can persist for 10-30 minutes before the next attempt. A container restart flushes all in-memory state (retry timers, failure caches) while preserving on-disk certs. Cost: ~5 seconds of TLS interruption.
⚠️If the script reports "No clear success in 120s", check /tmp/cleanup-watch.txt for what Caddy actually did. Common causes: (a) Let's Encrypt rate-limited the account after 5+ failed validations in the past hour — wait 60 minutes and re-run, (b) DNS hadn't fully propagated when LE validated — re-run after another 2 minutes, (c) the wildcard catch-all block isn't in the Caddyfile yet — add it per Tab 18 Step 15 first.
5
Verify Full Recovery

After Step 4 reports the wildcard cert acquired, run this verification to confirm every hostname presents a valid cert and every HTTP route behaves correctly.

bash · create verify script
nano ~/verify-tls-recovery.sh

Paste:

bash · verify-tls-recovery.sh
#!/bin/bash
# verify-tls-recovery.sh
set +e
BOLD=$'\033[1m'; G=$'\033[0;32m'
Y=$'\033[1;33m'; R=$'\033[0;31m'; NC=$'\033[0m'
header() { echo; echo "${BOLD}=== $1 ===${NC}"; }
PASS=0; FAIL=0

header "1. TLS cert per hostname"
for host in pocketcode.in www.pocketcode.in setup.pocketcode.in \
            docs.pocketcode.in sim.pocketcode.in sim-realtime.pocketcode.in \
            chat.pocketcode.in n8n.pocketcode.in openclaw.pocketcode.in \
            pgadmin.pocketcode.in terminal.pocketcode.in \
            qdrant.pocketcode.in ollama.pocketcode.in openrouter.pocketcode.in; do
  cert=$(echo Q | timeout 5 openssl s_client -servername "$host" \
    -connect 127.0.0.1:443 2>/dev/null \
    | openssl x509 -noout -subject -enddate 2>/dev/null)
  if [ -z "$cert" ]; then
    printf "  ${R}FAIL %-30s no cert${NC}\n" "$host"; FAIL=$((FAIL+1))
  else
    cn=$(echo "$cert" | grep '^subject=' | sed -E 's/.*CN ?= ?([^,]+).*/\1/')
    printf "  ${G}OK   %-30s CN=%s${NC}\n" "$host" "$cn"; PASS=$((PASS+1))
  fi
done

header "2. HTTP behavior — apex, gated, unknown sub"
check() {
  code=$(curl -s -o /dev/null -w "%{http_code}" -m 5 "$2")
  printf "  %-40s HTTP %s\n" "$1" "$code"
}
check "apex"                 "https://pocketcode.in/"
check "sim (gated)"          "https://sim.pocketcode.in/"
check "openclaw (gated)"     "https://openclaw.pocketcode.in/"
check "unknown sub"          "https://anything.pocketcode.in/"

header "3. Stale TXT records (must be empty)"
dig +short @1.1.1.1 TXT _acme-challenge.pocketcode.in. \
  | grep -q . && echo "${R}TXTs present!${NC}" \
  || echo "${G}clean${NC}"

header "Summary"
echo "  ${G}PASS: $PASS${NC}    ${R}FAIL: $FAIL${NC}"

Save with Ctrl+O, Enter, Ctrl+X. Run:

bash · run verify
chmod +x ~/verify-tls-recovery.sh && ~/verify-tls-recovery.sh

Expected: every hostname shows OK with CN=*.pocketcode.in (the wildcard, except for the apex which has its own cert). HTTP codes: apex 200 or 302, gated 302 to login, unknown sub 302 to /error?lost=1. Stale TXT: clean. If the summary shows FAIL: 0, recovery is complete.

6
Post-Recovery: Why Things Are Now Better

After running Steps 1-5, your stack has four permanent improvements over the pre-recovery state:

ChangeImpact
6-action IAM policyCaddy can now find zones (ListHostedZones) and read zone metadata (GetHostedZone), enabling its automatic TXT-record cleanup pathway. Stale records can't accumulate.
acme_ca directive pins Let's EncryptNo more ZeroSSL fallback racing. One issuer per acquisition, no TXT-record collisions, predictable failure modes.
Wildcard cert in useOne renewal event per quarter instead of 13. Failure surface reduced by ~90%. New subdomains pick up the existing wildcard cert instantly.
Diagnostic + recovery scripts savedNext time something cert-related goes wrong, you have a playbook. Don't delete ~/diagnose-tls.sh, ~/cleanup-and-fix.sh, or ~/verify-tls-recovery.sh.

If you want to keep the old per-subdomain certs cleaner, they're harmless leftovers that Caddy garbage-collects automatically after ~30 days. To remove them now:

bash · optional: prune old per-sub certs
docker exec caddy sh -c '
  cd /data/caddy/certificates
  for dir in */; do
    if [ "${dir}" != "wildcard_.pocketcode.in/" ] && \
       [ "${dir}" != "pocketcode.in/" ] && \
       [ "${dir%/}" != "${dir%/}" ] && \
       [ -d "${dir}" ]; then
      echo "Would remove: ${dir}"
      # Uncomment to actually delete:
      # rm -rf "${dir}"
    fi
  done
'

Above shows what would be removed; uncomment the rm -rf line to actually delete.

💡Renewal monitoring. The wildcard cert renews automatically ~30 days before expiry. To watch a renewal in real time when it happens (typically ~60 days after acquisition), run: docker logs caddy -f | grep -iE '(obtain|renewal|certificate)'. If a renewal ever fails, run Step 2 of this tab to diagnose; the cleanup script handles every recovery scenario this tab covers.
📝
Auth Pages Source Code
// Reference source for login.html, home.html, server.js · For app.html (v1.2 launcher) and error.html (v1.4) use the bundle in Tab 14
📚Self-contained alternative to Tab 15 + Tab 18. If you didn't download the bundle ZIP — or you want to read and customize the code before deploying — this tab has every file inline. Copy each block into a nano editor on your server. End result is identical to the ZIP-based approach. You can skip this tab entirely if you've already deployed the bundle per Tab 15 Step 1.
🧠How the auth flow works — read this first:

no cookie

cookie OK

submit

invalid

valid

document

iframe

poll

200

401

Sign in

click

cookie gone

User opens
sim.pocketcode.in/path

Caddy auth_gate:
session cookie
present?

Redirect 302 to
pocketcode.in/login
?return=URL

Login form
login.html

POST /login → auth-gateway:7000

credentials
match?

Set-Cookie pocketcode_session
domain=.pocketcode.in
HttpOnly · Secure · SameSite=lax

Redirect 302
to return URL

Caddy:
Sec-Fetch-Dest?

Serve wrapper
service-wrapper/index.html
+ bar with icon + Logout

Proxy to
upstream service

User sees
wrapped service

Wrapper iframe
loads same URL

periodic /verify
every 15s + on focus

Session-expired overlay
Sign-in button → /login?return=URL

Click Logout
same-origin fetch /logout

auth-gateway clears
cookie + 302 /

Auth flow — gateway cookie, wrapper routing, session-expired overlay
🔍Reading the diagram: Green-outlined nodes are public or success states. Amber/red are auth-related (warn/error). Cyan are auth-service calls. Diamonds are decision points. The cookie scoped to .pocketcode.in in the bottom-left means once you log in once, every subdomain's /verify call succeeds — no re-login per service.
1
Create the Folder Structure

All gateway files live under ~/ai-stack/auth-gateway/. Create the structure first:

bash · on server
mkdir -p ~/ai-stack/auth-gateway/public
cd ~/ai-stack/auth-gateway
2
Create server.js — Auth Logic

The Express server that handles login, session validation, and the /verify endpoint Caddy calls on every request. Key features:

  • GET /verify — used by Caddy's forward_auth on every protected subdomain. Returns 200 if cookie valid, 401 if not.
  • GET / — shows home.html (logged in) or login.html (logged out)
  • POST /login — validates credentials with bcrypt, sets the .pocketcode.in-scoped cookie
  • safeReturn() — validates returnTo URLs to prevent open-redirect phishing
  • Defensive www handling — if a request arrives with Host starting with www., the server 301s to the non-www twin itself (belt-and-suspenders with Caddy's redirect)
  • Timing-safe bcrypt compare runs even on wrong username, so attackers can't tell which field was wrong
bash · create file
nano ~/ai-stack/auth-gateway/server.js

Paste this content (Ctrl+O, Enter, Ctrl+X to save):

javascript · ~/ai-stack/auth-gateway/server.js
// ============================================================================
//  pocketcode.in — Auth Gateway
//  -------------------------------------------------------------------------
//  Single-user gateway that protects every *.pocketcode.in subdomain except
//  public ones (apex pocketcode.in itself for the login page, setup.X, docs.X).
//
//  REQUEST FLOW
//  -------------
//  1. Caddy receives request for any *.pocketcode.in URL
//  2. If hostname starts with "www." → Caddy 301s to non-www twin (Caddyfile)
//  3. If hostname is "setup." or "docs." → public, no auth check
//  4. Otherwise Caddy calls auth-gateway:7000/verify (forward_auth)
//     - 200 → request proceeds to the actual service
//     - 401 → Caddy 302s the user to https://pocketcode.in/login?return=...
//  5. On pocketcode.in itself, this server checks the cookie:
//     - Valid session → serve home.html (service-card grid + SSO bootstrap)
//     - No/invalid session → serve login.html
//  6. POST /login validates credentials, sets a .pocketcode.in cookie,
//     then redirects to the safe returnTo URL (or / if none).
//
//  AUTO-SSO (optional — enabled when SERVICE_CREDS_JSON env var is set)
//  --------------------------------------------------------------------
//  home.html creates hidden iframes pointing at each service's
//  /_sso_init endpoint. Caddy routes that to auth-gateway:7000/sso-init/:svc.
//  Gateway POSTs the user's stored credentials to the service's login API
//  (via Docker DNS internally), forwards the resulting Set-Cookie back
//  through Caddy → browser stores it scoped to <service>.pocketcode.in.
//  A watchdog re-runs this every 2 min and on tab focus, so any out-of-band
//  logout gets silently undone.
//
//  ENDPOINTS
//  ----------
//    GET  /              → homepage (logged in) OR login form (logged out)
//    GET  /login         → login form (or redirect to / if already logged in)
//    POST /login         → set cookie on success, redirect to returnTo
//    GET  /logout        → clear cookie, back to /
//    GET  /verify        → 200 if valid session, 401 otherwise (Caddy fwd_auth)
//    GET  /health        → 200 OK (docker healthcheck + ai-doctor)
//    GET  /sso-services  → list of services SSO is configured for (auth req'd)
//    GET  /sso-init/:svc → log user into one service (called from iframe)
// ============================================================================

const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const path = require('path');

const app = express();

const SECRET         = process.env.JWT_SECRET;
const USERNAME       = process.env.AUTH_USERNAME       || 'admin';
const PASSWORD_HASH  = process.env.AUTH_PASSWORD_HASH  || '';
const COOKIE_DOMAIN  = process.env.COOKIE_DOMAIN       || '.pocketcode.in';
const COOKIE_NAME    = 'pocketcode_session';
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
const PORT           = parseInt(process.env.PORT || '7000', 10);

// Fail fast on missing/weak config
if (!SECRET || SECRET.length < 32) {
  console.error('FATAL: JWT_SECRET env var must be set (32+ random chars).');
  process.exit(1);
}
if (!PASSWORD_HASH || !PASSWORD_HASH.startsWith('$2')) {
  console.error('FATAL: AUTH_PASSWORD_HASH env var must be a bcrypt hash (starts with $2a$ / $2b$).');
  process.exit(1);
}

// Parse SERVICE_CREDS_JSON. If missing/invalid, auto-SSO is silently disabled.
let SERVICE_CREDS = {};
try {
  SERVICE_CREDS = JSON.parse(process.env.SERVICE_CREDS_JSON || '{}');
} catch (e) {
  console.warn('[sso] SERVICE_CREDS_JSON could not be parsed — auto-SSO disabled.');
}

// Trust Caddy reverse proxy
app.set('trust proxy', 1);
app.disable('x-powered-by');

app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(cookieParser());

// ----- Helpers ----------------------------------------------------------

function getSession(req) {
  const token = req.cookies && req.cookies[COOKIE_NAME];
  if (!token) return null;
  try { return jwt.verify(token, SECRET); }
  catch (_) { return null; }
}

// Only allow redirects to https://<single-label>.pocketcode.in URLs.
function safeReturn(url) {
  if (!url || typeof url !== 'string') return null;
  if (!/^https:\/\/[a-z0-9-]+\.pocketcode\.in(\/.*)?$/i.test(url)) return null;
  return url;
}

// Defensive: if a request arrives at the gateway with a www-prefixed Host
// header (Caddy should have redirected), 301 it ourselves.
app.use((req, res, next) => {
  const host = (req.headers.host || '').toLowerCase();
  if (host.startsWith('www.')) {
    const apex = host.slice(4);
    return res.redirect(301, `https://${apex}${req.originalUrl}`);
  }
  next();
});

// ----- Auto-SSO config --------------------------------------------------
// Per-service login configuration. Each entry tells the gateway how to drive
// that service's login API from inside the Docker network.
const SSO_CONFIG = {
  sim: {
    upstream: 'http://sim-simstudio-1:3000',
    loginPath: '/api/auth/sign-in/email',
    method: 'POST',
    contentType: 'application/json',
    bodyFn: (c) => JSON.stringify({ email: c.email, password: c.password })
  },
  chat: {
    // Open WebUI lives at chat.pocketcode.in
    upstream: 'http://open-webui:8080',
    loginPath: '/api/v1/auths/signin',
    method: 'POST',
    contentType: 'application/json',
    bodyFn: (c) => JSON.stringify({ email: c.email, password: c.password })
  },
  n8n: {
    upstream: 'http://n8n:5678',
    loginPath: '/rest/login',
    method: 'POST',
    contentType: 'application/json',
    bodyFn: (c) => JSON.stringify({ emailOrLdapLoginId: c.email, password: c.password })
  }
};

function ssoEnabledServices() {
  return Object.keys(SSO_CONFIG).filter(svc => SERVICE_CREDS[svc]);
}

// Tiny HTML body returned to iframes — its only job is to postMessage the
// result up to the parent so the watchdog UI can update.
function ssoResultPage(svc, status, msg) {
  const safeMsg = String(msg || '').replace(/'/g, "\\'").slice(0, 200);
  return `<!DOCTYPE html><html><head><meta charset="UTF-8"></head>
<body style="font:11px monospace;color:#94a3b8;background:#0a0e1a;padding:8px;margin:0">
<div>${svc}: ${status}</div>
<script>try{window.parent.postMessage(JSON.stringify({type:'sso-result',service:'${svc}',status:'${status}',message:'${safeMsg}'}),'*')}catch(_){}</script>
</body></html>`;
}

// ----- Routes -----------------------------------------------------------

// Health — no auth, used by docker healthcheck + ai-doctor
app.get('/health', (_req, res) => res.status(200).send('OK'));

// Forward-auth endpoint. Caddy calls this for every protected subdomain.
app.get('/verify', (req, res) => {
  const session = getSession(req);
  if (!session) return res.status(401).send('Unauthorized');
  res.set('X-Auth-User', session.user);
  return res.status(200).send('OK');
});

// Homepage: logged-in users see the service grid, others see login form
app.get('/', (req, res) => {
  const session = getSession(req);
  const file = session ? 'home.html' : 'login.html';
  res.sendFile(path.join(__dirname, 'public', file));
});

// Explicit /login route — for redirects from Caddy with ?return=
app.get('/login', (req, res) => {
  const session = getSession(req);
  if (session) return res.redirect(safeReturn(req.query.return) || '/');
  res.sendFile(path.join(__dirname, 'public', 'login.html'));
});

// Login form submission
app.post('/login', async (req, res) => {
  const { username = '', password = '', returnTo = '' } = req.body || {};
  const userOk = username === USERNAME;
  const passOk = await bcrypt.compare(password, PASSWORD_HASH).catch(() => false);

  if (!userOk || !passOk) {
    const safeRet = safeReturn(returnTo);
    const ret = safeRet ? `&return=${encodeURIComponent(safeRet)}` : '';
    return res.redirect(`/login?error=1${ret}`);
  }

  const token = jwt.sign({ user: username }, SECRET, { expiresIn: '7d' });
  res.cookie(COOKIE_NAME, token, {
    domain: COOKIE_DOMAIN,
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: COOKIE_MAX_AGE,
    path: '/',
  });

  return res.redirect(safeReturn(returnTo) || '/');
});

// Logout — clears gateway cookie ONLY. Service-internal sessions are
// untouched on purpose: when the user logs back in, every service is
// instantly accessible again without re-running SSO.
app.get('/logout', (req, res) => {
  res.clearCookie(COOKIE_NAME, {
    domain: COOKIE_DOMAIN,
    path: '/',
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
  });
  // v1.6+: all 5 attributes must match the cookie set in /login above.
  // Recent Chrome enforces attribute parity for HttpOnly/Secure cookies;
  // without them, the clear is treated as a separate cookie and the
  // original persists — silent logout failure.
  res.redirect('/');
});

// ----- Auto-SSO endpoints -----------------------------------------------

// Returns the list of services SSO is configured for. Called by home.html
// to know which iframes to create.
app.get('/sso-services', (req, res) => {
  if (!getSession(req)) return res.status(401).send('Unauthorized');
  res.json({ services: ssoEnabledServices() });
});

// Auto-login proxy. Called from a hidden iframe at <service>.pocketcode.in/_sso_init
// (Caddy rewrites that path to here). The response's Set-Cookie headers
// flow back through Caddy as if they came from <service>.pocketcode.in,
// so the browser stores them scoped to that subdomain.
app.get('/sso-init/:service', async (req, res) => {
  if (!getSession(req)) return res.status(401).send('Unauthorized');

  const svc = req.params.service;
  const cfg = SSO_CONFIG[svc];
  const creds = SERVICE_CREDS[svc];

  if (!cfg) return res.send(ssoResultPage(svc, 'skipped', 'no config'));
  if (!creds) return res.send(ssoResultPage(svc, 'skipped', 'no credentials'));

  try {
    const upstreamRes = await fetch(cfg.upstream + cfg.loginPath, {
      method: cfg.method,
      headers: { 'Content-Type': cfg.contentType },
      body: cfg.bodyFn(creds),
      redirect: 'manual',
    });

    // Forward every Set-Cookie from the upstream login response. Strip any
    // explicit Domain attribute so the browser defaults the cookie to the
    // response's host (= <service>.pocketcode.in, courtesy of Caddy).
    const cookies = typeof upstreamRes.headers.getSetCookie === 'function'
      ? upstreamRes.headers.getSetCookie()
      : [];

    cookies.forEach(c => {
      const cleaned = c.replace(/;\s*Domain=[^;]+/i, '');
      res.append('Set-Cookie', cleaned);
    });

    const status = upstreamRes.ok ? 'ok' : 'fail';
    return res.send(ssoResultPage(svc, status, `service responded ${upstreamRes.status}`));
  } catch (err) {
    console.error(`[sso] ${svc} failed:`, err.message);
    return res.send(ssoResultPage(svc, 'error', err.message));
  }
});

// Static assets — last so it doesn't shadow routes above
app.use(express.static(path.join(__dirname, 'public')));

// ----- Boot -------------------------------------------------------------

app.listen(PORT, '0.0.0.0', () => {
  console.log(`pocketcode auth gateway listening on :${PORT}`);
  console.log(`  username:       ${USERNAME}`);
  console.log(`  cookie domain:  ${COOKIE_DOMAIN}`);
  console.log(`  session TTL:    7 days`);
  console.log(`  www handling:   defensive 301 if Host starts with www.`);
  const ssoSvcs = ssoEnabledServices();
  if (ssoSvcs.length > 0) {
    console.log(`  auto-SSO:       enabled for ${ssoSvcs.join(', ')}`);
  } else {
    console.log(`  auto-SSO:       disabled (set SERVICE_CREDS_JSON in .env to enable)`);
  }
});
3
Create package.json — Pinned Dependencies

Tiny dependency list. bcryptjs is the pure-JS version (no native compile, works on alpine without build tools). jsonwebtoken for signed cookies. express + cookie-parser for the web framework.

bash · create file
nano ~/ai-stack/auth-gateway/package.json
json · ~/ai-stack/auth-gateway/package.json
{
  "name": "pocketcode-auth-gateway",
  "version": "1.0.0",
  "private": true,
  "description": "Single-user auth gateway for *.pocketcode.in",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "bcryptjs": "^2.4.3",
    "cookie-parser": "^1.4.7",
    "express": "^4.21.2",
    "jsonwebtoken": "^9.0.2"
  }
}
4
Create Dockerfile — Image Build Recipe

Multi-layer image with node:20-alpine base. The healthcheck means ai-doctor can see if the gateway is alive. Dependencies install first (better layer caching when you edit just the source).

bash · create file
nano ~/ai-stack/auth-gateway/Dockerfile
dockerfile · ~/ai-stack/auth-gateway/Dockerfile
FROM node:20-alpine

WORKDIR /app

# Install deps first (better layer caching)
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund && \
    npm cache clean --force

# Copy app
COPY server.js ./
COPY public ./public

EXPOSE 7000

# Healthcheck — Caddy/ai-doctor relies on this being up
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget -q -O- http://127.0.0.1:7000/health || exit 1

CMD ["node", "server.js"]
5
Create run-auth.sh — Build & Launch Script

One-shot script that builds the image and recreates the container. In v1.6+, the script also bind-mounts server.js and public/ read-only into the container — so future edits to either go live on docker restart auth-gateway without a rebuild. Only package.json changes (rare) need a full re-run of this script.

bash · create file
nano ~/ai-stack/auth-gateway/run-auth.sh
bash · ~/ai-stack/auth-gateway/run-auth.sh
#!/bin/bash
# ============================================================================
#  Build and launch the pocketcode auth-gateway container.
#  Reads credentials from ~/ai-stack/auth-gateway/.env
#
#  v1.6+: bind-mounts server.js and public/ from the host (read-only) so
#  edits on disk are picked up by `docker restart auth-gateway` without
#  rebuilding. node_modules stays in the image — no mount touches it.
# ============================================================================
set -e
cd ~/ai-stack/auth-gateway

docker build -t pocketcode-auth-gateway:latest . >/dev/null

docker rm -f auth-gateway 2>/dev/null || true
docker run -d \
  --name auth-gateway \
  --network ai-stack \
  --restart unless-stopped \
  --env-file ~/ai-stack/auth-gateway/.env \
  -v "$HOME/ai-stack/auth-gateway/server.js:/app/server.js:ro" \
  -v "$HOME/ai-stack/auth-gateway/public:/app/public:ro" \
  pocketcode-auth-gateway:latest

echo "auth-gateway started on internal port 7000 (ai-stack network)"
echo "Caddy reaches it via: http://auth-gateway:7000"
echo ""
echo "Bind-mounted (read-only):"
echo "  $HOME/ai-stack/auth-gateway/server.js -> /app/server.js"
echo "  $HOME/ai-stack/auth-gateway/public    -> /app/public/"
echo "Future workflow: edit on host, then 'docker restart auth-gateway'"

Make it executable:

bash
chmod +x ~/ai-stack/auth-gateway/run-auth.sh
6
Create public/login.html — Login Page

The login form users see at pocketcode.in when logged out, or when redirected from any other subdomain. Key features:

  • Reads ?return=<url> from query string and stores it in a hidden form field
  • Form POSTs to /login with username + password + returnTo
  • Shows error banner if URL has ?error=1
  • Dark theme matching the rest of the stack (Syne for branding, JetBrains Mono for body)
  • Gradient background and subtle glow effects
  • noindex,nofollow meta — search engines won't crawl it
bash · create file
nano ~/ai-stack/auth-gateway/public/login.html
html · ~/ai-stack/auth-gateway/public/login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>pocketcode.in — Sign in</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
  :root{
    --bg:#0a0e1a; --surface:#141926; --surface-2:#1a2030;
    --border:rgba(255,255,255,.08); --border-hover:rgba(255,255,255,.18);
    --text:#e2e8f0; --muted:#64748b; --dim:#475569;
    --accent:#7c3aed; --cyan:#00d4ff; --red:#ef4444;
    --glow:0 0 60px rgba(124,58,237,.15);
  }
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%}
  body{
    font-family:'JetBrains Mono',ui-monospace,monospace;
    background:var(--bg);color:var(--text);
    display:flex;align-items:center;justify-content:center;
    padding:20px;
    background-image:
      radial-gradient(ellipse 80% 50% at 25% 15%, rgba(124,58,237,.10), transparent 60%),
      radial-gradient(ellipse 70% 40% at 80% 85%, rgba(0,212,255,.06), transparent 60%),
      radial-gradient(circle at 50% 50%, rgba(255,255,255,.01), transparent 40%);
  }
  .login-card{
    background:linear-gradient(180deg,var(--surface) 0%,var(--surface-2) 100%);
    border:1px solid var(--border);
    border-radius:18px;
    padding:44px 40px 36px;
    width:100%;max-width:420px;
    box-shadow:0 30px 80px rgba(0,0,0,.5),var(--glow);
    position:relative;overflow:hidden;
  }
  .login-card::before{
    content:'';position:absolute;top:0;left:0;right:0;height:3px;
    background:linear-gradient(90deg,var(--accent) 0%,var(--cyan) 100%);
  }
  .brand{text-align:center;margin-bottom:8px}
  .brand-name{
    font-family:'Syne',sans-serif;font-size:32px;font-weight:800;
    letter-spacing:-0.5px;
    background:linear-gradient(135deg,var(--accent) 0%,var(--cyan) 100%);
    -webkit-background-clip:text;-webkit-text-fill-color:transparent;
    background-clip:text;
  }
  .brand-tag{color:var(--muted);font-size:11px;margin-top:6px;letter-spacing:0.5px}
  .divider{
    height:1px;background:var(--border);margin:28px 0;
    position:relative;
  }
  .divider::after{
    content:'SIGN IN';position:absolute;top:50%;left:50%;
    transform:translate(-50%,-50%);
    background:var(--surface);padding:0 12px;
    color:var(--dim);font-size:10px;letter-spacing:2px;
  }
  label{
    display:block;font-size:10px;color:var(--muted);
    margin-bottom:7px;text-transform:uppercase;letter-spacing:1px;font-weight:500;
  }
  input{
    width:100%;background:var(--bg);
    border:1px solid var(--border);border-radius:9px;
    padding:13px 14px;color:var(--text);
    font-family:'JetBrains Mono',monospace;font-size:14px;
    transition:border-color .2s,box-shadow .2s;
    margin-bottom:18px;
  }
  input:focus{
    outline:none;border-color:var(--accent);
    box-shadow:0 0 0 3px rgba(124,58,237,.15);
  }
  input::placeholder{color:var(--dim)}
  button{
    width:100%;
    background:linear-gradient(135deg,var(--accent) 0%,#5b21b6 100%);
    border:none;border-radius:9px;
    padding:14px;color:#fff;
    font-family:'Syne',sans-serif;font-weight:700;
    font-size:13px;letter-spacing:1.5px;text-transform:uppercase;
    cursor:pointer;transition:transform .15s,box-shadow .2s;
    margin-top:6px;
  }
  button:hover{transform:translateY(-1px);box-shadow:0 10px 30px rgba(124,58,237,.4)}
  button:active{transform:translateY(0)}
  .error{
    background:rgba(239,68,68,.08);
    border:1px solid rgba(239,68,68,.3);
    color:var(--red);
    padding:11px 13px;border-radius:8px;
    font-size:12px;margin-bottom:18px;
    display:flex;align-items:center;gap:8px;
  }
  .footer{
    text-align:center;margin-top:24px;
    color:var(--dim);font-size:10px;letter-spacing:0.5px;
  }
  .footer a{color:var(--cyan);text-decoration:none}
  .footer a:hover{text-decoration:underline}
</style>
</head>
<body>
  <div class="login-card">
    <div class="brand">
      <div class="brand-name">pocketcode.in</div>
      <div class="brand-tag">// AI LAB GATEWAY</div>
    </div>
    <div class="divider"></div>

    <div id="error" class="error" style="display:none">
      <span>⚠</span>
      <span>Invalid username or password</span>
    </div>

    <form method="POST" action="/login" autocomplete="on">
      <label for="username">Username</label>
      <input type="text" id="username" name="username" placeholder="admin"
             autocomplete="username" required autofocus>

      <label for="password">Password</label>
      <input type="password" id="password" name="password" placeholder="••••••••••"
             autocomplete="current-password" required>

      <input type="hidden" name="returnTo" id="returnTo">

      <button type="submit">Sign In</button>
    </form>

    <div class="footer">
      Setup guide:&nbsp;<a href="https://setup.pocketcode.in" target="_blank">setup.pocketcode.in</a>
    </div>
  </div>
  <script>
    (function(){
      const params = new URLSearchParams(window.location.search);
      if (params.get('error')) document.getElementById('error').style.display = 'flex';
      const ret = params.get('return');
      if (ret) document.getElementById('returnTo').value = ret;
    })();
  </script>
</body>
</html>
7
Create public/home.html — Service Cards Homepage

The homepage users see at pocketcode.in after logging in. 9 service cards, each opening its target subdomain in a new tab. Customization tips:

  • Add a card: Copy any <a class="card"> block, change the href, name, description, color class
  • Remove a card: Delete the whole <a class="card"> block
  • Change colors: Each card has a unique c-name class — modify the CSS variables at the top
  • Add an analytics ping or anything else: it's plain HTML with no framework, just edit and rebuild
bash · create file
nano ~/ai-stack/auth-gateway/public/home.html
html · ~/ai-stack/auth-gateway/public/home.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>pocketcode.in — AI Lab</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
  :root{
    --bg:#0a0e1a; --surface:#141926; --surface-2:#1a2030;
    --border:rgba(255,255,255,.08); --border-hover:rgba(255,255,255,.18);
    --text:#e2e8f0; --muted:#64748b; --dim:#475569;
    --accent:#7c3aed; --cyan:#00d4ff; --green:#10b981;
    --amber:#f59e0b; --orange:#f97316; --red:#ef4444;
    --emerald:#22c55e; --pink:#ea4b71; --pg-blue:#336791;
    --claude:#cc785c;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  body{
    font-family:'JetBrains Mono',ui-monospace,monospace;
    background:var(--bg);color:var(--text);
    min-height:100vh;
    background-image:
      radial-gradient(ellipse 60% 40% at 20% 10%, rgba(124,58,237,.08), transparent 60%),
      radial-gradient(ellipse 60% 40% at 90% 90%, rgba(0,212,255,.05), transparent 60%);
  }
  .header{
    padding:32px 48px 16px;
    display:flex;justify-content:space-between;align-items:center;
    border-bottom:1px solid var(--border);
    flex-wrap:wrap;gap:16px;
  }
  .brand-block{display:flex;flex-direction:column}
  .brand-name{
    font-family:'Syne',sans-serif;font-size:28px;font-weight:800;
    letter-spacing:-0.5px;
    background:linear-gradient(135deg,var(--accent) 0%,var(--cyan) 100%);
    -webkit-background-clip:text;-webkit-text-fill-color:transparent;
    background-clip:text;
  }
  .brand-tag{color:var(--muted);font-size:11px;margin-top:4px;letter-spacing:0.3px}
  .user-block{display:flex;align-items:center;gap:14px}
  .user-name{color:var(--muted);font-size:12px}
  .user-name strong{color:var(--text)}
  .logout-btn{
    background:transparent;border:1px solid var(--border);
    color:var(--muted);padding:8px 18px;border-radius:6px;
    cursor:pointer;font-family:'JetBrains Mono',monospace;
    font-size:11px;text-decoration:none;letter-spacing:0.5px;
    transition:all .2s;text-transform:uppercase;
  }
  .logout-btn:hover{border-color:var(--red);color:var(--red)}
  .sso-status{
    font-size:10px;color:var(--muted);
    padding:6px 12px;border:1px solid var(--border);
    border-radius:6px;letter-spacing:0.3px;
    display:none;align-items:center;gap:6px;
    background:rgba(0,0,0,0.2);
  }
  .sso-status.visible{display:flex}
  .sso-status .dot{
    width:6px;height:6px;border-radius:50%;
    background:var(--amber);
    box-shadow:0 0 8px currentColor;
  }
  .sso-status.ok .dot{background:var(--green);color:var(--green)}
  .sso-status.partial .dot{background:var(--amber);color:var(--amber)}
  .sso-status.fail .dot{background:var(--red);color:var(--red)}
  .sso-frame{display:none;position:absolute;left:-9999px;width:1px;height:1px;border:0}
  .main{
    padding:36px 48px 60px;
    max-width:1400px;margin:0 auto;
  }
  .section-label{
    font-family:'Syne',sans-serif;
    font-size:11px;color:var(--dim);
    text-transform:uppercase;letter-spacing:2px;
    margin-bottom:8px;
  }
  .section-title{
    font-family:'Syne',sans-serif;
    font-size:22px;font-weight:700;
    margin-bottom:6px;letter-spacing:-0.3px;
  }
  .section-sub{
    color:var(--muted);font-size:12px;
    margin-bottom:28px;
  }
  .grid{
    display:grid;
    grid-template-columns:repeat(auto-fill,minmax(240px,1fr));
    gap:18px;
  }
  .card{
    background:linear-gradient(180deg,var(--surface) 0%,var(--surface-2) 100%);
    border:1px solid var(--border);
    border-radius:12px;
    padding:24px 22px 22px;
    text-decoration:none;color:var(--text);
    transition:all .2s;
    position:relative;overflow:hidden;
    display:flex;flex-direction:column;
  }
  .card::before{
    content:'';position:absolute;top:0;left:0;right:0;
    height:3px;background:var(--accent);
    opacity:.85;
  }
  .card:hover{
    transform:translateY(-3px);
    border-color:var(--border-hover);
    box-shadow:0 16px 40px rgba(0,0,0,.4);
  }
  .card:hover::before{opacity:1;height:4px}
  .card-icon{
    height:54px;margin-bottom:14px;
    display:flex;align-items:center;
    font-size:42px;line-height:1;
  }
  .card-icon img{width:48px;height:48px;object-fit:contain;border-radius:8px}
  .card-name{
    font-family:'Syne',sans-serif;
    font-size:17px;font-weight:700;
    margin-bottom:5px;letter-spacing:-0.2px;
  }
  .card-desc{
    color:var(--muted);font-size:11px;line-height:1.55;
    flex:1;
  }
  .card-foot{
    margin-top:14px;padding-top:12px;
    border-top:1px solid var(--border);
    display:flex;justify-content:space-between;align-items:center;
    color:var(--dim);font-size:10px;
  }
  .card-foot .arrow{
    color:var(--muted);transition:transform .2s;
  }
  .card:hover .arrow{transform:translateX(3px);color:var(--accent)}
  /* Per-card accent colors */
  .c-openwebui::before{background:var(--emerald)}
  .c-sim::before{background:linear-gradient(90deg,#a855f7,#ec4899)}
  .c-n8n::before{background:var(--pink)}
  .c-openclaw::before{background:var(--amber)}
  .c-ollama::before{background:var(--cyan)}
  .c-openrouter::before{background:var(--green)}
  .c-qdrant::before{background:var(--accent)}
  .c-pgadmin::before{background:var(--pg-blue)}
  .c-setup::before{background:var(--orange)}
  .c-claude::before{background:var(--claude)}
  .c-docs::before{background:var(--cyan)}
  .c-terminal::before{background:var(--amber)}
  .footer{
    text-align:center;color:var(--dim);font-size:10px;
    margin-top:48px;padding-top:24px;
    border-top:1px solid var(--border);letter-spacing:0.5px;
  }
  @media(max-width:640px){
    .header{padding:24px 20px 14px}
    .main{padding:24px 20px 40px}
    .grid{grid-template-columns:1fr}
  }
</style>
</head>
<body>
  <header class="header">
    <div class="brand-block">
      <div class="brand-name">pocketcode.in</div>
      <div class="brand-tag">// AI LAB · <span id="status">All systems operational</span></div>
    </div>
    <div class="user-block">
      <div id="sso-status" class="sso-status" title="Auto-SSO syncs your login to every service"></div>
      <div class="user-name">Signed in as <strong id="username">admin</strong></div>
      <a href="/logout" class="logout-btn">Logout</a>
    </div>
  </header>

  <main class="main">
    <div class="section-label">★ Launch Pad</div>
    <h2 class="section-title">AI Tools & Services</h2>
    <p class="section-sub">Click any card to launch the service in a new tab. Authentication carries over via your session cookie.</p>

    <div class="grid">

      <a href="https://chat.pocketcode.in" target="_blank" rel="noopener" class="card c-openwebui">
        <div class="card-icon">
          <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/open-webui.svg"
               alt="Open WebUI" onerror="this.outerHTML='💬'">
        </div>
        <div class="card-name">Open WebUI</div>
        <div class="card-desc">ChatGPT-style interface for local Ollama models. RAG, file uploads, multi-user.</div>
        <div class="card-foot"><span>chat.pocketcode.in</span><span class="arrow">→</span></div>
      </a>

      <a href="https://sim.pocketcode.in" target="_blank" rel="noopener" class="card c-sim">
        <div class="card-icon" style="background:linear-gradient(135deg,#a855f7,#ec4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-family:'Syne',sans-serif;font-weight:900;font-size:36px;letter-spacing:-2px">sim</div>
        <div class="card-name">Sim.ai</div>
        <div class="card-desc">Visual workflow canvas for building AI agents and pipelines.</div>
        <div class="card-foot"><span>sim.pocketcode.in</span><span class="arrow">→</span></div>
      </a>

      <a href="https://n8n.pocketcode.in" target="_blank" rel="noopener" class="card c-n8n">
        <div class="card-icon" style="color:var(--pink);font-family:'Syne',sans-serif;font-weight:900;font-size:34px;letter-spacing:-1px">n8n</div>
        <div class="card-name">n8n</div>
        <div class="card-desc">Workflow automation. Connect APIs, schedule jobs, build integrations.</div>
        <div class="card-foot"><span>n8n.pocketcode.in</span><span class="arrow">→</span></div>
      </a>

      <a href="https://openclaw.pocketcode.in" target="_blank" rel="noopener" class="card c-openclaw">
        <div class="card-icon">🦞</div>
        <div class="card-name">OpenClaw</div>
        <div class="card-desc">AI developer dashboard. Multi-model agent orchestration.</div>
        <div class="card-foot"><span>openclaw.pocketcode.in</span><span class="arrow">→</span></div>
      </a>

      <a href="https://ollama.pocketcode.in" target="_blank" rel="noopener" class="card c-ollama">
        <div class="card-icon">🦙</div>
        <div class="card-name">Ollama API</div>
        <div class="card-desc">Local LLM runtime. Raw API access for programmatic integration.</div>
        <div class="card-foot"><span>ollama.pocketcode.in</span><span class="arrow">→</span></div>
      </a>

      <a href="https://openrouter.pocketcode.in" target="_blank" rel="noopener" class="card c-openrouter">
        <div class="card-icon" style="color:#6366f1;font-family:'JetBrains Mono',monospace;font-weight:900;font-size:42px">⇆</div>
        <div class="card-name">OpenRouter Proxy</div>
        <div class="card-desc">LiteLLM gateway to GPT-4, Claude, Llama, and more cloud models.</div>
        <div class="card-foot"><span>openrouter.pocketcode.in</span><span class="arrow">→</span></div>
      </a>

      <a href="https://qdrant.pocketcode.in/dashboard" target="_blank" rel="noopener" class="card c-qdrant">
        <div class="card-icon" style="color:var(--accent);font-weight:900;font-size:34px">◆</div>
        <div class="card-name">Qdrant</div>
        <div class="card-desc">Vector database for embeddings, semantic search, RAG retrieval.</div>
        <div class="card-foot"><span>qdrant.pocketcode.in</span><span class="arrow">→</span></div>
      </a>

      <a href="https://pgadmin.pocketcode.in" target="_blank" rel="noopener" class="card c-pgadmin">
        <div class="card-icon">🐘</div>
        <div class="card-name">pgAdmin</div>
        <div class="card-desc">PostgreSQL admin GUI. Inspect databases, run queries, manage roles.</div>
        <div class="card-foot"><span>pgadmin.pocketcode.in</span><span class="arrow">→</span></div>
      </a>

      <a href="https://setup.pocketcode.in" target="_blank" rel="noopener" class="card c-setup">
        <div class="card-icon">📖</div>
        <div class="card-name">Setup Guide</div>
        <div class="card-desc">Full installation guide. Public — accessible without login.</div>
        <div class="card-foot"><span>setup.pocketcode.in</span><span class="arrow">→</span></div>
      </a>

      <a href="https://docs.pocketcode.in" target="_blank" rel="noopener" class="card c-docs">
        <div class="card-icon">📚</div>
        <div class="card-name">Service Docs</div>
        <div class="card-desc">How to use each tool in the stack. Public — accessible without login.</div>
        <div class="card-foot"><span>docs.pocketcode.in</span><span class="arrow">→</span></div>
      </a>

      <a href="https://terminal.pocketcode.in" target="_blank" rel="noopener" class="card c-terminal">
        <div class="card-icon">🖥️</div>
        <div class="card-name">Web Terminal</div>
        <div class="card-desc">Browser-based terminal with Docker socket access. Manage the stack from any device.</div>
        <div class="card-foot"><span>terminal.pocketcode.in</span><span class="arrow">→</span></div>
      </a>

    </div>

    <div class="footer">
      Session expires after 7 days of inactivity · Built with help from Claude
    </div>
  </main>

<!-- ──────────────────────────────────────────────────────────────────────
     AUTO-SSO
     ───────
     1. Ask the gateway which services SSO is configured for
     2. Create a hidden iframe per service pointing at
        https://<svc>.pocketcode.in/_sso_init
        (Caddy proxies that to the gateway, which programmatically logs
        the user in to that service and forwards the Set-Cookie back)
     3. Watch for postMessage results to update the status indicator
     4. Refresh every 2 minutes AND on tab focus, so if the user gets
        out-of-band logged out of any service (clicked Logout inside it,
        session expired, etc.) they're silently re-logged-in within
        seconds of returning to this page.

     This block does nothing useful if SERVICE_CREDS_JSON is not set
     on the gateway — /sso-services just returns an empty array.
     ────────────────────────────────────────────────────────────────────── -->
<script>
(function(){
  const REFRESH_MS = 2 * 60 * 1000;  // 2 min
  const statusEl = document.getElementById('sso-status');
  let services = [];
  let results = {};        // service → status string
  let watchdog = null;

  function setStatus(){
    if (services.length === 0) { statusEl.classList.remove('visible'); return; }
    statusEl.classList.add('visible');
    const ok = services.filter(s => results[s] === 'ok').length;
    const total = services.length;
    let cls = 'partial', label = `${ok}/${total} services synced`;
    if (ok === total) { cls = 'ok'; label = `✓ ${total} services synced`; }
    else if (ok === 0 && Object.keys(results).length === total) { cls = 'fail'; label = 'sync failed'; }
    statusEl.className = 'sso-status visible ' + cls;
    statusEl.innerHTML = `<span class="dot"></span><span>${label}</span>`;
  }

  // Create or refresh one hidden iframe per service
  function refresh(){
    services.forEach(svc => {
      let frame = document.getElementById('sso-frame-' + svc);
      if (!frame){
        frame = document.createElement('iframe');
        frame.id = 'sso-frame-' + svc;
        frame.className = 'sso-frame';
        frame.setAttribute('aria-hidden', 'true');
        document.body.appendChild(frame);
      }
      // Cache-bust so each refresh hits the gateway
      frame.src = `https://${svc}.pocketcode.in/_sso_init?t=${Date.now()}`;
    });
  }

  // Each iframe posts back its result via postMessage
  window.addEventListener('message', e => {
    try {
      const m = JSON.parse(e.data);
      if (m && m.type === 'sso-result' && services.includes(m.service)){
        results[m.service] = m.status;
        setStatus();
      }
    } catch(_) {}
  });

  // Boot: fetch service list, then start
  async function boot(){
    try {
      const r = await fetch('/sso-services', { credentials: 'same-origin' });
      if (r.status === 401){
        // Gateway session gone — stop everything; user will be redirected
        if (watchdog) clearInterval(watchdog);
        return;
      }
      if (!r.ok) return;
      const data = await r.json();
      services = Array.isArray(data.services) ? data.services : [];
      if (services.length === 0) return;
      results = {};
      setStatus();
      refresh();
      watchdog = setInterval(refresh, REFRESH_MS);
      window.addEventListener('focus', refresh);
    } catch(_) { /* network blip - try again on next focus */ }
  }

  boot();
})();
</script>
</body>
</html>
8
Build Image & Generate Password Hash

Build the image first — this installs bcryptjs inside, which we then use to generate the password hash (no separate npm install needed):

bash · build image
cd ~/ai-stack/auth-gateway
docker build -t pocketcode-auth-gateway:latest .

Generate the JWT secret (64-char hex):

bash · generate JWT secret
openssl rand -hex 32

Copy that output. Now generate a bcrypt hash of your password using the image you just built:

bash · generate password hash (replace YOUR_PASSWORD)
docker run --rm pocketcode-auth-gateway:latest   node -e "console.log(require('bcryptjs').hashSync('YOUR_PASSWORD', 12))"

Output looks like $2a$12$N7K8... — copy the entire string.

🔒Choose a strong password. This is the single key to your entire stack. 16+ chars, mix of types. Generated bcrypt hash is stored on disk; the plaintext password is never persisted.
9
Create .env File
bash · create file
nano ~/ai-stack/auth-gateway/.env

Paste, then replace the placeholders with the values from Step 8:

env · ~/ai-stack/auth-gateway/.env
# ============================================================================
#  pocketcode auth-gateway — environment template
#  -------------------------------------------------------------------------
#  Copy this to .env and fill in real values:
#    cp .env.example .env && nano .env
# ============================================================================

# ─── Required ─────────────────────────────────────────────────────────────

# 32+ char random string. Generate with:
#   openssl rand -hex 32
JWT_SECRET=REPLACE_WITH_OUTPUT_OF_openssl_rand_hex_32

# Login username for pocketcode.in itself (master credential)
AUTH_USERNAME=admin

# bcrypt hash of the pocketcode password. Generate with:
#   docker run --rm node:20-alpine sh -c "npm i bcryptjs >/dev/null 2>&1 && node -e \"console.log(require('bcryptjs').hashSync('YOUR_PASSWORD', 12))\""
AUTH_PASSWORD_HASH=REPLACE_WITH_BCRYPT_HASH_STARTING_WITH_$2a$12$

# Cookie domain — leading dot makes it work across all subdomains
COOKIE_DOMAIN=.pocketcode.in

# Port (don't change unless you also update the Caddyfile upstream)
PORT=7000

# ─── Optional: Auto-SSO to underlying services ────────────────────────────
#
# When set, the gateway will automatically log the user into each
# downstream service after they sign in at pocketcode.in. A client-side
# watchdog re-runs SSO every 2 min and on tab focus, so any out-of-band
# logout (clicking a service's own "Logout" button, session timeout, etc.)
# is silently undone.
#
# Format: single-line JSON. Use the SAME email/password you used when
# creating each service's admin account during the setup tabs.
#
# Currently supported keys (services with cookie-based login APIs):
#   - "sim"  → Sim.ai (BetterAuth · POST /api/auth/sign-in/email)
#   - "chat" → Open WebUI (POST /api/v1/auths/signin)
#   - "n8n"  → n8n (POST /rest/login)
#
# Leave SERVICE_CREDS_JSON blank or unset to disable auto-SSO entirely.
# Services not listed here (OpenClaw, pgAdmin) still work normally —
# you'll just sign in to them once via their own login form.

SERVICE_CREDS_JSON={"sim":{"email":"admin@example.com","password":"YOUR_SIM_PASSWORD"},"chat":{"email":"admin@example.com","password":"YOUR_OPENWEBUI_PASSWORD"},"n8n":{"email":"admin@example.com","password":"YOUR_N8N_PASSWORD"}}

Save, then lock down permissions:

bash
chmod 600 ~/ai-stack/auth-gateway/.env
10
Launch the Container
bash
bash ~/ai-stack/auth-gateway/run-auth.sh

Verify it's running:

bash · check status
docker ps --filter "name=auth-gateway" --format "{{.Status}}\t{{.Names}}"
docker logs auth-gateway --tail 10

Expected log output:

expected output
pocketcode auth gateway listening on :7000
  username:       admin
  cookie domain:  .pocketcode.in
  session TTL:    7 days
  www handling:   defensive 301 if Host starts with www.

Now jump to Tab 15 Step 5 to wire up Caddy (Caddyfile updates), Step 6 to reload, Step 7 to test the flow. Steps 1-4 of Tab 16 are replaced by Steps 1-10 of this tab.

11
Verify the Logic Flow Works

After completing the Caddyfile updates from Tab 15, walk through these test commands to verify each branch of the flow:

bash · test 1 — www redirect
curl -sI https://www.pocketcode.in/ | head -3

Expected: HTTP/2 301 with location: https://pocketcode.in/

bash · test 2 — apex login page (logged out)
curl -s https://pocketcode.in/ | grep -E "<title>|brand-name"

Expected: title contains "Sign in", brand-name contains "pocketcode.in" ✓

bash · test 3 — protected subdomain bounces to login (no cookie)
curl -sI https://sim.pocketcode.in/ | head -5

Expected: HTTP/2 302 with location: https://pocketcode.in/login?return=https://sim.pocketcode.in/

bash · test 4 — setup page is PUBLIC (no redirect)
curl -sI https://setup.pocketcode.in/ | head -3

Expected: HTTP/2 200 (loads directly, no auth bounce) ✓

bash · test 5 — health endpoint always works
docker exec auth-gateway wget -qO- http://127.0.0.1:7000/health

Expected: OK

All 5 tests pass? Your logic flow is correct: www redirects work, the apex serves the login page when logged out, protected subdomains bounce to login with returnTo set, setup stays public, and the health check is reachable. Now visit https://pocketcode.in in your browser, log in, and click around — the whole experience should feel seamless.