Private deployment
The stack runs unchanged as an organization-internal service: private
GitHub repository, the same routines and publishing chain, and the reader
site served by your own web server instead of GitHub Pages. This is the
supported mode when the intelligence you handle — including anything dropped
under intel/ — should not be world-readable. The
pipeline itself has no TLP or visibility gate: it processes everything it
can read, so keeping material non-public is a function of the repo being
private and the site being internally hosted, not of any config flag.
What changes, what doesn't
| Piece | Public (default) | Private |
|---|---|---|
| Repository | public | private (routines, auto-merge, compose workflows all work identically) |
config/org-profile.yaml deployment.site_url |
https://ctipilot.ch/ |
your internal URL, or "" to skip site polling |
| Site hosting | GitHub Pages via deploy-site.yml |
internal web server pulling + building on a schedule |
| TLP / visibility gate | none (removed) | none (removed) — privacy comes from the private repo + internal hosting |
| Routine phases | identical | identical — only Phase 7's site poll adapts to site_url |
site/build.py is stdlib-only and emits a fully static bundle into
site/_site/ — no server-side code, so any static web server can host
it. All assets are vendored (strict CSP, no external requests), which means
the site works on an air-gapped network segment.
Step-by-step
- Make the repository private (or create a private fork/mirror your
routines run against). Verify the scheduled Claude Code routines and the
GitHub App still have access;
auto-merge-claude.yml,compose-profile.yml, andsource-health.ymlkeep working — Actions run fine on private repos (minutes are metered). - Disable GitHub Pages publishing: either disable Pages in the repo
settings, or delete/disable
.github/workflows/deploy-site.yml(it is the only Pages-specific piece; nothing else references it). - Point the deployment at your host and compose:
# config/org-profile.yaml
# deployment:
# site_url: "https://cti.intra.example.ch/" # or "" to skip Phase 7 site polling
python3 tools/compose_prompts.py --write && git add config prompts .claude && git commit
There is no visibility key — the pipeline processes everything it can
read regardless; privacy is enforced by the private repo and internal
hosting, not a config flag. An empty site_url makes the routine's
Phase 7 report publish: ok (main — site polling disabled) after the
auto-merge lands, instead of polling a site it cannot reach.
- Host the site internally — scheduled pull + build + serve. On the internal web host:
# /opt/ctipilot/refresh.sh — pull main, rebuild, atomically swap the webroot
set -euo pipefail
cd /opt/ctipilot/repo # git clone <repo-url> repo (once)
git fetch origin main && git reset --hard origin/main
python3 site/build.py # writes site/_site/
rsync -a --delete site/_site/ /var/www/ctipilot/
Schedule it (systemd timer shown; cron works the same):
# /etc/systemd/system/ctipilot-refresh.service
[Service]
Type=oneshot
User=ctipilot
ExecStart=/opt/ctipilot/refresh.sh
# /etc/systemd/system/ctipilot-refresh.timer
[Timer]
OnCalendar=*:0/15 # every 15 min; entries land throughout the day
Persistent=true
[Install]
WantedBy=timers.target
Serve /var/www/ctipilot/ with any static server. nginx example:
server {
listen 443 ssl;
server_name cti.intra.example.ch;
root /var/www/ctipilot;
index index.html;
# Mirror the Pages behaviour: extensionless pretty URLs resolve to dirs.
location / { try_files $uri $uri/ $uri/index.html =404; }
}
Quick local preview without a real server:
python3 -m http.server --directory site/_site 8080.
- Access control is the web server's job (VPN-only vhost, mTLS, SSO proxy — whatever your estate uses). The bundle itself is inert static HTML; there is nothing session-based to protect inside it.
- Ops dashboard included —
/ops/ships in the same bundle, so the operator view is available internally with no extra steps.
Notes and sharp edges
- The routine cannot verify your internal site. With
site_urlset to an internal URL, the Anthropic-managed routine container generally cannot reach it; leavesite_url: ""and treat "onmain" as the routine's definition of published — the timer owns the last mile. If you pointsite_urlat a URL the container can reach, Phase 7 polls it as usual. - Custom domain artefacts:
CNAMEand.nojekyllare Pages-specific and harmless to keep; deleteCNAMEif you disable Pages to avoid confusion. - Feed scripts (intel drops, source-health cron) need push access to the private repo — deploy keys or a machine PAT scoped to the repo.
- Back to public: re-enable
deploy-site.ymland make the repo public — but first auditintel/and every published entry, because the pipeline applies no TLP filter and git history is forever once public. Anything you would not want world-readable must be scrubbed from history before the repo goes public.