Self-hosting
xixo ships as a container image. Point it at Postgres, OpenSearch, and a masks server, and it migrates itself on boot.
docker pull ghcr.io/xixo/xixo:latestWhat it runs
Section titled “What it runs”| Web process | The image’s default command, ./bin/thrust ./bin/rails server, listening on port 80 |
| Worker process | ./bin/jobs, from the same image |
| Postgres | Four databases: xixo_production, xixo_production_cache, xixo_production_queue, and xixo_production_cable |
| OpenSearch | The search index, at OPENSEARCH_URL |
| masks | One issuer per tenant, named by MASKS_ISSUER_TEMPLATE |
When the container runs the web command, bin/docker-entrypoint runs bin/rails db:prepare,
bin/rails xixo:tenants, and bin/rails xixo:resources before it starts the server. Those create
or migrate the four databases, create every declared tenant, and declare the
resources in config/resources.yml in every tenant. Any other command starts without them.
xixo stores where each file lives and what its analysis found. The bytes stay in the resources they came from, or in the storage a file was placed in.
Image tags
Section titled “Image tags”| Tag | Pushed on | Platforms |
|---|---|---|
:main |
every push to main | amd64 |
:sha-<full commit sha> |
every push to main | amd64 |
:latest |
every push to main | amd64 |
:latest follows main. Pin a sha- tag or a digest in production, and move it on purpose.
Example compose.yml
Section titled “Example compose.yml”x-xixo: &xixo image: ghcr.io/xixo/xixo:latest environment: POSTGRES_HOST: postgres XIXO_DATABASE_PASSWORD: ... OPENSEARCH_URL: http://opensearch:9200 SECRET_KEY_BASE: ... ENCRYPTION_PRIMARY_KEY: ... ENCRYPTION_DETERMINISTIC_KEY: ... ENCRYPTION_KEY_DERIVATION_SALT: ... XIXO_TENANTS: acme XIXO_HOST_SUFFIX: xixo.example.com XIXO_PUBLIC_ORIGIN: https://%{subdomain}.xixo.example.com MASKS_ISSUER_TEMPLATE: https://%{subdomain}.auth.example.com XIXO_FILESYSTEM_ROOTS: /data/files volumes: - storage:/rails/storage - files:/data/files
services: xixo: <<: *xixo depends_on: - postgres - opensearch
worker: <<: *xixo command: ["./bin/jobs"] depends_on: - xixo
postgres: image: postgres:17-alpine environment: POSTGRES_PASSWORD: ... volumes: - postgres:/var/lib/postgresql/data - ./initdb:/docker-entrypoint-initdb.d:ro
opensearch: image: opensearchproject/opensearch:2.11.0 environment: discovery.type: single-node plugins.security.disabled: "true" OPENSEARCH_JAVA_OPTS: -Xms512m -Xmx512m volumes: - opensearch:/usr/share/opensearch/data
caddy: image: caddy:2-alpine ports: - "443:443" - "80:80" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data depends_on: - xixo
volumes: storage: files: postgres: opensearch: caddy-data:initdb/01-xixo.sql creates the role xixo connects as, with the password in
XIXO_DATABASE_PASSWORD:
CREATE ROLE xixo WITH LOGIN PASSWORD '...' CREATEDB;*.xixo.example.com { reverse_proxy xixo:80}Keep Postgres and OpenSearch on a network that only xixo and the proxy can reach. The OpenSearch security plugin is off in this file, so anything that can reach port 9200 can read every tenant’s index.
The database role
Section titled “The database role”In production xixo always connects as the role xixo. POSTGRES_USER is not read there. The
password comes from XIXO_DATABASE_PASSWORD.
-
Connect to the Postgres server as an administrator and create the role:
CREATE ROLE xixo WITH LOGIN PASSWORD 'change-me' CREATEDB;CREATEDBletsdb:preparecreate the four databases on first boot. To create them yourself instead, create each one withOWNER xixoand leaveCREATEDBoff. -
Confirm the role holds neither
SUPERUSERnorBYPASSRLS:SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'xixo';Both must be
f. Row-level security keeps each tenant’s rows apart, and a role holding either attribute is exempt from it. xixo refuses to enter any tenant when it connects as such a role.
The official Postgres image makes POSTGRES_USER a superuser, so xixo cannot use it. The
development stack creates the xixo role in db/docker-entrypoint-initdb.d/01-app-role.sql, with a
development password.
Encryption & secrets
Section titled “Encryption & secrets”Per tenant, the database holds the masks client’s secret and registration token, and every resource’s credentials. xixo encrypts those columns with Active Record Encryption (AES-256-GCM). Reading them needs the keys as well as the database.
| Secret | Protects |
|---|---|
SECRET_KEY_BASE |
The session cookie. Anyone holding it can forge one. |
ENCRYPTION_PRIMARY_KEY |
The encrypted columns. |
ENCRYPTION_DETERMINISTIC_KEY |
Columns looked up by value. Rails requires it. |
ENCRYPTION_KEY_DERIVATION_SALT |
The per-column keys derived from the two above. |
-
Generate
SECRET_KEY_BASEin a xixo checkout:Terminal window bin/rails secret -
Generate the Active Record encryption keys in the same checkout:
Terminal window bin/rails db:encryption:initIt prints three values. Set them as environment variables:
Printed as Variable primary_keyENCRYPTION_PRIMARY_KEYdeterministic_keyENCRYPTION_DETERMINISTIC_KEYkey_derivation_saltENCRYPTION_KEY_DERIVATION_SALTDo not use the
dev_only_values from.env.exampleorcompose.yml.
Tenants and hosts
Section titled “Tenants and hosts”A tenant is a separate catalog with its own resources, feeds, and settings, at its own host. See tenants for how they are kept apart.
-
Declare the tenants. Set exactly one of these. Setting both stops
xixo:tenantswith an error.XIXO_TENANT=xixoOne tenant answers on every host. XIXO_TENANTS=demo,acmeEach tenant answers on hosts whose first label is its name, such as demo.xixo.example.com. -
Set
XIXO_HOST_SUFFIXto the domain xixo answers under, such asxixo.example.com. Rails then accepts that domain and its subdomains and refuses otherHostheaders./upis exempt. Left unset, Rails checks no hosts at all. -
Set
XIXO_PUBLIC_ORIGINto the origin browsers use.%{subdomain}is replaced by the first label of the host:Terminal window XIXO_PUBLIC_ORIGIN=https://%{subdomain}.xixo.example.comWith a single tenant, give the origin without the placeholder, such as
https://xixo.example.com. The origin followed by/mcpis the audience every access token must name. Production refuses to boot without it. -
Point DNS for each tenant’s host, or a wildcard under the suffix, at the reverse proxy.
RAILS_ASSUME_SSL and RAILS_FORCE_SSL default to true. The proxy must terminate TLS and pass the
original Host header, because xixo resolves the tenant from the host.
Signing in through masks
Section titled “Signing in through masks”-
Set
MASKS_ISSUER_TEMPLATEto the issuer URL of the masks tenant each xixo tenant signs in through.%{subdomain}works as it does inXIXO_PUBLIC_ORIGIN:Terminal window MASKS_ISSUER_TEMPLATE=https://%{subdomain}.auth.example.com -
After xixo is running, open each tenant in a browser and choose Connect a sign-in server. A masks account allowed to approve connections approves it, and xixo stores the credentials it receives. See signing in.
For MCP clients to register themselves, the masks tenant has to allow dynamic registration. See connecting an agent.
Storage
Section titled “Storage”xixo uses storage in two places.
- Staging. Uploads are staged through Active Storage before a job places them. The default,
XIXO_STAGING_SERVICE=local, writes to/rails/storagein the container, so the web and worker containers must share that directory. To stage in S3-compatible storage instead, setXIXO_STAGING_SERVICE=s3and theXIXO_STAGING_BUCKET,XIXO_STAGING_ENDPOINT,XIXO_STAGING_REGION,XIXO_STAGING_ACCESS_KEY_ID, andXIXO_STAGING_SECRET_ACCESS_KEYvariables. - Catalog storage. Files are kept in resources that serve
storage.config/resources.ymldeclares afilesystemresource namedfilesas every tenant’s default storage. It writes inside<root>/<tenant>/for the first directory inXIXO_FILESYSTEM_ROOTS(colon-separated). Without that variable, thefilesresource cannot write. Attachs3orwebdavresources from the app as well. See attaching a resource.
Private networks
Section titled “Private networks”xixo refuses to reach a private, loopback, or link-local address on a resource’s behalf. A service on the same private network needs its origin listed:
| Variable | Lets through |
|---|---|
XIXO_S3_ORIGINS |
An s3 endpoint, such as a MinIO container at http://minio:9000 |
XIXO_MCP_ORIGINS |
An MCP server attached as a resource |
XIXO_SEARCH_ORIGINS |
A self-hosted web search backend |
XIXO_INFERENCE_ORIGINS |
Every model backend, public or private, such as https://api.openai.com,http://ollama:11434 |
Each lists origins separated by commas. A private address whose origin is not listed stays refused.
XIXO_INFERENCE_ORIGINS names public backends too. A model backend whose origin is not listed is
unusable.
See addresses and security.
A service on a Tailscale or Headscale network is reached through a tailnet resource instead. See
reach a tailnet.
Minimum environment
Section titled “Minimum environment”Every variable xixo reads, with its default, is on the ENV vars page. A production deployment sets at least these:
| Database | POSTGRES_HOST, POSTGRES_PORT, XIXO_DATABASE_PASSWORD |
| Search | OPENSEARCH_URL |
| Secrets | SECRET_KEY_BASE, ENCRYPTION_PRIMARY_KEY, ENCRYPTION_DETERMINISTIC_KEY, ENCRYPTION_KEY_DERIVATION_SALT |
| Tenancy | XIXO_TENANT or XIXO_TENANTS, XIXO_HOST_SUFFIX, XIXO_PUBLIC_ORIGIN |
| Sign-in | MASKS_ISSUER_TEMPLATE |
| Storage | XIXO_FILESYSTEM_ROOTS |
The worker
Section titled “The worker”./bin/jobs starts Solid Queue, which runs every job and the recurring schedule in
config/recurring.yml. It has two worker pools, set in config/queue.yml:
| Queues | Threads | Processes |
|---|---|---|
sync, export, default |
5 | BULK_CONCURRENCY (default 1) |
analysis |
ANALYSIS_THREADS (default 4) |
ANALYSIS_CONCURRENCY (default 1) |
The worker shells out to Chromium, LibreOffice, Tesseract, Poppler, FFmpeg, and whisper.cpp, which
the image includes. The worker runs in its own container so that analysis does not slow down web
requests, and so the two scale separately. To run jobs inside the web process on a small host, set
SOLID_QUEUE_IN_PUMA=1 on the web container and run no worker.
Syncs and exports checkpoint as they go, so a worker restarted during one resumes from its last
checkpoint. Set XIXO_ITERATORS_DISABLED=true to stop every iterating job in every tenant.
Follow job progress in the worker’s log:
docker compose logs -f workerMission Control at /jobs
Section titled “Mission Control at /jobs”Mission Control is mounted at /jobs, on any tenant’s host. Its HTTP basic authentication is on,
and xixo sets no user or password for it, so it answers 401 to every request. The dashboard
lists the jobs of every tenant, because Solid Queue’s tables sit outside row-level security. Leave it
closed, or block /jobs at the proxy.
System requirements
Section titled “System requirements”xixo has no fixed CPU or memory floor. The models are the largest cost, and they run wherever the inference resource points, outside these containers.
| OpenSearch | A 512 MB heap is enough for a personal catalog. Give the container about three times the heap. |
| Worker | Chromium and LibreOffice each hold a core while they render. Raise ANALYSIS_CONCURRENCY when analyses queue. |
| Web | RAILS_MAX_THREADS (default 3) sizes Puma’s threads and the connection pool. |
Health check
Section titled “Health check”GET /up answers 200 when the application has booted. It is served outside any tenant, is
exempt from the host check, and is not logged.
docker compose exec -T xixo curl -fsS http://localhost/upUpgrade
Section titled “Upgrade”-
Choose the build: a
sha-<full commit sha>tag or its digest, orlatestfor the newest build of main. -
Pull the image and recreate the containers:
Terminal window docker compose pulldocker compose up -d -
The web container runs
db:prepareon start, which applies new migrations to all four databases, thenxixo:tenantsandxixo:resources.
To add a tenant, add it to XIXO_TENANTS and recreate the web container. It is created on boot with
the files resource, and needs Connect a sign-in server once.
If it doesn’t work
Section titled “If it doesn’t work”- Every page answers
404 Unknown tenant: no tenant variable is set, or the host’s first label names no declared tenant. Tenant::Exposed, naming the role: the database role holdsSUPERUSERorBYPASSRLS. Remove them withALTER ROLE xixo NOSUPERUSER NOBYPASSRLS;.Tenant::TenancyConflict: bothXIXO_TENANTandXIXO_TENANTSare set. Remove one.- The container exits with
XIXO_PUBLIC_ORIGIN is not set: set it to the origin browsers use. - The container exits with
Switch::Invalid: a switch holds a value other thantrueorfalse. The error names it. MASKS_ISSUER_TEMPLATE is not set: sign-in and the/mcpmetadata cannot name an issuer. Set the variable.- Requests answer
403: the request’s host is outsideXIXO_HOST_SUFFIX. - Uploads stage but never land: the worker is not running, or it cannot read the web
container’s
/rails/storage. Checkdocker compose psanddocker compose logs worker. - “no filesystem roots are permitted”:
XIXO_FILESYSTEM_ROOTSis unset, so thefilesresource cannot read or write. - An
s3resource on the private network fails its check: add its origin toXIXO_S3_ORIGINS.
Next steps
Section titled “Next steps”curl https://acme.xixo.example.com/.well-known/oauth-protected-resourceThe metadata names the tenant’s resource URL, its masks issuer, and every xixo: scope. Then open the
tenant in a browser, choose Connect a sign-in server, and
attach a resource.