Skip to content

Self-hosting

uris 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/urisrb/uris: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: uris_production, uris_production_cache, uris_production_queue, and uris_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 uris:tenants, and bin/rails uris: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.

uris 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-uris: &uris
image: ghcr.io/urisrb/uris:latest
environment:
POSTGRES_HOST: postgres
URIS_DATABASE_PASSWORD: ...
OPENSEARCH_URL: http://opensearch:9200
SECRET_KEY_BASE: ...
ENCRYPTION_PRIMARY_KEY: ...
ENCRYPTION_DETERMINISTIC_KEY: ...
ENCRYPTION_KEY_DERIVATION_SALT: ...
URIS_TENANTS: acme
URIS_HOST_SUFFIX: uris.example.com
URIS_PUBLIC_ORIGIN: https://%{subdomain}.uris.example.com
MASKS_ISSUER_TEMPLATE: https://%{subdomain}.auth.example.com
URIS_FILESYSTEM_ROOTS: /data/files
volumes:
- storage:/rails/storage
- files:/data/files
services:
uris:
<<: *uris
depends_on:
- postgres
- opensearch
worker:
<<: *uris
command: ["./bin/jobs"]
depends_on:
- uris
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:
- uris
volumes:
storage:
files:
postgres:
opensearch:
caddy-data:

initdb/01-uris.sql creates the role uris connects as, with the password in URIS_DATABASE_PASSWORD:

CREATE ROLE uris WITH LOGIN PASSWORD '...' CREATEDB;
*.uris.example.com {
reverse_proxy uris:80
}

Keep Postgres and OpenSearch on a network that only uris 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 uris always connects as the role uris. POSTGRES_USER is not read there. The password comes from URIS_DATABASE_PASSWORD.

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

    CREATE ROLE uris 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 uris and leave CREATEDB off.

  2. Confirm the role holds neither SUPERUSER nor BYPASSRLS:

    SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'uris';

    Both must be f. Row-level security keeps each tenant’s rows apart, and a role holding either attribute is exempt from it. uris refuses to enter any tenant when it connects as such a role.

The official Postgres image makes POSTGRES_USER a superuser, so uris cannot use it. The development stack creates the uris 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. uris 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 uris 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 uris:tenants with an error.

    URIS_TENANT=uris One tenant answers on every host.
    URIS_TENANTS=demo,acme Each tenant answers on hosts whose first label is its name, such as demo.uris.example.com.
  2. Set URIS_HOST_SUFFIX to the domain uris answers under, such as uris.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 URIS_PUBLIC_ORIGIN to the origin browsers use. %{subdomain} is replaced by the first label of the host:

    Terminal window
    URIS_PUBLIC_ORIGIN=https://%{subdomain}.uris.example.com

    With a single tenant, give the origin without the placeholder, such as https://uris.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 uris resolves the tenant from the host.

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

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

uris uses storage in two places.

  • Staging. Uploads are staged through Active Storage before a job places them. The default, URIS_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 URIS_STAGING_SERVICE=s3 and the URIS_STAGING_BUCKET, URIS_STAGING_ENDPOINT, URIS_STAGING_REGION, URIS_STAGING_ACCESS_KEY_ID, and URIS_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 URIS_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.

uris 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
URIS_S3_ORIGINS An s3 endpoint, such as a MinIO container at http://minio:9000
URIS_MCP_ORIGINS An MCP server attached as a resource
URIS_SEARCH_ORIGINS A self-hosted web search backend
URIS_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. URIS_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 uris reads, with its default, is on the ENV vars page. A production deployment sets at least these:

Database POSTGRES_HOST, POSTGRES_PORT, URIS_DATABASE_PASSWORD
Search OPENSEARCH_URL
Secrets SECRET_KEY_BASE, ENCRYPTION_PRIMARY_KEY, ENCRYPTION_DETERMINISTIC_KEY, ENCRYPTION_KEY_DERIVATION_SALT
Tenancy URIS_TENANT or URIS_TENANTS, URIS_HOST_SUFFIX, URIS_PUBLIC_ORIGIN
Sign-in MASKS_ISSUER_TEMPLATE
Storage URIS_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 URIS_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 uris 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.

uris 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 uris 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 uris:tenants and uris:resources.

To add a tenant, add it to URIS_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 uris NOSUPERUSER NOBYPASSRLS;.
  • Tenant::TenancyConflict: both URIS_TENANT and URIS_TENANTS are set. Remove one.
  • The container exits with URIS_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 URIS_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”: URIS_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 URIS_S3_ORIGINS.
Terminal window
curl https://acme.uris.example.com/.well-known/oauth-protected-resource

The metadata names the tenant’s resource URL, its masks issuer, and every uris: scope. Then open the tenant in a browser, choose Connect a sign-in server, and attach a resource.