Skip to content

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.

There is no published image; the compose file builds one from the repo.

Terminal window
git clone https://github.com/sugihAF/contexo
cd contexo/docker
cp .env.example .env
# edit .env — CONTEXO_API_KEY must be set; compose refuses to start without it
docker compose up -d
curl 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.

Terminal window
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-server

SQLite is a pure-Go driver, so CGO_ENABLED=0 builds fine and the binary is static.

A minimal systemd unit:

[Unit]
Description=Contexo server
After=network.target
[Service]
User=contexo
ExecStart=/usr/local/bin/contexo-server
EnvironmentFile=/etc/contexo.env
Restart=on-failure
[Install]
WantedBy=multi-user.target

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.

Under CONTEXO_DATA_ROOT:

  • <repo_id>/ — one git working repository per project, created with git init --initial-branch=main and a committer identity of contexo-server <server@contexo.local>. Real git log, git show and git diff work in there; that history is the source of truth for knowledge pages, and it is what ctx history, ctx diff and ctx evolution read. A repo id created through POST /v1/repos must match [A-Za-z0-9][A-Za-z0-9_-]{0,63}; on the other creation paths (a first ctx push, the legacy POST /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_keys and repo_activity (the feed behind ctx 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.

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:

Terminal window
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:

Terminal window
docker compose stop
docker run --rm -v contexo_data:/src -v "$PWD":/backup alpine \
tar -czf /backup/contexo-$(date +%F).tar.gz -C /src .
docker compose start

Restoring is untarring into an empty data root. Nothing in the data root is derived — no index to rebuild, no cache to warm.

The server speaks plain HTTP and has no TLS of its own. Bind it to loopback and let the proxy be the only ingress:

Terminal window
CONTEXO_LISTEN_ADDR=127.0.0.1:8080

Caddy:

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.

Terminal window
ctx remote set https://ctx.example.com
ctx remote set-repo acme-api
ctx auth login --server https://ctx.example.com --token ctxp_…
ctx remote get
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.

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.