Skip to content

Installation & Deployment

TIS runs as a Docker Compose stack. The image is built by GitLab CI from release tags and published as registry.cap-net.cz/tis/tis-platform:<version> and :latest (until v1.40.0 the project was capnet/threat-intel-scrany; an older docker-compose.yml names that image – change it to the new path).

Services

Service Role
tis-worker The daemon: scheduler (feed fetching, pruning) and the REST API on CONFIG_API_PORT (3000). Runs database migrations on every start (npm start → prestart). Proxies /cli to tis-cli. Attached to the external proxy_public network so a reverse proxy can publish it.
tis-cli ttyd web terminal running the interactive CLI, served under /cli of the GUI's host.
tis-db Postgres; data in the tis-db volume.
tis-cache Redis (no persistence): export cache, CLI sessions, live schedule resync between CLI/API and worker.

No port is published to the host; Traefik (v3) publishes tis-worker:3000 on proxy_public (TLS terminates there) through the labels in docker-compose.yml.

Domains

Since v1.44.0. The parts of TIS live on their own hosts, all served by tis-worker, which tells them apart by the Host header:

Part Variable in .env Example Served
API TIS_API_HOST api.scrany.net /v1/… and /api/v1/… (agents), the API browser at /
GUI + CLI TIS_APP_HOST dash.scrany.net the GUI at /, the CLI at /cli, the API again at /api
Landing page TIS_WEB_HOST scrany.net /

The GUI calls the API on its own host (/api/v1), so its session cookie stays first-party and no CORS is involved; only GET /v1/stats (for the landing page) allows any origin. /api/v1 works on every TIS host, so the agent, which appends it to its platform.url, needs just https://<TIS_API_HOST>. TIS_LEGACY_HOST (tis.cap-net.cz) keeps the old paths: /api and /cli keep working for clients that haven't moved, /app and / redirect to the new hosts. Once nothing uses the old domain, drop its aliases from CONFIG_URLS_* and the tis-legacy router in docker-compose.yml.

Moving from one host: point the DNS records at Traefik, set the TIS_* variables and TIS_CERT_RESOLVER (the Traefik certificate resolver), remove CONFIG_API_PUBLIC_URL and CONFIG_CLI_PATH from .env, and add https://<TIS_APP_HOST>/api/v1/auth/oidc/callback to the Authentik provider's redirect URIs. Then switch the agents to platform.url: https://<TIS_API_HOST> and the client scripts to --api-base https://<TIS_API_HOST>/v1/….

Behind Cloudflare

With the hosts proxied by Cloudflare, Traefik and TIS see a Cloudflare edge server as the client – rate limiting of failed sign-ins, the IP filters of tokens and the audit log would all get its address. Both have to trust Cloudflare's ranges for X-Forwarded-For:

  • Traefik (static configuration, the websecure entrypoint):
entryPoints:
  websecure:
    forwardedHeaders:
      trustedIPs: ["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22", "2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32"]
  • TIS in .env:
CONFIG_API_TRUST_PROXY=loopback, uniquelocal, 173.245.48.0/20, 103.21.244.0/22, 103.22.200.0/22, 103.31.4.0/22, 141.101.64.0/18, 108.162.192.0/18, 190.93.240.0/20, 188.114.96.0/20, 197.234.240.0/22, 198.41.128.0/17, 162.158.0.0/15, 104.16.0.0/13, 104.24.0.0/14, 172.64.0.0/13, 131.0.72.0/22, 2400:cb00::/32, 2606:4700::/32, 2803:f800::/32, 2405:b500::/32, 2405:8100::/32, 2a06:98c0::/29, 2c0f:f248::/32

A request that bypasses Cloudflare can't fake its address then: Traefik drops X-Forwarded-For from anyone but Cloudflare. Cloudflare rarely changes the ranges; compare them with the list now and then.

Further: SSL mode Full (strict); a DNS challenge (dnsChallenge.provider=cloudflare) or a Cloudflare Origin CA certificate for Traefik, as the HTTP challenge is unreliable behind the proxy; no Cache Everything rule for the API host (exports carry the token in the query); WebSockets enabled for the CLI. The GUI's assets (/assets/*, hashed names) are sent as immutable for a year, so Cloudflare caches them by itself; its pages are no-cache.

Without Docker Compose the same is set with CONFIG_URLS_* (see Configuration); unset, everything stays on one host at /api, /app, /cli and /.

First installation

  1. Copy docker-compose.yml and .env.sample from the repository to the server.
  2. Create .env from the sample and set at least:
NODE_ENV=production
CONFIG_API_PORT=3000
CONFIG_DB_USER=tis
CONFIG_DB_PASSWORD=<strong password>
CONFIG_DB_NAME=tis
CONFIG_CLI_PORT=7681
TIS_IMAGE_TAG=<version>
TIS_API_HOST=api.example.com
TIS_APP_HOST=dash.example.com
TIS_WEB_HOST=example.com
TIS_CERT_RESOLVER=<Traefik certificate resolver>
CONFIG_OIDC_ISSUER=https://auth.example.com/application/o/tis/
CONFIG_OIDC_CLIENT_ID=<device-flow client id>

All settings are described in Configuration.

  1. Make sure the external network exists: docker network create proxy_public (if your reverse proxy doesn't create it).
  2. Start the stack:
docker compose pull
docker compose up -d
docker compose logs -f tis-worker

The worker waits for healthy tis-db, tis-cache and tis-cli, applies migrations and starts the scheduler and API.

  1. Note the admin token. The first start creates the admin token admin: set its secret with CONFIG_SEED_ADMIN_TOKEN in .env, or take the random one printed once in the log (docker compose logs tis-worker | grep "admin API token"). Example feeds (AbuseIPDB, URLhaus, MalwarePatrol) are only created when CONFIG_SEED_ABUSEIPDB_KEY, CONFIG_SEED_URLHAUS_KEY or CONFIG_SEED_MALWAREPATROL_RECEIPT are set. Installations from before v1.25.2 seeded a token whose secret is in the repository history – rotate it (token:rotate --id 1).

  2. Open the Web GUI at https://<TIS_APP_HOST>/ (or the CLI at https://<TIS_APP_HOST>/cli), sign in and add your sources. For Authentik sign-in in the GUI, add its redirect URI to the provider – see Web GUI.

Accessing the CLI

  • Browser: https://<TIS_APP_HOST>/cli – the worker proxies it to the ttyd container.
  • Shell on the server: docker exec -it tis-cli node /app/src/cli/index.js, or define an alias:
alias tis-cli="docker exec -it tis-cli node /app/src/cli/index.js"

CLI sign-in

Outside development/test the CLI requires a sign-in:

  1. Authentik (SSO) via the OAuth 2.0 Device Authorization Grant – the CLI prints a URL and a code, you approve it in the browser. Used when CONFIG_OIDC_ISSUER and CONFIG_OIDC_CLIENT_ID are set; it is offered by default.
  2. Username / password – the name and secret of an admin API token. The only option when OIDC isn't configured.

If CONFIG_OIDC_ALLOWED_GROUPS is set, the user must be in one of those groups (groups claim). With Redis available, the session is kept for CONFIG_SESSION_TTL seconds (default 24 h); logout ends it.

Authentik setup

Authentik has no default flow for the device endpoint, so the verification URL returns 404 until you create one:

  1. OAuth2/OpenID Provider for TIS – public client (no secret) unless you set CONFIG_OIDC_CLIENT_SECRET. Device endpoint: https://<authentik>/application/o/device/.
  2. Flow (Flows & Stages → Flows → Create): slug default-device-code-flow, designation Stage Configuration, authentication Require authentication. No stage bindings needed.
  3. Bind it to the brand (System → Brands → edit → Device code flow). This fixes the 404.
  4. Only with CONFIG_OIDC_ALLOWED_GROUPS: add a groups scope mapping and include groups in CONFIG_OIDC_SCOPES (e.g. openid profile email groups).

CONFIG_OIDC_ISSUER is the application issuer, https://<authentik>/application/o/<app-slug>/.

Upgrading

docker compose pull
docker compose up -d

Migrations run automatically when tis-worker starts. Read the release notes (GitLab releases, generated from commit messages) first and back up the database before upgrades that contain migrations:

docker exec tis-db pg_dump -U "$CONFIG_DB_USER" "$CONFIG_DB_NAME" | gzip > tis-$(date +%F).sql.gz

Notable migrations:

Migration Note
20261012000000-lowercase-fqdn Irreversible. Merges domain indicators that differ only in letter case (per source) and lowercases all domains.
20261012100000-add-indicator-permanent, 20261012110000-add-source-manual, 20261012120000-create-groups New columns/tables only.
20261013000000-unique-live-names Source and token names must be unique among live rows only, so names of deleted ones can be reused.
20261013100000-add-json-indicator-type Adds the missing json indicator type; JSON indicators could not be stored before.
20261014000000-create-audit-logs, 20261014100000-indicator-network-index Audit log table; GiST index for network lookups.

The worker shuts down gracefully on SIGTERM (docker stop): running fetches get up to ~4 s to finish, in-flight HTTP requests complete, connections close.

Migrating from SQLite

Versions before the Postgres switch stored data in SQLite. To move the data:

# 1. create the Postgres schema
CONFIG_DB_URL=postgres://tis:tis@localhost:5432/tis npx sequelize-cli db:migrate
# 2. copy the data
CONFIG_DB_URL=postgres://tis:tis@localhost:5432/tis node scripts/migrate-sqlite-to-psql.cjs --sqlite data/production.sqlite

Health and monitoring

  • Docker healthchecks: tis-worker uses GET /healthz (see below), tis-cli answers its ttyd token endpoint, tis-db uses pg_isready, tis-cache redis-cli ping.
  • GET /healthz (no token, not logged) checks the database and Redis: { "status": "ok" | "degraded" | "error", "version", "checks": { "db", "redis" } }. 503 when the database is unreachable; 200 degraded when only Redis is down (no cache, in-memory GUI sessions, no live resync). Point external monitoring at it too.
  • Logs: docker compose logs -f tis-worker – fetch results ([CRON SUCCESS] … Ingested: +N, Updated: ~N, Skipped: !N), pruning, API requests (tokens are redacted). Level via CONFIG_LOG_LEVEL.
  • Errors are reported to Sentry when CONFIG_SENTRY_DSN is set.
  • GET /api/v1/stats (public) returns an approximate indicator count, also shown on the landing page.