Self-hosting the server
The Contexo server is one Go binary. Its state is a git repository per project plus a
single SQLite file — no Redis, no Postgres, no object store. api.contexo.live runs the
same core; the hosted build adds billing routes, admin routes and usage caps through a
public extension seam (app.Run), so nothing you rely on day to day is missing from the
open-source server.
You need git on PATH wherever the server runs: page storage is real git, driven by
shelling out.
Docker
Section titled “Docker”There is no published image; the compose file builds one from the repo.
git clone https://github.com/sugihAF/contexocd contexo/dockercp .env.example .env# edit .env — CONTEXO_API_KEY must be set; compose refuses to start without it
docker compose up -dcurl http://localhost:8080/health # {"status":"ok"}The runtime image is Alpine + git, runs as a non-root user (uid 10001), stores
everything in the named volume contexo_data mounted at /data, and has a healthcheck
that polls /health. CTXHUB_PORT (compose only) changes the published host port.
The compose file forwards four variables: CONTEXO_API_KEY, CONTEXO_SESSION_SECRET,
GOOGLE_OAUTH_CLIENT_ID and CONTEXO_CORS_ORIGINS. The image itself already sets
CONTEXO_DATA_ROOT=/data and PORT=8080; anything else from the table below has to be
added to the environment: block yourself.
Binary
Section titled “Binary”CGO_ENABLED=0 go build -o contexo-server ./cmd/contexo-server
CONTEXO_DATA_ROOT=/var/lib/contexo \CONTEXO_LISTEN_ADDR=127.0.0.1:8080 \CONTEXO_SESSION_SECRET=$(openssl rand -hex 32) \CONTEXO_API_KEY=$(openssl rand -hex 32) \./contexo-serverSQLite is a pure-Go driver, so CGO_ENABLED=0 builds fine and the binary is static.
A minimal systemd unit:
[Unit]Description=Contexo serverAfter=network.target
[Service]User=contexoExecStart=/usr/local/bin/contexo-serverEnvironmentFile=/etc/contexo.envRestart=on-failure
[Install]WantedBy=multi-user.targetEnvironment
Section titled “Environment”Everything the server reads, with the value it uses when unset.
| Variable | Default | What it does |
|---|---|---|
PORT |
8080 |
Port used to build the listen address when CONTEXO_LISTEN_ADDR is unset. |
CONTEXO_LISTEN_ADDR |
:$PORT |
Explicit bind address. Set 127.0.0.1:8080 to bind loopback behind a proxy. |
CONTEXO_DATA_ROOT |
./contexo-data (relative to the working directory) |
Where per-repo git working trees and contexo.db live. Created at boot if missing. |
CONTEXO_SESSION_SECRET |
random per boot | HMAC secret signing session JWTs. Unset logs a warning and invalidates every session on restart — set it in production. |
GOOGLE_OAUTH_CLIENT_ID |
unset | Google OAuth client ID used to verify dashboard ID tokens. Unset means POST /v1/auth/google returns 503 and the server has no user identity at all. |
CONTEXO_API_KEY |
dev-key |
Legacy shared key. Whoever presents it authenticates as the single identity legacy:admin. See the caution below. |
CONTEXO_CORS_ORIGINS |
http://localhost:5173,http://localhost:3000 |
Comma-separated browser origins allowed by CORS. * is honoured. Only matters for browser clients; the CLI and MCP server don’t use CORS. |
CONTEXO_TRUSTED_PROXIES |
127.0.0.1,::1 |
Comma-separated proxy addresses whose X-Forwarded-For is trusted when deriving the client IP for rate limiting. |
CONTEXO_MAX_BODY_BYTES |
8388608 (8 MiB) |
Request body cap. Oversized pushes fail with a 4xx. |
CONTEXO_RATELIMIT_PER_MIN |
300 |
Per-IP token-bucket refill, all routes. |
CONTEXO_RATELIMIT_BURST |
100 |
Per-IP burst, all routes. |
CONTEXO_AUTH_RATELIMIT_PER_MIN |
15 |
Per-IP refill for POST /v1/auth/google specifically — each call runs an RS256 verification plus DB writes. |
CONTEXO_AUTH_RATELIMIT_BURST |
8 |
Per-IP burst for the auth route. |
CONTEXO_RATELIMIT_DISABLE |
unset | 1, true or yes removes both limiters entirely. |
All five numeric settings parse as integers and are only accepted when greater than zero;
anything else silently falls back to the default. /health is exempt from rate limiting
so uptime checks are never throttled.
What lives where
Section titled “What lives where”Under CONTEXO_DATA_ROOT:
<repo_id>/— one git working repository per project, created withgit init --initial-branch=mainand a committer identity ofcontexo-server <server@contexo.local>. Realgit log,git showandgit diffwork in there; that history is the source of truth for knowledge pages, and it is whatctx history,ctx diffandctx evolutionread. A repo id created throughPOST /v1/reposmust match[A-Za-z0-9][A-Za-z0-9_-]{0,63}; on the other creation paths (a firstctx push, the legacyPOST /v1/repos/:id) the id is instead stripped down to[A-Za-z0-9_-]before it becomes a directory name.contexo.db— SQLite holding everything that is not a page:users,personal_access_tokens,repo_members(with roles),repo_invite_keysandrepo_activity(the feed behindctx activity).
Losing contexo.db loses accounts, tokens and membership, but not a single page. Losing
a repo directory loses that project’s pages and their history.
Backups
Section titled “Backups”Page storage is ordinary files, so tar is a legitimate backup. SQLite is the part that
needs care — copy it with a consistent snapshot rather than mid-write:
sqlite3 /var/lib/contexo/contexo.db ".backup '/var/backups/contexo.db'"tar -czf /var/backups/contexo-$(date +%F).tar.gz \ -C /var/lib/contexo --exclude contexo.db .For the Docker volume:
docker compose stopdocker run --rm -v contexo_data:/src -v "$PWD":/backup alpine \ tar -czf /backup/contexo-$(date +%F).tar.gz -C /src .docker compose startRestoring is untarring into an empty data root. Nothing in the data root is derived — no index to rebuild, no cache to warm.
Behind a reverse proxy
Section titled “Behind a reverse proxy”The server speaks plain HTTP and has no TLS of its own. Bind it to loopback and let the proxy be the only ingress:
CONTEXO_LISTEN_ADDR=127.0.0.1:8080Caddy:
ctx.example.com { reverse_proxy 127.0.0.1:8080 { header_up X-Forwarded-For {remote_host} } request_body { max_size 8MB }}nginx:
server { listen 443 ssl http2; server_name ctx.example.com; ssl_certificate /etc/letsencrypt/live/ctx.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/ctx.example.com/privkey.pem;
client_max_body_size 8m; # keep in step with CONTEXO_MAX_BODY_BYTES
location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}If the proxy is not on loopback (a separate host, a container network, a load balancer),
set CONTEXO_TRUSTED_PROXIES to its address. Otherwise X-Forwarded-For is ignored, every
request appears to come from the proxy, and the whole fleet shares one rate-limit bucket.
Pointing clients at it
Section titled “Pointing clients at it”ctx remote set https://ctx.example.comctx remote set-repo acme-apictx auth login --server https://ctx.example.com --token ctxp_…Server: https://contexo.example.com
No repo configured (run 'ctx remote set-repo <id>')Both settings land in the project’s .contexo/config.json; the token goes to
.contexo/credentials.json with mode 0600. Each project is configured independently,
so a self-hosted repo and a hosted one can coexist on the same machine.
Identity: shared key or Google
Section titled “Identity: shared key or Google”This is the decision that shapes the rest of your setup.
| Legacy shared key | Google identity | |
|---|---|---|
| Setup | CONTEXO_API_KEY only |
GOOGLE_OAUTH_CLIENT_ID + CONTEXO_SESSION_SECRET + a dashboard |
| Who you are | one identity, legacy:admin, for everyone |
a real user row per person |
| Push / pull | yes | yes |
Repo init (ctx push on a new repo) |
yes | yes |
GET /v1/repos |
returns every repo on the server | only repos you’re a member of |
| Membership / ownership checks | always pass | enforced |
PATs, minting invite keys, ctx join |
rejected — “user session required” | yes |
GET /v1/me |
rejected — 401 “session required” | yes |
| Listing / revoking invite keys, removing members | allowed — ownership always passes | owner only |
| Activity feed | writes are skipped (no identity to attribute) | recorded |
| Commit author | whatever the client sends | resolved from the account |
The shared key is genuinely convenient for a personal server or an evaluation, and genuinely unsuitable for a team: it is a server-wide admin credential with no attribution and no revocation short of rotating it and restarting.
Google identity is per-user and revocable, with two things to know before you enable it:
One practical wrinkle is bootstrapping the first token. POST /v1/pats accepts any real
user identity — a session token or an existing PAT — and rejects the legacy key, so the
first PAT has to come from a session, and a session only comes from posting a Google ID
token for your GOOGLE_OAUTH_CLIENT_ID to POST /v1/auth/google. That is what the
dashboard’s sign-in does; the open-source server ships no UI of its own, so you either
run a dashboard against it or obtain that ID token yourself. Once one PAT exists it can
mint the next one.
Finally: the open-source server enforces no quotas. Repo and member caps only exist when a hosted build injects a policy, so self-hosted installs are uncapped.
Related
Section titled “Related”ctx remote— pointing a project at a serverctx auth login— tokens and the browser flow- Working as a team — invites, members, activity
