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:
- 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. - 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:7777publishes a workinghttps://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 inkentrydocker-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 -dThis 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 7777Run 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 -dBecause 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/healthA 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
0600and 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-keyare the PEM certificate chain and private key. Both or neither: setting one without the other is a startup error. They can also be supplied asINKENTRY_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
| Flag | Env | Default | Purpose |
|---|---|---|---|
--host | (none) | 127.0.0.1 | Interface to bind. Non-loopback needs both a key and TLS. |
--port | (none) | 7777 | Port to bind. |
--key | (none) | unset | Shared bearer API key, passed inline. Visible in the process table, so prefer --key-file or INKENTRY_SERVER_KEY. |
--key-file | (none) | unset | Read the key from a file (whole contents, trimmed). First-class alternative to INKENTRY_SERVER_KEY. |
| (none) | INKENTRY_SERVER_KEY | unset | Read the key from the environment. |
--tls-cert | INKENTRY_SERVER_TLS_CERT | unset | PEM certificate chain (leaf plus intermediates) for in-process HTTPS. The chain is public. Set with --tls-key. |
--tls-key | INKENTRY_SERVER_TLS_KEY | unset | PEM 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-serverThe 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_urlmust behttps://unless it points at loopback (127.0.0.1,::1, orlocalhost). A non-loopbackhttp://URL is rejected at startup with no override, because the CLI attaches your bearer token to these requests. A deployed server serves thathttps://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.comThe 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-keyproject_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 bundleor 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 pushThis 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 logsstart 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.
| Bind | TLS configured | Key set | Result |
|---|---|---|---|
| loopback | any | any | allow (local HTTP, no key needed) |
| non-loopback | no | any | refuse (no plaintext off-host, keyed or not) |
| non-loopback | yes | no | refuse (remote requires an API key) |
| non-loopback | yes | yes | allow (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 withINKENTRY_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
- Memory guide - kinds, supersede chains, harvesting, and
plumbing push - Config reference -
server_url,server_key,server_ca, andproject_id - CLI reference -
inkentry serverandinkentry memorysubcommands