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
websecureentrypoint):
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¶
- Copy
docker-compose.ymland.env.samplefrom the repository to the server. - Create
.envfrom 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.
- Make sure the external network exists:
docker network create proxy_public(if your reverse proxy doesn't create it). - Start the stack:
The worker waits for healthy tis-db, tis-cache and tis-cli, applies migrations and starts the scheduler and API.
-
Note the admin token. The first start creates the admin token
admin: set its secret withCONFIG_SEED_ADMIN_TOKENin.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 whenCONFIG_SEED_ABUSEIPDB_KEY,CONFIG_SEED_URLHAUS_KEYorCONFIG_SEED_MALWAREPATROL_RECEIPTare set. Installations from before v1.25.2 seeded a token whose secret is in the repository history – rotate it (token:rotate --id 1). -
Open the Web GUI at
https://<TIS_APP_HOST>/(or the CLI athttps://<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:
CLI sign-in¶
Outside development/test the CLI requires a sign-in:
- 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_ISSUERandCONFIG_OIDC_CLIENT_IDare set; it is offered by default. - Username / password – the
nameand 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:
- OAuth2/OpenID Provider for TIS – public client (no secret) unless you set
CONFIG_OIDC_CLIENT_SECRET. Device endpoint:https://<authentik>/application/o/device/. - Flow (Flows & Stages → Flows → Create): slug
default-device-code-flow, designation Stage Configuration, authentication Require authentication. No stage bindings needed. - Bind it to the brand (System → Brands → edit → Device code flow). This fixes the 404.
- Only with
CONFIG_OIDC_ALLOWED_GROUPS: add agroupsscope mapping and includegroupsinCONFIG_OIDC_SCOPES(e.g.openid profile email groups).
CONFIG_OIDC_ISSUER is the application issuer, https://<authentik>/application/o/<app-slug>/.
Upgrading¶
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:
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-workerusesGET /healthz(see below),tis-clianswers its ttyd token endpoint,tis-dbusespg_isready,tis-cacheredis-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; 200degradedwhen 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 viaCONFIG_LOG_LEVEL. - Errors are reported to Sentry when
CONFIG_SENTRY_DSNis set. GET /api/v1/stats(public) returns an approximate indicator count, also shown on the landing page.