Skip to content

Self-hosting

xixo ships as a container image. Point it at Postgres, OpenSearch, and a masks server, and it migrates itself on boot.

Terminal window
docker pull ghcr.io/xixo/xixo:latest
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.

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.

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.

In production xixo always connects as the role xixo. POSTGRES_USER is not read there. The password comes from XIXO_DATABASE_PASSWORD.

  1. Connect to the Postgres server as an administrator and create the role:

    CREATE ROLE xixo WITH LOGIN PASSWORD 'change-me' CREATEDB;

    CREATEDB lets db:prepare create the four databases on first boot. To create them yourself instead, create each one with OWNER xixo and leave CREATEDB off.

  2. Confirm the role holds neither SUPERUSER nor BYPASSRLS:

    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.

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.
  1. Generate SECRET_KEY_BASE in a xixo checkout:

    Terminal window
    bin/rails secret
  2. Generate the Active Record encryption keys in the same checkout:

    Terminal window
    bin/rails db:encryption:init

    It prints three values. Set them as environment variables:

    Printed as Variable
    primary_key ENCRYPTION_PRIMARY_KEY
    deterministic_key ENCRYPTION_DETERMINISTIC_KEY
    key_derivation_salt ENCRYPTION_KEY_DERIVATION_SALT

    Do not use the dev_only_ values from .env.example or compose.yml.

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.

  1. Declare the tenants. Set exactly one of these. Setting both stops xixo:tenants with an error.

    XIXO_TENANT=xixo One tenant answers on every host.
    XIXO_TENANTS=demo,acme Each tenant answers on hosts whose first label is its name, such as demo.xixo.example.com.
  2. Set XIXO_HOST_SUFFIX to the domain xixo answers under, such as xixo.example.com. Rails then accepts that domain and its subdomains and refuses other Host headers. /up is exempt. Left unset, Rails checks no hosts at all.

  3. Set XIXO_PUBLIC_ORIGIN to the origin browsers use. %{subdomain} is replaced by the first label of the host:

    Terminal window
    XIXO_PUBLIC_ORIGIN=https://%{subdomain}.xixo.example.com

    With a single tenant, give the origin without the placeholder, such as https://xixo.example.com. The origin followed by /mcp is the audience every access token must name. Production refuses to boot without it.

  4. 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.

  1. Set MASKS_ISSUER_TEMPLATE to the issuer URL of the masks tenant each xixo tenant signs in through. %{subdomain} works as it does in XIXO_PUBLIC_ORIGIN:

    Terminal window
    MASKS_ISSUER_TEMPLATE=https://%{subdomain}.auth.example.com
  2. 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.

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/storage in the container, so the web and worker containers must share that directory. To stage in S3-compatible storage instead, set XIXO_STAGING_SERVICE=s3 and the XIXO_STAGING_BUCKET, XIXO_STAGING_ENDPOINT, XIXO_STAGING_REGION, XIXO_STAGING_ACCESS_KEY_ID, and XIXO_STAGING_SECRET_ACCESS_KEY variables.
  • Catalog storage. Files are kept in resources that serve storage. config/resources.yml declares a filesystem resource named files as every tenant’s default storage. It writes inside <root>/<tenant>/ for the first directory in XIXO_FILESYSTEM_ROOTS (colon-separated). Without that variable, the files resource cannot write. Attach s3 or webdav resources from the app as well. See attaching a resource.

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.

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

./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:

Terminal window
docker compose logs -f worker

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.

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.

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.

Terminal window
docker compose exec -T xixo curl -fsS http://localhost/up
  1. Choose the build: a sha-<full commit sha> tag or its digest, or latest for the newest build of main.

  2. Pull the image and recreate the containers:

    Terminal window
    docker compose pull
    docker compose up -d
  3. The web container runs db:prepare on start, which applies new migrations to all four databases, then xixo:tenants and xixo: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.

  • 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 holds SUPERUSER or BYPASSRLS. Remove them with ALTER ROLE xixo NOSUPERUSER NOBYPASSRLS;.
  • Tenant::TenancyConflict: both XIXO_TENANT and XIXO_TENANTS are 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 than true or false. The error names it.
  • MASKS_ISSUER_TEMPLATE is not set: sign-in and the /mcp metadata cannot name an issuer. Set the variable.
  • Requests answer 403: the request’s host is outside XIXO_HOST_SUFFIX.
  • Uploads stage but never land: the worker is not running, or it cannot read the web container’s /rails/storage. Check docker compose ps and docker compose logs worker.
  • “no filesystem roots are permitted”: XIXO_FILESYSTEM_ROOTS is unset, so the files resource cannot read or write.
  • An s3 resource on the private network fails its check: add its origin to XIXO_S3_ORIGINS.
Terminal window
curl https://acme.xixo.example.com/.well-known/oauth-protected-resource

The 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.