--- skill_name: self-hosted-setup skill_description: Stand up a self-hosted Contextify Cloud server on this machine, step by step, pausing at the decisions that matter. title: "Self-hosted Contextify: agent setup runbook" --- # Self-hosted Contextify: agent setup runbook You are helping the user stand up their own **Contextify Cloud** server on a machine they operate (a Mac mini, a Linux box, or a private tailnet host). Contextify Cloud syncs their Claude Code and Codex history so they can search it across machines. This is the free, single-user **Personal Self-Hosted** deployment, source-available under the Functional Source License, Version 1.1, Apache 2.0 Future License (FSL-1.1-Apache-2.0). Follow this runbook top to bottom, one step at a time, in the user's shell. If you are re-invoked after an interruption, run the Resume check first. ## Operating contract (read first, follow throughout) - **Two kinds of instruction.** **[YOU RUN]** commands you run in the user's shell. **[USER DOES]** actions the user performs themselves (opening a browser page, using the Mac app, or entering a secret at a prompt). For a [USER DOES] action, walk the user through it and wait; do not attempt it yourself. - **Never print a secret.** Do not echo API keys, database passwords, or the setup token to the screen. This session's transcript is itself indexed by Contextify, so a printed secret becomes searchable history. Generate secrets and write them straight into the local `.env` file without printing the values, and `chmod 600 .env`. - **Never write a `$(...)` command as a line inside `.env`.** Docker Compose reads `.env` literally and does not run commands. Generating a value with `$(...)` inside a shell command that writes to `.env` is fine; a literal `$(...)` sitting in `.env` is not. - **Pause at every [ASK ME].** Stop and ask the user, and do not proceed without their answer, at: the server hostname (Step 1), the ingress method and any system-level network change (Step 2), and before creating the owner account (Step 2). - **Keep it private by default (fail closed).** This server holds the user's whole indexed history. The one blessed ingress is Tailscale, which stays on the user's private tailnet. Do NOT expose the server to the public internet. A public reverse proxy is an advanced, at-your-own-risk exception only: never do it without a distinct [ASK ME] in which the user accepts that the login page becomes internet-reachable and that they own the TLS, authentication, and rate-limiting hardening. - **Stay in bounds.** Work inside the cloned project directory and Docker. Do not send the user's data anywhere and do not push or publish anything. System-level networking (Tailscale, a proxy) happens only after the Step 2 [ASK ME]. - **On any failure, stop.** Show the error and consult Troubleshooting below. If the fix is not listed there, explain your plan and get the user's OK before running it. Never use `sudo`, install system packages, change ports, edit Docker config, or modify user groups without asking first. - **Never work around a security guard.** When the server refuses a default secret, that is correct: rotate the secret, do not disable the check. ## Resume: run this first if you are re-invoked If the user is restarting this after an interruption, do NOT redo completed work or overwrite existing state. Determine where things stand, **without printing any secret value**, then continue from the first incomplete phase: 1. Is the `contextify-cloud-self-hosted` directory already cloned? If so, do not `git clone` again; `cd` into it. 2. Does `.env` exist, and does it contain each required key exactly once: `API_SECRET_KEY`, `DB_PASSWORD`, `CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN`? Check presence and count without printing values, e.g. `grep -c '^API_SECRET_KEY=' .env`. 3. Are the example placeholders gone (no `API_SECRET_KEY=dev-secret-change-me`, no `DB_PASSWORD=contextify`)? `grep -c '^API_SECRET_KEY=dev-secret-change-me$' .env` should be 0. 4. Is `.env` mode `600`? (`ls -l .env`.) 5. Are `ALLOWED_ORIGINS`, `EMAIL_BASE_URL`, `INVITATION_BASE_URL` set to the user's HTTPS origin (not the localhost defaults)? 6. Is the stack up? `docker compose -f docker-compose.selfhosted.yml ps`. 7. Is it healthy? `curl -sS http://127.0.0.1:8443/api/v1/health`. 8. Is HTTPS ingress in place, and does an owner account already exist (the `/setup` page returns 410 once it does)? Resume at the first phase that is not yet complete. If `.env` exists but is incomplete (placeholders still present, or missing the setup token), finish Step 1's `.env` work rather than re-copying `.env.example`. ## What you will do (tell the user first) Four steps: run the server, secure it, point Contextify at it, and verify search and recall. You pause to ask at the choices that matter, and everything runs on the user's machine. ## Step 1: Preflight, choose the hostname, run the server **[YOU RUN] Preflight the tools:** `git`, `openssl`, `curl`, `docker` and `docker compose` must be available (`docker --version`, `docker compose version`, `git --version`, `openssl version`, `curl --version`). If any is missing, STOP and ask the user how they want to install it; do not pick an installer for them. **[ASK ME] Decide the hostname the user's machines will use to reach this server** (a bare hostname, no scheme). It gets baked into the server config and the TLS certificate, so choose it before writing any config. A Tailscale HTTPS hostname (`your-host.your-tailnet.ts.net`) is the simplest: a stable name and a valid certificate that stays on the user's private tailnet. Record the bare hostname (for example `hive.example.ts.net`); the HTTPS origin is then `https://` (do not double the scheme if the user typed one). **[YOU RUN]** Pick a directory the user names (not inside another git repo). If `contextify-cloud-self-hosted` is already there, do not clone again. Otherwise: ``` git clone https://github.com/PeterPym/contextify-cloud-self-hosted.git cd contextify-cloud-self-hosted cp .env.example .env ``` **[YOU RUN]** Generate three secrets and write them into `.env` without printing the values, then remove the example placeholders so each key appears exactly once, then lock the file: ``` printf 'API_SECRET_KEY=%s\n' "$(openssl rand -hex 32)" >> .env printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 20)" >> .env printf 'CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN=%s\n' "$(openssl rand -hex 32)" >> .env grep -v -e '^API_SECRET_KEY=dev-secret-change-me$' -e '^DB_PASSWORD=contextify$' .env > .env.tmp && mv .env.tmp .env chmod 600 .env ``` Confirm each key now appears exactly once without printing values: `grep -c '^API_SECRET_KEY=' .env` should print `1` (same for `DB_PASSWORD` and `CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN`). Notes: - `API_SECRET_KEY` must not stay `dev-secret-change-me`; the server refuses to start on it. - **Set `DB_PASSWORD` before the first start.** PostgreSQL bakes the password when its data volume is first created, so changing it after the first `up` means resetting the volume (which deletes data). - `CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN` is not in `.env.example`; Compose will not start without it. It gates the one-time owner-setup page. **[YOU RUN]** Set the URL values in `.env` to the HTTPS origin from the start of this step (these are not secrets, so editing them normally is fine): - `ALLOWED_ORIGINS`: replace the example value entirely with `https://`. - `EMAIL_BASE_URL` and `INVITATION_BASE_URL`: set both to `https://`. - Leave `SELF_HOSTED=true`. Leave the `DATABASE_URL` line (this Compose file ignores it). An email provider is optional: login is by password, and with `RESEND_API_KEY` empty, auth emails are written to the server log instead of sent. **[YOU RUN]** Start the stack (the first run builds the API image and takes a few minutes): ``` docker compose -f docker-compose.selfhosted.yml up -d --build ``` **[YOU RUN]** Confirm local health. The API is loopback-bound on `127.0.0.1:8443` by default, and migrations run on first boot, so wait and retry for up to a minute: ``` curl -sS http://127.0.0.1:8443/api/v1/health ``` A healthy server returns `{"status": "ok", "self_hosted": true}`. If it keeps failing, check `docker compose -f docker-compose.selfhosted.yml logs api --tail 50`. Create the owner account in Step 2, once HTTPS is in place at the real hostname. ## Step 2: Secure it (Tailscale), then create the owner account The API listens on loopback. Put HTTPS in front at the hostname chosen in Step 1, so the certificate proves that hostname and the server stays private. **[ASK ME] before any system-level network change.** Confirm the ingress method and get the user's approval before touching anything outside the project directory. **Tailscale (the blessed path, tailnet-only):** 1. Prereq: the user enables MagicDNS and HTTPS certificates in the Tailscale admin console for their tailnet, and this machine is on the tailnet (`tailscale status`). 2. **[YOU RUN]** Confirm the version and syntax (the `serve` CLI has changed across versions): `tailscale version` and `tailscale serve --help`. 3. **[YOU RUN]** Serve the loopback API in the background (persistent, non-blocking) at the tailnet HTTPS hostname. The approved shape proxies HTTPS to `http://127.0.0.1:8443`; use the form your version's `--help` shows, for example: ``` tailscale serve --bg http://127.0.0.1:8443 ``` Use `tailscale serve`, never `tailscale funnel` (`funnel` exposes the server to the public internet, which this deployment does not want). `--bg` keeps it running without holding the foreground. 4. **[YOU RUN]** Validate the config and the live HTTPS path against the real hostname: ``` tailscale serve status --json curl -sS https:///api/v1/health ``` The health call must return `{"status": "ok", "self_hosted": true}` over HTTPS before you continue. **Public reverse proxy (advanced, at your own risk, not the default):** only if the user explicitly asks to expose the server publicly. This makes the login page internet-reachable and puts TLS, authentication, and rate-limiting hardening on the user. Do not take this path without a distinct **[ASK ME]** in which the user accepts those consequences. If they do, they terminate TLS at their hostname (for example with Caddy) and proxy to `127.0.0.1:8443`. **[YOU RUN]** Once HTTPS is in front, match the config to the final origin: - Confirm `ALLOWED_ORIGINS`, `EMAIL_BASE_URL`, and `INVITATION_BASE_URL` in `.env` all equal `https://`. - Set `FORCE_SECURE_COOKIES=true` in `.env` (the example ships it `false` for local HTTP validation). - Rerun `docker compose -f docker-compose.selfhosted.yml up -d` so the container picks up the change. **[ASK ME] before creating the owner account**, then have the **[USER DOES]** it (both paths involve a secret, so the user runs them, not you). Personal Self-Hosted is single-user; this first account is the server's owner. Confirm the email with the user first, then either: - The user opens the one-time setup page at the real HTTPS hostname in their browser: `https:///setup?token=THEIR_SETUP_TOKEN` (the user reads `CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN` from their own `.env`; do not print it). Or - The user runs, in their own terminal, `docker compose -f docker-compose.selfhosted.yml exec api python -m contextify_cloud create-admin --email them@example.com` and types the password when prompted. Do not pass `--password` on the command line (it would land in shell history and the transcript). **[YOU RUN] Confirm the owner exists** before moving on: the setup page now returns 410 (`curl -sS -o /dev/null -w '%{http_code}' https:///setup`), which means an owner account exists. The workspace name field on the setup form is only a label for the single-user server. The setup token becomes inert once the owner exists. ## Step 3: Point Contextify at it Self-hosted servers connect with an API key (the browser sign-in flow is a managed-Hosted convenience and is not used for a custom server URL). **[USER DOES] Mint the key:** in the self-hosted dashboard (at the HTTPS origin), the user signs in and creates a sync-capable API key (it starts with `ctx_`) from Cloud settings. A search-only key is not enough to upload. - **Mac app ([USER DOES]):** Settings, then Cloud, choose Contextify Cloud Self-Hosted, enter the server URL, and paste the `ctx_` key. - **CLI:** `contextify cloud setup --url https://` **[YOU RUN]** prompts for the API key interactively; the **[USER DOES]** types (or pastes) the `ctx_` key at the prompt, which is an interactive read, not a shell argument, so it stays out of history. Then **[YOU RUN]** `contextify cloud status --json` and `contextify cloud sync`. A headless form exists (`contextify cloud setup --url ... --key ctx_... --no-input`); if it is used, the user runs it so the key does not pass through an agent-typed argument. If the server is reachable only inside a tailnet, run the connect commands from a device on that tailnet. ## Step 4: Verify search and recall - **[YOU RUN]** `contextify cloud status --json` and confirm recent projects have synced. The read-only dashboard is also at the HTTPS origin. - Search from the Mac app or the dashboard for a term you know is in a past session. Results come from the user's own server. - Pull a past decision or fix into the current work with Total Recall: `/total-recall` in Claude Code, or `$total-recall` in Codex. If search is empty right after connecting, the app finishes indexing local history before it syncs, so a large first run takes time. That is expected, not a failure. ## Verify checklist - `curl -sS http://127.0.0.1:8443/api/v1/health` returns `{"status": "ok", "self_hosted": true}`. - `curl -sS https:///api/v1/health` returns the same over HTTPS. - The owner account exists (the `/setup` page returns 410) and can sign in at the HTTPS URL. - `contextify cloud status --json` shows the self-hosted server and recent sync. - A search returns a known past result. ## Troubleshooting - **Compose will not start, complains a variable must be set:** a required secret is missing from `.env`. The most common is `CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN`, which is not in `.env.example` and must be added. - **Server refuses to start on the API secret:** `API_SECRET_KEY` is still `dev-secret-change-me`. Rotate it; do not disable the guard. - **API cannot reach the database after changing the password:** `DB_PASSWORD` was changed after the volume was initialized. Set it before the first `up`. Re-initializing the volume is **destructive**: `docker compose -f docker-compose.selfhosted.yml down -v` deletes all synced history in that volume. Do not run it without the user's explicit confirmation, and tell them exactly what is lost. - **`/setup` does nothing over the loopback address:** the API is loopback-bound, so `http://127.0.0.1:8443/setup` is unreachable from another device. Use the real HTTPS hostname, or the `create-admin` command. - **`tailscale serve` seems to hang or the HTTPS health check fails:** confirm MagicDNS and HTTPS certs are enabled in the Tailscale admin, that you used `--bg`, and that `tailscale serve status --json` shows the proxy to `127.0.0.1:8443`. Never switch to `tailscale funnel` to "fix" reachability; that exposes the server publicly. - **Port 8443 already in use:** something else is bound to it. Ask the user how they want to proceed; do not silently edit the compose file's ports. - **Linux `docker: permission denied`:** the user is not in the `docker` group (which is root-equivalent). Ask before changing group membership; do not reflexively run `sudo docker`. - **Cannot resolve or reach the hostname:** separate DNS, tailnet routing, port reachability, and certificate identity. For Tailscale, confirm the machine is on the tailnet and the certificate matches the `.ts.net` name.