Self-hosting inkentry-server

Run inkentry-server as a shared team service so a team can sync project memory, with native in-process TLS, systemd, and Docker.

inkentry-server does two jobs, and it helps to keep them separate:

  1. Local inference server (automatic). The CLI starts a local instance for you in the background to provide embeddings and LLM inference, see getting started. It listens only on loopback and is inference only: it never stores memory. Your memory stays in the project's local memory.db. There is nothing to set up for this, and the rest of this page does not apply to it.
  2. Team memory server (optional, deployed). The same binary, run as a long-lived service, lets a team share project memory (decisions, context, requirements) without sharing code. Each developer's code index stays local; only memory entries travel to the server.

Pointing everyone at an explicit server_url is the usual way to move memory off the local machine, but it is not the only one: inkentry hooks install --pre-push publishes your memory to teammates over git notes on git push, with no server involved (ADR-069). This page covers the deployed team server.


Team server, in one shape

Run inkentry-server bound to a routable interface, terminating HTTPS itself with a certificate and key you provide, protected by an API key. inkentry-server serves TLS in-process (ADR-066), so nothing sits in front of it: there is no separate TLS terminator to install, own, or keep current.

  • Bare-metal and systemd is the recommended single-host deployment (see Run it under systemd).
  • Docker is an equally valid vehicle for the same shape. With in-process TLS the container binds its routable interface directly and -p 443:7777 publishes a working https:// endpoint (see Docker).

A non-loopback bind is refused unless both TLS and an API key are set (see Non-loopback plaintext binds are refused), so there is no way to expose the server in cleartext, keyed or not. The API key is the app-level guard; putting the server behind a VPN or private subnet is good defence-in-depth on top, not a substitute for the key.


Docker: a team server or a local scaffold

Clone the repo and build the image. The first image build takes around 12 minutes because it compiles the server from source; later builds are cached.

git clone https://github.com/inkentries/inkentry
cd inkentry

docker-compose.yml ships two shapes in one file.

Team-reachable: the team-server profile

The team-server profile binds a routable interface, mounts your certificate and key, publishes the port, and terminates HTTPS in-process:

export INKENTRY_SERVER_KEY=$(openssl rand -hex 32)

INKENTRY_TLS_CERT=/etc/inkentry/tls-cert \
INKENTRY_TLS_KEY=/etc/inkentry/tls-key \
docker compose --profile team-server up -d

This publishes 443:7777, so https://<host> now answers, keyed, with the container serving TLS itself. Save the key: you will distribute it to your team.

echo "INKENTRY_SERVER_KEY=$INKENTRY_SERVER_KEY"

INKENTRY_TLS_CERT / INKENTRY_TLS_KEY are compose-only host-path selectors for the bind mounts; the binary's own variables (set for you inside the container) are INKENTRY_SERVER_TLS_CERT / INKENTRY_SERVER_TLS_KEY, pointing at /tls/cert and /tls/key.

The equivalent as a bare docker run:

docker run -d --name inkentry-server \
  -p 443:7777 \
  -v inkentry-data:/data \
  -v /etc/inkentry/tls-cert:/tls/cert:ro \
  -v /etc/inkentry/tls-key:/tls/key:ro \
  -e INKENTRY_SERVER_KEY \
  -e INKENTRY_SERVER_TLS_CERT=/tls/cert \
  -e INKENTRY_SERVER_TLS_KEY=/tls/key \
  inkentry-server --host 0.0.0.0 --port 7777

Run a long-lived server without --rm, so a restart does not discard the data volume's bookkeeping.

Local scaffold: the default service

The default docker compose up -d service is a local scaffold, not a team server. It runs inkentry-server on loopback inside the container's own network namespace with a persistent named volume and no published port, for poking at the API by hand:

INKENTRY_SERVER_KEY=your-key docker compose up -d

Because it binds 127.0.0.1 inside the container's netns and publishes nothing, curl http://localhost:7777/v1/health from the host cannot reach it. On a fresh machine that curl connection-refuses; on a machine where the CLI's local auto-server is already running it "verifies" the wrong server. Verify the container with its built-in healthcheck instead:

docker compose ps
# NAME              IMAGE                     STATUS
# inkentry-server   inkentry-server:latest    Up 2 minutes (healthy)

healthy means the server answered its own --health-check probe inside the container. To reach the scaffold's API by hand, use a sidecar in the same network namespace (the runtime image is a minimal Debian base with no curl/wget):

docker run --rm --network container:inkentry-server curlimages/curl \
  curl http://127.0.0.1:7777/v1/health

A healthy server answers with its capabilities and operative limits:

{
  "status": "ok",
  "version": "0.9.8",
  "capabilities": ["memory", "index.embed", "search.semantic"],
  "instance_id": "d1f2…",
  "embedding_dim": 896,
  "embedder": { "state": "ready" },
  "limits": {
    "embed_request_timeout_secs": 1800,
    "max_batch_chunks": 256,
    "embedder_token_cap": null
  }
}

index.embed and search.semantic are advertised only once the embedder has finished loading; a server whose embedder is still warming up reports "memory" alone with "embedder": { "state": "loading" }. A bare docker run -p 7777:7777 of the loopback scaffold will not be reachable, because -p forwards host traffic to the container's routable interface, not into its private loopback.


Bring your own certificate

inkentry-server loads an operator-provided PEM certificate chain and private key. It does not obtain or renew certificates itself (no ACME or Let's Encrypt automation): you bring a certificate from wherever you already get one, and you renew it. A certificate with no renewal eventually expires and the server stops answering, so treat renewal as part of running the service.

Any of these are fine:

  • an internal CA your fleet already trusts,
  • certbot (or another ACME client) run out-of-band to produce the PEM files,
  • a cloud-issued certificate.

You need two files:

  • a certificate chain (leaf plus any intermediates), PEM, which is public,
  • a private key, PEM, which is a high-value secret: keep it 0600 and root-owned, and never place it in an environment variable.

Two rules matter when you mint your own leaf. The leaf must be marked basicConstraints=critical,CA:FALSE - rustls rejects a CA:TRUE certificate presented as an end-entity certificate. And its subjectAltName must list every hostname or IP any client will put in server_url; rustls validates the SAN list, with no fall back to the Common Name.


Run with a routable TLS bind

Bind a routable interface and pass the cert, the key, and an API key. The server terminates HTTPS itself:

INKENTRY_SERVER_KEY=$(openssl rand -hex 32) \
inkentry-server \
  --host 0.0.0.0 --port 7777 \
  --tls-cert /etc/inkentry/tls-cert \
  --tls-key  /etc/inkentry/tls-key
  • --host 0.0.0.0 (or a specific routable IP) makes the server reachable off-host. Loopback (127.0.0.1, the default) stays plain-HTTP local-only.
  • --tls-cert / --tls-key are the PEM certificate chain and private key. Both or neither: setting one without the other is a startup error. They can also be supplied as INKENTRY_SERVER_TLS_CERT / INKENTRY_SERVER_TLS_KEY.
  • An API key is required for any non-loopback bind (--key / --key-file / INKENTRY_SERVER_KEY). A routable bind with TLS but no key is refused, as is a routable bind with no TLS.

That is the whole exposure story: https://<host>:7777 now answers, the bearer key is required, and the connection is encrypted by the server with nothing in front of it. The bind flags are distinct from the API-key flags on purpose: --tls-key is the TLS private key, --key/--key-file is the bearer API key, two different secrets.

Bind and auth flags

FlagEnvDefaultPurpose
--host(none)127.0.0.1Interface to bind. Non-loopback needs both a key and TLS.
--port(none)7777Port to bind.
--key(none)unsetShared bearer API key, passed inline. Visible in the process table, so prefer --key-file or INKENTRY_SERVER_KEY.
--key-file(none)unsetRead the key from a file (whole contents, trimmed). First-class alternative to INKENTRY_SERVER_KEY.
(none)INKENTRY_SERVER_KEYunsetRead the key from the environment.
--tls-certINKENTRY_SERVER_TLS_CERTunsetPEM certificate chain (leaf plus intermediates) for in-process HTTPS. The chain is public. Set with --tls-key.
--tls-keyINKENTRY_SERVER_TLS_KEYunsetPEM private key matching --tls-cert. A high-value secret: supply via a systemd credential or a 0600 root-owned file, never an Environment= line.

The key is resolved in precedence order: --key, then --key-file, then INKENTRY_SERVER_KEY, then a systemd LoadCredential=server-key. A blank value from any source falls through to the next.


Run it under systemd

inkentry ships a first-party unit for the team server, packaging/inkentry-server-team.service. It runs the server as a dedicated unprivileged inkentry user, binds a routable interface with TLS, and supplies both the API key and the TLS private key as systemd credentials rather than environment lines, which keeps them out of systemctl show and /proc/<pid>/environ where an Environment= line would leak them to any local user.

# Dedicated user + data dir
sudo useradd --system --home-dir /var/lib/inkentry --shell /usr/sbin/nologin inkentry
sudo install -d -o inkentry -g inkentry -m 0750 /var/lib/inkentry

# The bearer key, as a root-only 0600 file
sudo install -d -m 0755 /etc/inkentry
openssl rand -hex 32 | sudo tee /etc/inkentry/server-key >/dev/null
sudo chmod 0600 /etc/inkentry/server-key

# Bring your own TLS cert chain + private key
sudo install -m 0644 fullchain.pem /etc/inkentry/tls-cert   # public chain
sudo install -m 0600 privkey.pem   /etc/inkentry/tls-key    # root:root, private

# Install and start the unit
sudo install -m 0644 packaging/inkentry-server-team.service \
     /etc/systemd/system/inkentry-server.service
sudo systemctl daemon-reload
sudo systemctl enable --now inkentry-server
sudo systemctl status inkentry-server

The shipped unit's ExecStart and credential lines are:

ExecStart=/usr/local/bin/inkentry-server \
  --host 0.0.0.0 --port 7777 \
  --db /var/lib/inkentry/inkentry.db \
  --tls-cert /etc/inkentry/tls-cert \
  --tls-key %d/tls-key

LoadCredential=server-key:/etc/inkentry/server-key
LoadCredential=tls-key:/etc/inkentry/tls-key

%d expands to $CREDENTIALS_DIRECTORY, where systemd exposes each loaded credential. The certificate chain is public, so it stays a plain readable path; only the two secrets go through LoadCredential=. To rotate the bearer key, replace /etc/inkentry/server-key and sudo systemctl restart inkentry-server, then redistribute it to clients. To renew the certificate, replace /etc/inkentry/tls-cert (and /etc/inkentry/tls-key if the key changed) and restart the service.

If you would rather not manage a static user and data dir, the DynamicUser= variant lets systemd allocate a per-boot UID and create /var/lib/inkentry via StateDirectory=. The trade-off is that the data dir's owner changes across boots, so any out-of-band backup tooling must not assume a fixed UID.


Client configuration

Each developer adds a .inkentry/config.toml at the project root (commit it, it contains no secrets):

# .inkentry/config.toml - commit this
server_url = "https://inkentry.internal.example.com"
project_id = "my-awesome-app"

server_url must be https:// unless it points at loopback (127.0.0.1, ::1, or localhost). A non-loopback http:// URL is rejected at startup with no override, because the CLI attaches your bearer token to these requests. A deployed server serves that https:// itself, so pointing at its TLS endpoint satisfies the rule.

Each developer supplies their own bearer key: it is the one per-developer secret and never goes in a committed file. Set it with inkentry auth set-key, which reads the key from stdin (piped, or an interactive prompt) so it never lands in shell history or ps output:

inkentry auth set-key --server https://inkentry.internal.example.com

The key is stored in your OS secret store (macOS Keychain, Linux Secret Service, Windows Credential Manager), keyed by the server's origin, so keys for two different self-hosted servers never collide. Check what is stored with inkentry auth list-servers (origins only, never key material).

For CI or headless use, the INKENTRY_SERVER_KEY environment variable works everywhere and takes precedence over the stored key:

export INKENTRY_SERVER_KEY=your-shared-api-key

project_id is a human-readable slug (or a raw UUID). It is sent to the server exactly as configured: there is no slug-to-UUID resolution step and nothing is cached, so both a self-hosted inkentry-server and the hosted cloud accept either form as-is (see ADR-005).

Trusting the server's certificate on the client

When the server's certificate chains to a public CA, clients need no extra configuration. When it is signed by a self-signed or internal CA, point the CLI at the CA bundle explicitly with the INKENTRY_SERVER_CA environment variable:

export INKENTRY_SERVER_CA=/etc/inkentry/internal-ca.pem   # PEM CA bundle

or set it per project in .inkentry/config.toml:

server_ca = "/etc/inkentry/internal-ca.pem"

INKENTRY_SERVER_CA overrides the config value. The bundle is added as a trust anchor on top of the built-in roots; TLS verification stays on, and there is no option to disable it. For an internal CA, the bundle must contain the issuing CA certificate, not the server's leaf. If instead you issued a single self-signed leaf (CA:FALSE, with the right SANs), point INKENTRY_SERVER_CA at that same certificate: pinning the leaf as its own trust anchor is supported.


Pointing a remote agent at the server

On a remote host (or in its container), the configuration is identical to a local client; only the URL and the mandatory key change:

export INKENTRY_SERVER_URL=https://inkentry.example.com
export INKENTRY_SERVER_KEY=your-shared-api-key

inkentry status               # should report the server reachable over TLS
inkentry search "auth tokens"

The https:// URL points straight at the server's own TLS listener. The agent's network path to inkentry.example.com is yours to provide: a VPN, Tailscale, or a public DNS record. inkentry does not tunnel traffic; it just needs the URL to resolve and the server to answer.


Migrating existing local memory

If team members have existing local memory.db entries, seed them to the server once .inkentry/config.toml is set up:

inkentry plumbing push

This reads the local memory database and sends all active entries to the server in a batch. Archived entries are skipped by default; pass --include-archived to push them.


Managing the server from the CLI

The inkentry server subcommands manage the local daemon on this machine only. They never act on a configured server_url: lifecycle-managing a remote server over its HTTP API is not something the protocol supports, so with a team server_url set, inkentry server status still reports your local daemon, not the remote one.

inkentry server start     # start the local daemon (no-op if already running)
inkentry server stop      # stop the local daemon
inkentry server status    # whether a local daemon is running, and its PID
inkentry server logs      # tail the local daemon's logs

start reclaims a stale or hung prior daemon on the requested port rather than drifting to a different one, and fails loudly if an unrelated process already holds that port. stop terminates even a wedged daemon whose /v1/health has stopped responding: it sends SIGTERM, escalates to SIGKILL after a bounded wait, and reports success only once the process is confirmed gone.

To run a team server as a long-lived service, use systemd or Docker as above, not these subcommands.


Multiple projects

One server instance supports multiple projects. Each project has its own namespace: entries from project_id = "api" are not mixed with entries from project_id = "frontend". This is an addressing convenience, not an access-control boundary (see Trust model). Projects are auto-created on first write, with no registration step. GET /v1/projects enumerates every project slug on the instance by design; it is not a data leak, it follows directly from the trust model below.


Trust model

An inkentry-server instance is a single trust domain. The shared API key (--key / INKENTRY_SERVER_KEY) is the only access boundary the server has. It answers exactly one question, "does this bearer token match the configured key?", and nothing more: there is no per-project or per-user authorisation layer. Holding a server's key grants full administrator access to every project on that instance: list, read, search, write, supersede, archive, and permanently delete, regardless of which project slug a request names. This is a deliberate decision, not an oversight (see ADR-056).

What this means for you:

  • A shared team server is for one group that already trusts each other, the same trust you extend by giving someone commit access to the repo. Do not put memory for two teams or organisations that must not see each other's data on one instance.
  • Isolation between teams or projects is achieved by running separate server instances, each with its own key and its own database, not by relying on project slugs within one instance.
  • The server enforces this at startup: binding to a non-loopback address with a key configured logs a prominent warning restating exactly this, that every keyholder is a full administrator of every project on the instance.
  • If you need per-project or per-user access control within a single instance, this server does not provide it. The hosted inkentry cloud product provides organisation-scoped isolation if you need that instead.

Non-loopback plaintext binds are refused

inkentry-server refuses to bind a non-loopback address over plaintext HTTP, whether or not a key is set, and there is no opt-out. With no key that would be an open, unauthenticated server; with a key the bearer token would travel across the network in cleartext. The refusal names the interface and port and points at --tls-cert/--tls-key.

BindTLS configuredKey setResult
loopbackanyanyallow (local HTTP, no key needed)
non-loopbacknoanyrefuse (no plaintext off-host, keyed or not)
non-loopbackyesnorefuse (remote requires an API key)
non-loopbackyesyesallow (the remote HTTPS path)

So the supported way to reach the server from another machine (including a container) is a routable bind with --tls-cert/--tls-key and a key, where the server terminates HTTPS itself. Plaintext off-host stays refused with no override.


Production notes

  • The API key is the app-level guard. Putting the server behind a VPN or private subnet is good defence-in-depth on top, not a substitute for the key.
  • The SQLite WAL-mode database handles 2 to 20 concurrent writers comfortably.
  • Back up the database file with your normal database backup process.
  • For large teams or heavy write loads, Postgres support is planned.
  • On a CPU-only host the bundled embedder caps its thread count at startup (max(1, physical cores - 2)) to leave headroom for request serving; override with INKENTRY_EMBED_THREADS. GPU (Metal/CUDA) builds are unaffected.

API reference

All routes require Authorization: Bearer <key> except /v1/health, which is unauthenticated by design (it is the liveness probe used before a client knows whether a key is even needed).

GET    /v1/health
GET    /v1/projects
POST   /v1/projects/{project_id}/memory
GET    /v1/projects/{project_id}/memory           ?kind=&limit=&archived=
GET    /v1/projects/{project_id}/memory/{id}
POST   /v1/projects/{project_id}/memory/search
POST   /v1/projects/{project_id}/memory/batch
DELETE /v1/projects/{project_id}/memory/{id}
POST   /v1/projects/{project_id}/memory/{id}/archive
POST   /v1/projects/{project_id}/memory/{id}/supersede
GET    /v1/projects/{project_id}/memory/since     ?t=<epoch>&limit=
GET    /v1/projects/{project_id}/memory/stream    (Server-Sent Events)
GET    /v1/projects/{project_id}/memory/harvested-shas
GET    /v1/projects/{project_id}/stats
POST   /v1/projects/{project_id}/index/embed      (embedding proxy - vectors not stored)
POST   /v1/projects/{project_id}/search           (query embedding proxy for CLI KNN)
POST   /v1/projects/{project_id}/llm/complete     (SSE - raw LLM completion)

POST /index/embed has its own, much longer request timeout (1800s) than the rest of the API, because a legitimate batch can take minutes on CPU-only hardware. GET /v1/health's limits object advertises the current server's embed_request_timeout_secs, max_batch_chunks, and (when the native embedder is loaded) embedder_token_cap, so a client can size its own batching to the server it is talking to.

Conflict detection

When POST /v1/projects/{project_id}/memory is called, the server checks whether a semantically similar entry already exists (cosine similarity >= 0.92). If a conflict is detected, the response is HTTP 409 with a JSON body:

{
  "stored": true,
  "id": 42,
  "conflicts": [
    { "id": 37, "title": "Previous similar entry", "similarity": 0.97 }
  ]
}

The new entry is stored with a contradicts edge to the conflicting entry. Clients should log or display this warning. Configure the threshold with the --conflict-threshold flag (0.0 to 1.0, default 0.92).


What's next

On this page