internkim

Self-hosting levels

Three ways to run internkim, from the host on your own computer to a host you can reach from any network.

A company can run as much of internkim as it wants and leave the rest to intern.kim. The levels below name the common stopping points.

LevelThe hostSupabaseCloudflareReached from other networks
1your computerintern.kimintern.kimno
3your computeryoursyoursno
4your computer or a cloud serveryoursyoursyes, over SSH

Level 1: the host on your own computer

Install the host on a computer the company leaves on, such as a Kim mini, a Mac or a Linux machine. The company's record, the web app and the sign-in address stay with intern.kim. The host connects outward, and once it belongs to a company nothing listens off loopback, so the computer needs no public address.

Level 3: your own Supabase and your own Cloudflare

The record moves to a Supabase project you run, and the web app is deployed to your Cloudflare account and served from your own domain, which CLOUDFLARE_DOMAIN names. Deploying the web app yourself is what lets it read your project's address and keys from its settings. What lives where says which data that project holds. Self-hosted Supabase ships the same Postgres and the same row level security, so the schema in supabase/migrations applies unchanged.

Everyone signs in at your domain itself, so sign-in and the web app no longer depend on intern.kim. Your web app serves install.sh as well, and the host package that script installs still comes from our GitHub Releases.

Deploying the web app

Which Supabase project the app talks to is decided when it runs: hooks.server.ts injects SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY into the page, read through $env/dynamic/private. One build therefore serves any company. The Pages project carries every variable tools/environment.json marks isRequiredOnPages, and web/scripts/set-pages-secrets.ts sets them all as secrets.

Among them, SUPABASE_JWT_SIGNING_KEY is the private key the web app signs personal access tokens, agent sessions and host sessions with. web/scripts/issue-record-signing-key.ts mints one and prints it; import it in the Supabase dashboard under Project Settings → JWT Keys as a standby key, so the project verifies what the app signs.

A production deploy first checks that every migration in supabase/migrations is applied to your project, through the Supabase CLI. Sign in with your own supabase login and run supabase link --project-ref <ref> before deploying; no Supabase access token is kept with the project's keys.

A release therefore goes in one order: supabase db push, then the web app, then the edge functions and the gateway. Each migration is written to hold while the previous web app is still running, so one that asks more of a session accepts the sessions issued before it until they expire.

cd web
bun install
bun run build
bun run scripts/deploy-pages.ts --project <your-pages-project> --output .svelte-kit/cloudflare --production

The script reads CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, and deploys a preview unless --production is passed. Custom domains always serve the production deployment, so a preview answers only on *.pages.dev. Each hostname is attached explicitly with scripts/pages-domains.ts, because Pages custom domains do not take wildcards.

  • Store every Pages variable as a secret, including the public ones. wrangler pages deploy rewrites plain-text variables from its own configuration, so a plain-text value can vanish on the deploy that was meant to start using it.
  • api.<zone> attaches to the same project, because the public API is the same routes.
  • GATEWAY_URL and GATEWAY_ADMIN_TOKEN let an API call reach the company's computer through the connection gateway. Without them an invocation answers 503, while the catalog and the tokens still work.
  • A company whose people use the Buzz desktop or phone app gives the gateway its messenger address, <slug>.<zone>, as a Workers route. That hostname belongs to the messenger and is not attached to Pages, because the relay answers paths under /api/ of its own. A page opened there in a browser is still sent on to the zone with a 308. Deploy the gateway with web/scripts/deploy-worker.ts workers/connection-gateway --route-subdomain <gateway> --route-subdomain <slug>, naming every route it keeps, since a deploy replaces the set.
  • supabase config push sends the whole config.toml, so any setting the file omits returns to the CLI default. Read the diff it prints before trusting the exit code.

Level 4: reachable over SSH from any network

The host still opens no way in, and the product provides none. A level 4 company chooses its own route to the computer:

  • a Cloudflare Tunnel from your own account, with an Access policy in front of it, or a quick tunnel from trycloudflare.com for a single session
  • the host on a cloud server that already has a public address

The tunnel or the server is the operator's. ~/.ssh/config names the route, as for any host.

Through your own Cloudflare Tunnel

We reach our own company's host this way. On the host, a tunnel from your Cloudflare account carries one public hostname to ssh://localhost:22. In Zero Trust, a self-hosted Access application on that hostname admits your operators when they sign in, and a Service Auth policy admits a service token, which scripts use because it needs no browser. From any network:

Host ssh.example.com
  User <account on the host>
  ProxyCommand cloudflared access ssh --hostname %h --service-token-id <client id> --service-token-secret <client secret>

Leave out the two token flags and cloudflared opens a browser sign-in instead. In this repository, tools/cloudflared-access-ssh is that ProxyCommand with the token read from the vault, and ./internkim @host ssh connects through it.

On this page