Instance maintenance
The admin panel is currently available in Russian only; menu items and button labels in this guide are translated.
All commands go through tracker-ctl, which install.sh places at
/usr/local/bin/tracker-ctl. It works from any directory and relies on
INOTRACK_HOME (/opt/inotrack by default; docker-compose.yml and .env live there too).
tracker-ctl status # docker compose ps
tracker-ctl logs [service...] # docker compose logs -f --tail=200
tracker-ctl update # new version + migrations + rollback on failure
tracker-ctl backup # Postgres + ClickHouse + .env + storage, local/S3
tracker-ctl backup --to s3://bucket/prefix # + offsite copy, verification, rotation (see "Offsite backups")
tracker-ctl backup keygen # generate BACKUP_ENCRYPTION_KEY (store it off the server!)
tracker-ctl backup schedule daily [--at 03:00] [--to s3://...] # daily timer
tracker-ctl backup schedule off # remove the schedule (same as --remove)
tracker-ctl backup schedule # cron from BACKUP_SCHEDULE_CRON
tracker-ctl restore --from <path|s3://bucket/prefix[/archive]> # restore from a backup
tracker-ctl domain add|rm <domain> # tracker domain pool + Caddy re-created
tracker-ctl license info|refresh # local license status / forced activation-heartbeat
tracker-ctl doctor # diagnostics: DNS/TLS/DB/buffer/queue/disk
tracker-ctl uninstall [--purge] [--yes] [--no-backup] # remove the instance
status / logs
status wraps docker compose ps, logs wraps docker compose logs -f --tail=200 (you can name specific services: tracker-ctl logs tracker-core workers).
doctor
Diagnostics in one command, without reading logs by hand. It checks:
- that docker/docker compose are present;
- required variables in
.env; - the state of the compose containers;
- that Postgres/ClickHouse/Redis are reachable;
- DNS and TLS of every domain in the pool (panel + tracker domains);
- connectivity to the license server;
- how fresh the latest backup is;
- free disk space;
- click buffer lag and the length of the outgoing postback queue (via
metrics-probeinside thetracker-corecontainer).
There are no flags — it runs all checks at once and prints a list of what is wrong.
tracker-ctl doctor
ClickHouse: version and the daily report summary
The product works with any ClickHouse version that starts at all — there is no hard
minimum. The standard install brings up its own ClickHouse, pinned to 24.8; if you
connect a ClickHouse you already run instead (INOTRACK_CH_ADDRS in .env), the version
can be older.
The only thing that depends on the version is the daily report summary (inotrack.stats_daily /
stats_daily_recent, which speeds up reports over long periods): it is built on
REFRESHABLE MATERIALIZED VIEW, which appeared in ClickHouse 23.12. On an older version:
- migrations apply in full, the tracker starts and works normally — click intake, postbacks, finance and the panel do not depend on the summary;
- on startup the central/worker log shows a WARN saying that this ClickHouse version does
not support
REFRESHABLE MATERIALIZED VIEWand the daily report summary will not be created; - reports over long periods (a year or more) are computed directly from the raw tables — the numbers are correct, but on large volumes noticeably slower than with the summary (the report engine checks that the summary is ready before every use and never serves partially filled data).
Upgrading to 23.12+ later requires nothing manual: the next central/worker restart applies the skipped part of the migration and fills the summary.
⚠️ Upgrade of the summary's money logic (migration 201_stats_daily_decision.sql). This
migration recreates stats_daily/stats_daily_recent with a new SELECT (money according to
the network's current decision, not the raw rows) and empties both tables (TRUNCATE) —
the old data was computed with the previous formula and, until refilled, would diverge from
the raw path. After the migration is applied, the tracker itself starts a one-off
SYSTEM REFRESH VIEW in the background and does not block startup; until the summary is
filled, reports over long periods automatically fall back to the raw tables — the numbers
are correct, just slower until the rebuild finishes (usually minutes).
A separate trap is a rolling deploy across several central/worker replicas: the cache of
the summary's readiness (5 minutes) is local to each process, not shared. A replica that has
already restarted after the upgrade and sees an empty (not yet filled) summary may wrongly
cache it as "healthy" for up to 5 minutes before the summary is actually filled — reports
for a UTC period served by THAT replica during that window will show revenue near zero with
non-zero spend (the same consequence as the "cold start" in the previous paragraph, but
caused by one replica's stale local cache rather than by the summary being empty). The
recommended upgrade order in production with several replicas is stop → migrate → start
(the standard tracker-ctl update sequence), not a rolling restart one replica at a time:
that way the migration and the one-off summary fill finish before any replica starts serving
traffic and caching the summary state.
Backup and restore
tracker-ctl backup collects into one .tar.gz: a Postgres dump (always full), a dump of the
ClickHouse tables (Native format — see "Incremental ClickHouse backups" below), a copy of
.env, the storage volume (uploaded landers and files) and the license directory
(machine-id + the daemon's cache, see "Instance fingerprint" below). Optionally it uploads
the archive to S3-compatible storage (BACKUP_S3_* in .env) and deletes old backups per
BACKUP_RETENTION_DAYS (default 14 days). The schedule is set separately:
tracker-ctl backup schedule daily --to s3://bucket/prefix # systemd timer, 03:00 (+ up to 30 min)
tracker-ctl backup schedule daily --at 02:30 # time is the server's local time
tracker-ctl backup schedule # cron from BACKUP_SCHEDULE_CRON (default "0 3 * * *")
tracker-ctl backup schedule off # remove any kind of schedule (same as --remove)
daily installs the systemd timer inotrack-backup.timer (journal: journalctl -u inotrack-backup.service; a run missed because the server was off is caught up after
boot); on hosts without systemd it adds a cron line (log: BACKUP_LOCAL_DIR/backup-cron.log).
Each option first removes the others — there are never two schedules at once.
install.sh sets up cron from BACKUP_SCHEDULE_CRON during install; a schedule set via
backup schedule daily is left alone by a reinstall.
Offsite backups (S3-compatible storage)
The local BACKUP_LOCAL_DIR lives on the same server: if the disk or the server dies, the
backups go together with the data. An offsite copy goes to object storage at another provider:
tracker-ctl backup keygen # once; SAVE the key off the server
tracker-ctl backup --to s3://my-bucket/inotrack # backup + upload + verification + rotation
tracker-ctl backup schedule daily --to s3://my-bucket/inotrack
tracker-ctl restore --from s3://my-bucket/inotrack # the newest archive in the bucket
tracker-ctl restore --from s3://my-bucket/inotrack/inotrack-backup-incr-20260102-030000.tar.gz
The bucket and prefix go in the URL; the endpoint and access keys go in .env (there are no
keys on the command line: it is visible in ps and ends up in the timer unit):
| Storage | BACKUP_S3_ENDPOINT |
BACKUP_S3_REGION |
|---|---|---|
| AWS S3 | s3.<region>.amazonaws.com (or empty — derived from the region) |
eu-central-1 etc. |
| Cloudflare R2 | <account_id>.r2.cloudflarestorage.com |
auto |
| Yandex Object Storage | storage.yandexcloud.net |
ru-central1 |
| Selectel | s3.<pool>.storage.selcloud.ru |
the pool, e.g. ru-1 |
| MinIO | minio.example.com:9000 (BACKUP_S3_USE_SSL=0 for http) |
us-east-1 |
BACKUP_S3_ACCESS_KEY/BACKUP_S3_SECRET_KEY are keys with read, write, list and delete
rights on the bucket. Addressing, BACKUP_S3_URL_STYLE: auto (virtual-host for AWS, path
for the rest), path or virtual; a bucket with a dot in its name is always addressed
path-style (the provider's wildcard certificate does not cover it).
- Encryption. With
--to, an archive withoutBACKUP_ENCRYPTION_KEYis not uploaded (only with an explicit--allow-unencrypted): your whole business goes to a third-party provider. Get a key withtracker-ctl backup keygen(it does not overwrite a key that is already set) oropenssl rand -hex 32. The key lives only in the.envof the server, which is exactly the thing that can die: save it in a password manager right away, otherwise the offsite copy is useless. To restore on a new server, put the key andBACKUP_S3_*into its.envbeforerestore. - Integrity check. During upload the storage itself verifies
Content-MD5and the signed SHA-256 of the body (an archive corrupted in transit is rejected); the archive's SHA-256 is written to the object metadata. After the upload,HEADchecks the size and the ETag — the MD5 the storage computed over the bytes it wrote. Where the ETag is not an MD5 (SSE-KMS, SSE-C), the object is downloaded in full and the SHA-256 is recomputed automatically;--verify-downloadalways does this (twice the traffic). An object that fails the check is deleted from the bucket andbackupexits with an error (non-zero code, visible in the timer's journal).restoreverifies the SHA-256 when downloading. - Chain in the bucket. An incremental is uploaded only if its parent is already stored
under the same prefix; otherwise (the first
--tointo a new prefix, a failed previous upload) a full is made instead of an incremental — the offsite copy is always restorable on its own. - Rotation. After a successful upload, the bucket keeps the last
BACKUP_S3_KEEParchives of this instance (0— never delete; unset — 14 for--to, 0 for uploads viaBACKUP_S3_*without--to, so that an update does not suddenly start deleting old copies). An incremental without its full is useless, so rotation does not delete fulls and incrementals that a kept incremental depends on (there may end up being more than N). Every archive is tagged with the instance (metadatax-amz-meta-inotrack-instance, a hash of theINOTRACK_PUBLIC_URLhost): rotation does not touch archives of other servers in the same bucket/prefix or archives without the tag. Only objects of the form<prefix>/inotrack-backup-*.tar.gz(and their.manifest.json) are deleted; other files and nested "directories" are left alone. It is still better to give several servers separate prefixes:restore --from s3://bucket/prefixwithout an archive name, when the prefix holds archives of several instances, takes the newest archive of THIS instance (the tag fromINOTRACK_PUBLIC_URLin.env), and if there is none, it refuses and asks you to name the archive explicitly (the name is shown before confirmation). Local archives are cleaned up byBACKUP_RETENTION_DAYS, independently of the bucket. - Without
--to,tracker-ctl backupstill uploads toBACKUP_S3_BUCKETunderBACKUP_S3_PREFIX(defaultbackups) ifBACKUP_S3_ENDPOINTandBACKUP_S3_BUCKETare set; an unencrypted archive is uploaded in this mode with a warning, as before. - The upload is a single PUT, without multipart: the S3 limit for a single PUT is 5 GB. If
a full archive is getting close to it, reduce the ClickHouse TTL
(
clickhouse/ttl-policy.sqlin the install directory).
Backup growth at 1-10M clicks/day. The five large ClickHouse tables (clicks,
conversions, costs, blocked, funnel_steps) are dumped in full once a week, and on
the other nights only as a window since the previous backup (details in "Incremental
ClickHouse backups" below). Postgres, storage and the small ClickHouse tables are dumped
in full on every run. It is still worth keeping the disk for BACKUP_LOCAL_DIR separate from
clickhouse-data and watching it (tracker-ctl doctor, the InotrackDiskSpaceLow alert):
the weekly full dump of clicks at 10M/day weighs hundreds of gigabytes by the end of the
retention period; you can shrink it with the TTL (clickhouse/ttl-policy.sql).
Restore:
tracker-ctl restore --from /opt/inotrack/backups/inotrack-backup-full-20260101-030000.tar.gz
tracker-ctl restore --from s3://my-bucket/inotrack # the newest archive in the bucket
tracker-ctl restore --from-s3 --from backups/inotrack-backup-incr-....tar.gz # key in BACKUP_S3_BUCKET
tracker-ctl restore --from <archive> --new-instance # cloning the instance, see below
--from can point to either a full or an incremental archive — if it is an incremental,
restore finds and applies the whole chain itself (the full + intermediate incrementals) up
to the given link, see below.
⚠️ The .env from the backup archive is not applied automatically — its secrets may
differ (a different instance or an earlier generation). The file is placed alongside as
.env.restored for manual comparison; the active .env is not touched.
Restore overwrites the current Postgres/ClickHouse/storage with the backup contents — the
command asks for confirmation unless --yes is passed.
Restoring ClickHouse and refreshable MVs
The daily summary (stats_daily_mv, stats_daily_recent_mv) is a
REFRESHABLE MATERIALIZED VIEW. On the pinned ClickHouse version 24.8, any operation on
them (creation, RESTORE, SYSTEM REFRESH VIEW) requires the setting
allow_experimental_refreshable_materialized_view = 1, otherwise the server answers
Code: 344 (SUPPORT_IS_DISABLED) (since 24.10 the feature is no longer experimental; the
setting name is still accepted and breaks nothing).
tracker-ctl restoresets the setting itself: it checks whether the server knows it (system.settings) and passes it to every restore call ofclickhouse-client. On ClickHouse older than 23.12 (no refreshable MVs at all) the setting is not passed.Migrations (
tracker-ctl migrate, central/worker startup) set it in theCREATE MATERIALIZED VIEW ... SETTINGS allow_experimental_refreshable_materialized_view = 1itself — a clean install does not need a separate setting.Manually (native ClickHouse
BACKUP/RESTORE, replaying DDL fromSHOW CREATEand so on) — set it in the session before restoring:SET allow_experimental_refreshable_materialized_view = 1; RESTORE DATABASE inotrack FROM Disk('backups', 'inotrack.zip');With
clickhouse-client, as an argument:clickhouse-client --allow_experimental_refreshable_materialized_view=1 --query "RESTORE ...". If objects are created one by one, the order is: first the tables (the sourcesclicks,conversionsand the targetsstats_daily,stats_daily_recent,stats_hourly), then their data, and only then the views (stats_hourly_mv,stats_daily_mv,stats_daily_recent_mv): a view withTOneeds an existing target table, and a regular MV created before the sources are loaded would add aggregates on top of the restored ones. After that,SYSTEM REFRESH VIEW inotrack.stats_daily_mv(andstats_daily_recent_mv) recomputes the summary right away instead of waiting for the schedule.
tracker-ctl restore does not restore the schema: the dump contains only data, the tables
and views are created by tracker-ctl migrate, so the command keeps the
"tables → data → MV recompute" order by itself.
Incremental ClickHouse backups
At 1–10M clicks/day (≈300M rows/month, click TTL 13 months) a full daily dump of the largest ClickHouse tables grows without bound in time, IO and disk. That is why not everything is dumped in full:
- Always full, on every run: Postgres, the
storagevolume, the license directory and the small/aggregate ClickHouse tables (stats_hourly,landing_events,smartlink_clicks,stats_daily,stats_daily_recent) — they are either small or do not grow in proportion to traffic. - Incrementally, by a time window — the five largest event tables:
clicks,conversions,costs,blocked,funnel_steps. The window is built on thetscolumn (event time) for all of them exceptcosts— there the window is onsynced_at(when the row was actually written), not onday: a resync of a cost source can recompute spend for a day a month back, and such a correction must get into the next incremental instead of being lost until the next full. The window overlaps the previous backup's watermark by 2 hours — a margin for delivery delays of the click buffer/postback queue; re-inserting rows that already made it into the previous backup is safe (clicks,conversionsandcostsareReplacingMergeTree, and ClickHouse collapses duplicates on merge/FINAL; forblockedandfunnel_stepssee the warning below).
Mode:
tracker-ctl backup # auto: full once a week, incremental otherwise
tracker-ctl backup --full # force a full
tracker-ctl backup --incremental # force an incremental (with no history it still makes a full)
Without a flag (which is what the cron schedule runs) the command decides itself: if the
newest full is older than 7 days (or there has been none yet) it makes a full, otherwise an
incremental from the latest backup in the chain. The schedule (BACKUP_SCHEDULE_CRON) sets
how OFTEN backup runs (usually daily); which of those runs is a full is decided by the
command — the result is a full once a week + an incremental daily, with no extra settings.
Disk estimate: at 5M clicks/day an incremental dump of the big five holds roughly one day of data (plus the 2-hour overlap), not the whole 13-month history — two orders of magnitude less than a full dump of the same tables. A full dump is needed only once a week; over a week the total backup volume (1 full + 6 incrementals) is noticeably smaller than the seven daily full dumps taken before.
The manifest (manifest.json inside the archive + an unencrypted sidecar
<archive>.manifest.json next to it) stores: the backup type, a link to the parent in the
chain (parent_archive), the window [watermark_from, watermark_to), the list of tables
dumped by window, and the sha256 of the dump of each of the big five.
Restoring a chain: tracker-ctl restore --from <any archive of the chain> follows
parent_archive back to the full, unpacks every link, verifies the checksums and the
continuity of the chain (matching parent_id and no time gap between one link's
watermark_to and the next link's watermark_from) — and fails with an explicit error if an
archive is missing somewhere in the chain (deleted by retention or by hand) or a manifest is
damaged, instead of silently restoring incomplete data. Then: Postgres/storage/license/
small CH tables are taken from the last link of the chain (they are full in every backup
anyway); the big five are restored through the whole chain in order (the full, then each
incremental — with a plain INSERT, no TRUNCATE between links), and
ReplacingMergeTree/FINAL collapse the overlapping duplicates. Archives without
manifest.json (taken before incremental backups existed) are still restored as before — as
a self-contained full.
⚠️ blocked and funnel_steps are a plain MergeTree (no version column), not
ReplacingMergeTree: unlike clicks/conversions/costs, re-inserting overlapping rows
MAY leave a small number of duplicates within the two-hour overlap after restoring a chain of
several links. For these two tables (diagnostic logs of blocks and funnel steps, not money)
this is an accepted trade-off — just like the ReplacingMergeTree behaviour described above
(clicks/conversions/costs), where temporary duplication is also possible until a
merge/FINAL.
Retention (BACKUP_RETENTION_DAYS) deletes backups older than the given age by file
date, but never deletes a full that a not-yet-deleted incremental still depends on
through the chain — otherwise the chain would break silently (a full is made once a week
and usually lives longer than the retention horizon, and every incremental of that week
counts from it).
Backup encryption
The archive holds a full Postgres/ClickHouse dump (offers, payouts, advertisers, traffic
domains, finance, user password hashes) and the license machine-id (see "Instance
fingerprint" below). A leak of ONE archive file is your entire business, whoever ends up with
it: on the server's disk, in an S3 bucket, in an email you copied it to by hand.
Set BACKUP_ENCRYPTION_KEY in .env — the archive will be encrypted with AES-256-GCM before
it is written to disk (streamed, in frames; so encryption does not need free memory equal to
the archive size — it works on dumps of tens of gigabytes too):
openssl rand -hex 32 # put the result into .env as BACKUP_ENCRYPTION_KEY
Keep this key SEPARATE from the backups — in a password manager, your organization's
vault, anywhere, but not next to the archives (not in the same directory, not in the same S3
bucket, not in the same email). The point of encrypting a backup is the assumption that the
backup medium itself may be compromised (a stolen disk, a breached bucket); if the key lies
in the same place, that assumption buys nothing. For its part, tracker-ctl does not put
BACKUP_ENCRYPTION_KEY into the archive: the copy of .env in the archive (env.backup, see
below) has this key REPLACED with a placeholder — keeping the key itself safe is entirely the
operator's responsibility. A lost key is a lost backup: the archive cannot be decrypted
without the key (that is the point of encryption), and tracker-ctl restore will then fail
with a clear error rather than silently corrupt data. Check periodically that the key is
still there and that restoring with it actually works (tracker-ctl restore --from <fresh archive> --new-instance on a test host is a safe way to check without touching production).
If BACKUP_ENCRYPTION_KEY is not set, tracker-ctl backup does not refuse to make a
backup — it runs as before, unencrypted, but warns loudly about it on every run (in the
console and in backup-cron.log if the backup runs on schedule). Refusing to back up because
there is no encryption key would be worse: an instance that simply had not set up a key yet
after a tracker-ctl update would be left with no backups at all — for disaster recovery
that is a worse risk than an unencrypted archive (the same risk all backups of this
product had before BACKUP_ENCRYPTION_KEY existed).
Restore detects the archive format from the file contents, not from its extension or name —
tracker-ctl restore handles both old archives taken before you set a key and new encrypted
ones. An encrypted archive needs BACKUP_ENCRYPTION_KEY in the target instance's .env (the
same key the archive was encrypted with — when moving to a new server, copy it into the new
host's .env in advance, before tracker-ctl restore). A wrong key is an explicit error
BEFORE unpacking starts, not corrupted data on disk.
Restore and the license (machine-id) — default behaviour and --new-instance
The backup carries the license directory (machine-id, see "Instance fingerprint" below),
and restore restores it by default — the target instance gets THE SAME license
fingerprint the instance had when the copy was taken. This is a deliberate choice for the
one scenario tracker-ctl restore exists for — disaster recovery (a dead disk, moving to new
hardware): without restoring machine-id, the license server would see a restore from backup
as a new install and charge an extra activation against the limit on every restore, even
though physically it is the same customer.
The product cannot automatically tell disaster recovery from cloning an instance (a second
parallel copy of the product from the same backup) — both look the same: the archive is
deployed on another host. If you really need a clone (for example, a staging copy for
testing) rather than a replacement of the original, pass the --new-instance flag — it
skips restoring the license directory, and on the next start the daemon generates its own
machine-id (a new fingerprint; a separate activation will be needed). Without this flag the
clone will present itself to the license server as the same machine as the original — the
server will treat that as license circumvention.
The rule is simple: moving/restoring an existing instance — a plain restore, pass
nothing; making a second copy next to a running original — always --new-instance. Both
restore and restore --new-instance print an explicit message about what happened to
machine-id — the behaviour does not depend on the command guessing the operator's intent.
Adding/removing a tracker domain
From the panel (the main way)
"Tracker domains" section → "Add domain", then point the domain's A/CNAME record at this server (or use "Connect via Cloudflare" if a token is set). Nothing else is needed:
docker compose (Caddy). The
Caddyfilehas anhttps://block withtls { on_demand }. On the first TLS handshake for a new name, Caddy asks the coreGET http://tracker-core:8081/internal/tls/ask?domain=<name>and issues a certificate only if the domain is in the pool and enabled (answer 200; otherwise 404 — unknown names get no certificate). The endpoint exists only on the admin port 8081 and is not published outside. Installs older than this version need a freshCaddyfile—tracker-ctl updatebrings it.k3s (Traefik + cert-manager). Traefik has no on-demand TLS, so the core maintains the Ingress
inotrack-pool-domainsitself (envINOTRACK_K8S_POOL_INGRESSon thetracker-coreDeployment): one rule and a separatetlsblock per active pool domain → Servicetracker-core:8080. It is updated right after a domain is added/edited/removed in the panel and again on every health-check tick. cert-manager creates a Certificate per domain — a domain that fails validation does not break its neighbours' certificates. Permissions: thetracker-coreServiceAccount + theinotrack-domains-ingressRole (onlyingressesin its own namespace), as in the tracker-core manifest. Settings:env default purpose INOTRACK_K8S_POOL_INGRESSempty (off) name of the pool Ingress INOTRACK_K8S_POOL_ISSUERletsencrypt-http01ClusterIssuer. HTTP-01 works for any domain pointing at the cluster; DNS-01 ( letsencrypt-inotrack) — only for zones in the Cloudflare account that issuer is configured withINOTRACK_K8S_POOL_TLS_OPTIONStraefik-direct@kubernetescrdTraefik TLSOption; -— no annotation. Without it the cluster's origin lock breaks the handshakeINOTRACK_K8S_POOL_SERVICE/_SERVICE_PORTtracker-core/8080where to proxy INOTRACK_K8S_POOL_INGRESS_CLASStraefikingressClassName Domains that already have their own static Ingress also work in the pool: both routes lead to
tracker-core:8080, the certificate is just issued twice.
The panel shows "not served by this server" only AFTER the first health check, when
https://<domain>/healthz was answered by something other than this tracker
(PointsToTracker=false). Such a domain cannot be made primary (the button is disabled, the
core answers 409). Before the first check there is no badge — there is nothing to judge by;
run "Check now".
Links for smartlinks, landers, funnels and flows are built from the pool's primary domain
(GET /domains/tracking-base, the tracking_url field in API responses);
INOTRACK_PUBLIC_URL is only a fallback while there is no primary domain.
Statically, by command
tracker-ctl domain add track2.example.com
tracker-ctl domain rm track2.example.com
It edits TRACKER_DOMAINS in .env and re-creates the Caddy container with the new list (a
brief restart of the TLS front end; other services are not touched) — no manual config edits
are needed; Caddy gets the TLS certificate automatically on the first request to the domain. After add, don't forget to point the domain's A record at this server. This
is for installs with an old Caddyfile without the on-demand block and for domains outside
the panel's pool.
You cannot remove the last domain from TRACKER_DOMAINS — add a new one first, otherwise the
static Caddy block would be left with no domain at all.
Updating
tracker-ctl update # show versions, ask for confirmation, update
tracker-ctl update --yes # no question (scripts; without a terminal --yes is required)
tracker-ctl update --yes --backup # tracker-ctl backup first; if it fails, the update does not start
tracker-ctl update --check # check only: exit code 0 — up to date, 10 — a newer one exists
It asks the license server for the channel's version (INOTRACK_UPDATE_CHANNEL: stable by
default or beta), writes it to INOTRACK_VERSION, pulls the images, runs the migrations
inside the new image and waits until tracker-core is healthy. A failure at any step means
an automatic rollback to the previous version (INOTRACK_VERSION in .env is restored, the
old images are already on disk). update renews an expired license token on the host by
itself.
What update deliberately does not do:
- it does not downgrade. If 1.3.0 is installed and the channel has 1.2.0 (you switched
beta→stable), it reports this and exits: schema migrations do not go backwards; - it does not jump over
min_supported. If the installed version is below the minimum for a direct upgrade,updaterefuses and names an intermediate version: install it (INOTRACK_VERSION=<intermediate>in.env,docker compose pull && up -d, migrations —tracker-ctl migrate), then runtracker-ctl updateagain.
Releases and channels
Versions are announced by the license server (the versions feed), not by the registry: an
instance installs only what the server named for its channel. The image is always pinned to
a specific version — there are no floating tags in .env, and docker-compose.yml does not
start without INOTRACK_VERSION.
| What | What it is | Who gets it |
|---|---|---|
X.Y.Z |
a release version | the beta channel first, stable after it has been tested |
edge-<sha> |
a pre-release build | install.sh takes the latest one only while neither stable nor beta has been published |
:stable, :edge |
floating tracker-ctl (and tracker) tags in the registry |
only install.sh, to fetch tracker-ctl before the license is activated; they never end up in .env |
tracker-l<id>:X.Y.Z |
the shared X.Y.Z + a layer with the license watermark |
licenses with stamping (INOTRACK_IMAGE is set by install.sh and update) |
The stable channel is never downgraded, and beta is never below stable. Each release has its own
min_supported (if none is set — X.0.0 of the same major version).
After a successful update, tracker-ctl on the host is replaced with the binary from the new
image (the previous one stays next to it as tracker-ctl.prev; to roll back —
mv tracker-ctl.prev tracker-ctl).
Licensee watermark in the image
Every license with stamping (new ones by default) gets its own image
registry.inotrack.run/inotrack/tracker-l<id>: the tracker binary carries
<licensee_id>:<license_id>:<hmac> (visible in docker run --rm --entrypoint /usr/local/bin/tracker <image> version
and in the panel's /version as build_stamp; it is sent in the heartbeat). The license
server flags someone else's stamp in a heartbeat as build_stamp_leaked on the source
license. Only the tracker is stamped; the panel (admin) and tracker-ctl are shared.
A license issued between releases is installed on the shared image; as soon as its
personal image is ready, tracker-ctl update moves the instance to it, and the shared image
is closed to that license.
An install made before the first release (stable/beta empty) pins edge-<sha> with a
loud warning; as soon as stable is published, tracker-ctl update (or auto-update) moves
such an instance onto it.
Auto-update
INOTRACK_AUTO_UPDATE in .env (or install.sh --auto-update ...); after editing it, run
tracker-ctl update schedule:
| Mode | What it does once a day in INOTRACK_UPDATE_WINDOW (default 04:00, local time, + up to 15 min) |
|---|---|
off |
nothing |
notify (default) |
update --check --notify: writes to the journal and, once per version, to Telegram that a new one is out |
install |
update --yes --backup --notify: backup, update, automatic rollback on failure; the result goes to Telegram |
This installs the systemd timer inotrack-update.timer (journal: journalctl -u inotrack-update);
on hosts without systemd, a cron line (log: backups/update-cron.log). To remove:
tracker-ctl update schedule --remove. Telegram uses the bot from INOTRACK_TELEGRAM_TOKEN and
the numeric chat INOTRACK_TELEGRAM_CHAT; if they are not set, the result goes only to the journal.
The current version and "version X available" are shown in the admin panel: a badge in the sidebar and "Settings → About". The core checks with the license server once an hour.
Manual rollback
Automatic rollback covers a failure of the update itself. If a problem surfaces later:
tracker-ctl backup # snapshot of the current state
sed -i 's/^INOTRACK_VERSION=.*/INOTRACK_VERSION=1.1.0/' /opt/inotrack/.env
cd /opt/inotrack && docker compose pull && docker compose up -d
If there were schema migrations between the versions, rolling back the image does not undo
them: restore the backup taken before the update (tracker-ctl restore --from ...; the
install mode takes it by itself). While you investigate, set INOTRACK_AUTO_UPDATE=off and
run tracker-ctl update schedule, otherwise the timer will update the instance again at night.
License
tracker-ctl license info # status of the locally stored token
tracker-ctl license refresh # forced activation/heartbeat to the license server
license info shows: until when the local token is valid, when the last successful
contact with the license server was, the length of the offline grace period (72 hours by
default) and the deadline until which the instance can stay offline without degradation,
the enabled features and limits.
Exactly what goes to the license server on each heartbeat (and what is deliberately not included — offers, creatives, traffic domains, finance) — see the FAQ, section "What telemetry does the instance send to the license server?".
Instance fingerprint (machine-id) — don't lose this directory
The license is bound to a "fingerprint" (fingerprint): sha256 of a persistent
machine-id + the list of tracker domains. machine-id is not the host's /etc/machine-id
but a separate file next to the license cache; its path is INOTRACK_LICENSE_CACHE_PATH
(/var/lib/inotrack/license/state.json by default, so machine-id lives in
/var/lib/inotrack/license/).
In docker-compose.yml this directory is a bind mount of the host path
/var/lib/inotrack/license (the only one of the core's volumes that is NOT a named docker
volume, unlike geoip/storage/wal) — on purpose, so that tracker-ctl running directly
on the host and the daemon in the containers (tracker-core, workers) read the same file
and produce the same fingerprint. If you lose this directory (recreate the host without
moving /var/lib/inotrack/license and without tracker-ctl restore), the next start
generates a NEW id — the license server will see it as license cloning or an extra
activation, not as a continuation of the same install. tracker-ctl backup/restore
include this directory automatically (see "Backup and restore" above, the part about
--new-instance) — a manual move is needed only if you move the disk/files around
tracker-ctl (for example, rsync of the whole install directory): in that case move
/var/lib/inotrack/license by hand together with the rest of the data.
⚠️ The backup archive contains machine-id — a secret and an install identifier in
itself. Whoever gets the archive file gets someone else's license fingerprint. Set
BACKUP_ENCRYPTION_KEY (see "Backup encryption" above) — without it the archive stays a plain
.tar.gz with 0600 permissions (only the host's file ACL), both locally and when uploaded to
S3 (BACKUP_S3_*) — the SigV4 signature protects the transfer channel and key-based access
to the bucket, but not the object's contents once it is there. Even with encryption, restrict
access to BACKUP_LOCAL_DIR and to the BACKUP_S3_BUCKET bucket as strictly as to the
license itself — encryption protects against a leak of the MEDIUM, it does not replace access
control over it.
In Kubernetes the same thing is solved differently: machine-id is not a file on the pod's
disk (with HPA at 2-8 tracker-core replicas a regular PersistentVolume does not work —
ReadWriteOnce mounts to one pod only, the rest hang in Pending), but a Secret mounted
read-only into all tracker-core/workers pods with identical contents (the
inotrack-license.machine_id key) — read-only Secret volumes are by design available to any
number of replicas without StorageClass requirements, and the tracker never tries to
overwrite an existing non-empty file.
Removing the instance
tracker-ctl uninstall # interactive confirmation, backup before removal,
# data (Postgres/ClickHouse/Redis/storage volumes) stays
tracker-ctl uninstall --purge # + deletes the data volumes and the whole install directory
tracker-ctl uninstall --no-backup # don't take a backup before removal
tracker-ctl uninstall --yes # don't ask for confirmation
Without --purge the instance can be brought back with the same .env — the data stays on
disk. --purge deletes data irreversibly. Without --yes, uninstall (with or without
--purge) asks you to type the instance name or yes to confirm.
User management
The first administrator is created by the installer from INOTRACK_ADMIN_EMAIL/_PASSWORD
(this happens once, while the users table is empty). The owner (the admin role) adds the rest of the staff the standard way, by invitation — without editing
the DB and without a restart (only the admin role can do this):
# invite (owner's session cookie — see the quickstart, appendix "the same path through the API", step 0)
curl -s -b cookies.txt -X POST "$TRACKER/api/internal/auth/users/invite" \
-H 'Content-Type: application/json' -d '{"email":"[email protected]","role":"buyer"}'
# the response has a token, returned ONLY ONCE. Link for the invitee: https://<panel>/invite?token=<token>
# (valid for 7 days, the invitee sets their own password via the link — public POST /invite/accept)
curl -s -b cookies.txt "$TRACKER/api/internal/auth/users" # list
curl -s -b cookies.txt -X PATCH "$TRACKER/api/internal/auth/users/<id>/role" \
-H 'Content-Type: application/json' -d '{"role":"viewer"}' # change role
curl -s -b cookies.txt -X POST "$TRACKER/api/internal/auth/users/<id>/disable" # disable
curl -s -b cookies.txt -X POST "$TRACKER/api/internal/auth/users/<id>/enable" # restore access
Disabling forces re-login immediately (it revokes all of the user's sessions), rather than
just forbidding the next sign-in. You cannot demote or disable the last active admin —
409 conflict: the product does not let you lock the team out without an owner.
The same is available in the panel, in the "Users" section (/users): the staff list,
invitations, role changes, disabling and restoring access, resetting 2FA. The curl calls
above are for automation.
Roles: admin — full access and team management; buyer sees only their own
flows/sources (flows.owner_user_id); viewer — the same, but without the right to change
anything.
Administrator lost their 2FA device
Two standard ways (the detailed rationale is in the FAQ):
- A second admin resets the first one's 2FA, without a restart:
POST /api/internal/auth/users/<id>/2fa/reset(under the second admin's session, not for themselves). - A single administrator — the emergency variable
INOTRACK_AUTH_RESET_2FA=email:labelin.env; it needs a process restart (that is, physical access to the server). The label is any non-empty string unique to the incident (a date/ticket number): without it, the next ordinary restart would wipe 2FA again, after it had already been set up anew. Right after signing in, set up 2FA again and remove the variable — it does not clear itself.
POST /2fa/disable is still unconditionally 403 for the admin role (2FA is mandatory for
the owner) — this is not the same as the ways above; you cannot remove it from yourself with a
password or a recovery code, only by one of the two ways above. For buyer/viewer,
self-service works as usual (recovery code → sign in → 2fa/disable with the password →
2fa/setup again).
Environment variables
All of them are in /opt/inotrack/.env (permissions 600). There are about a hundred
variables across all modules — below is not the full list but what an instance owner really
needs to know: what the installer fills in itself, what to check for your configuration, and
what not to touch at all.
Filled in by the installer — usually not edited by hand
install.sh, interactively or via flags (install guide), writes to
.env by itself: domains, the first administrator's e-mail/password
(INOTRACK_ADMIN_EMAIL/_PASSWORD, read once while the users table is empty), the license
key (INOTRACK_LICENSE_KEY), Postgres/ClickHouse/Redis passwords, the JWT secret, the
instance role (INOTRACK_ROLE: central/edge), the time zone and base currency
(INOTRACK_TZ, INOTRACK_BASE_CURRENCY), ports (INOTRACK_HTTP_ADDR :8080,
INOTRACK_ADMIN_ADDR :8081), REDIS_MAXMEMORY (25% of host RAM, at least 1gb — computed
only on the first install; on a reinstall over the same directory an already set value is
kept, see "Redis growth estimate" in the install guide).
⚠️ One thing is worth checking by hand after install: INOTRACK_PUBLIC_URL — the domain
substituted into tracking links. If by mistake it points to the panel domain rather than a
tracker domain, links will go to the wrong place while the install looks formally "green"
(see the common problems in the install guide).
Change for your configuration — if you use the corresponding feature
- Password strength requirements for new passwords (applies when a password is set — a password
change, accepting an invitation, bootstrapping the first admin; it does not affect sign-in
itself):
INOTRACK_PASSWORD_MIN_LEN(default 8 characters),INOTRACK_PASSWORD_ALLOW_COMMON=1— turn off the check against the list of predictable passwords (a deliberate weakening for a test environment/trusted network). On a clean install a weakINOTRACK_ADMIN_PASSWORDdoes not block creating the first administrator (otherwise the product would lock out its own customer from the very first start) — instead anERRORis written to the log on startup, demanding that the password be changed right after the first sign-in. - Emergency access recovery for an administrator who lost their 2FA device:
INOTRACK_AUTH_RESET_2FA=email:incident-label— see "Administrator lost their 2FA device" above and the details in the FAQ. When unset it does nothing on startup; it is set only for the duration of an incident and removed right after. - Restricting panel access by IP:
INOTRACK_ADMIN_IP_ALLOWLIST(IP/CIDR, comma-separated) — closes the entire admin perimeter, including the sign-in form itself. Unset or empty — no restriction (default). The list is read only from the process environment, never from the database or the UI: this is deliberate, so that a mistake in a range is fixed by editing the configuration and restarting, rather than requiring a sign-in to an app that no longer lets you in. Before enabling it, check that your own address is in the list and that it is static. The public API with Bearer tokens is deliberately not covered by this restriction. - Running behind Cloudflare/another proxy:
INOTRACK_TRUSTED_PROXIES(CIDR, comma-separated) — which addresses to trust forX-Forwarded-For/X-Real-IP;INOTRACK_CLOUDFLARE_IPS— the same forCF-Connecting-IP. Without this the client IP is taken from an untrusted source — a hole for spoofing geo and bypassing the postback IP allowlist (postbacks). - Cloud backups:
BACKUP_S3_*(endpoint/bucket/keys/prefix/addressing,BACKUP_S3_KEEP— how many latest archives to keep in the bucket),BACKUP_RETENTION_DAYS(default 14),BACKUP_SCHEDULE_CRON(default0 3 * * *, set from.env.exampleat install;tracker-ctl backup schedulefails with a clear error if the line in.envis empty). - Updates:
INOTRACK_UPDATE_CHANNEL(stableby default,betaif you deliberately want fresher builds),INOTRACK_AUTO_UPDATE(off/notify/install, defaultnotify),INOTRACK_UPDATE_WINDOW(default04:00),INOTRACK_TELEGRAM_CHAT— the chat for notifications; see "Updating" above. - Report SQL sandbox (API guide):
INOTRACK_CH_SANDBOX_PASS— without it the/api/v1/reports/sqlendpoint is off. - Demo traffic for testing:
INOTRACK_SANDBOX=1— only on a non-production instance. - Push subscriptions, domain manager, ad account integrations, crypto payouts: each
module has its own key for encrypting secrets in the DB —
INOTRACK_PUSH_ENC_KEY,INOTRACK_DOMAINS_ENC_KEY,INOTRACK_INTEG_ENC_KEY,INOTRACK_EXPORT_ENC_KEY,INOTRACK_RANKTRACKER_ENC_KEY, and separatelyINOTRACK_FINANCE_TRONGRID_KEY/_ETHERSCAN_KEY/_BSCSCAN_KEYfor receiving crypto payouts (TRC20/ERC20/BEP20). Without its module's key the corresponding feature simply does not store secrets — not an error if you don't use it. The push service additionally hasINOTRACK_PUSH_CONTACT_MAILTO(optional) — the contact e-mail in the VAPID JWT (RFC 8292), and the domain manager hasINOTRACK_DOMAINS_CHECK_INTERVAL_SEC(how often a domain is health-checked; see the FAQ on the fact that failover in this module today means monitoring and the Cloudflare connection, not automatic switching of real traffic). - Telegram alerts:
INOTRACK_TELEGRAM_TOKEN/_CHAT. - TTL of the click cache for postback matching:
INOTRACK_CLICK_CACHE_TTL(default30m, Redis keyclick:<clickid>) —0turns the cache off (matching only via ClickHouse, colder). It affects the Redis footprint — see "Redis growth estimate" in the install guide. - Automatic GeoIP database updates: they work out of the box with no settings at all —
by default (none of the variables below set) the product downloads the free DB-IP Lite
database itself (CC BY 4.0 license, attribution is already shown in the panel, "Reports"
section) every
INOTRACK_GEOIP_UPDATE_INTERVAL_HOURS(default 24h), checks the file's integrity and atomically swaps the working database, without restarting the process. A failed download (network unavailable, the source returned something that is not .mmdb) writes anERRORto the log and keeps the previous/empty geo — click intake never stops under any circumstances. A completely empty database (first start/auto-update off) is visible: oneWARNon startup, the gaugeinotrack_geoip_loaded(0/1) andtracker-ctl doctor.INOTRACK_GEOIP_CITY_URL/_CITY_V6_URL/_ASN_URL— override the source (your own mirror or a direct link to a paid-license file)._CITY_V6_URLis needed only for a source with separate IPv4/IPv6 files (DB-IP); for a source with a single combined City file (including MaxMind) don't set it at all.INOTRACK_GEOIP_MAXMIND_LICENSE_KEY— an optional MaxMind GeoLite2 license key (more accurate than DB-IP Lite). If it is set and none of the three URLs above is set explicitly, the resolver builds the official MaxMind permalink URLs itself and unpacks theirtar.gz. ⚠️ The vendor has no MaxMind key of its own — this path has been tested only with synthetic data, not with a live download fromdownload.maxmind.com; if you use it, be sure to checktracker-ctl doctorand real IP resolution after the first start. The Kubernetes manifests do not include this key yet (just likeINOTRACK_TELEGRAM_TOKEN— an external credential needed by a minority of instances); for a Kubernetes install with paid MaxMind, add it to your secrets the same way asINOTRACK_LICENSE_KEY.INOTRACK_GEOIP_AUTOUPDATE=off— turns off background downloading entirely (air-gapped/ regulated environments where outgoing traffic to GitHub/MaxMind is undesirable) — then.mmdbfiles are placed intoINOTRACK_GEOIP_PATHby hand.
⚠️ One variable needs attention at first setup, even if anti-fraud/first_click don't seem
a priority: INOTRACK_IP_HASH_SALT — the salt of the IP hash in anti-fraud and identity
matching. If it is unset, a new random one is generated on every process restart, and
matching visitors by IP hash across restarts (including first_click) stops working. Set it
once at install and don't change it.
Internal tuning — don't touch without a clear reason
The click intake buffer (INOTRACK_CLICK_FLUSH_MS, _SHUTDOWN_FLUSH_MS, _RING_SIZE,
_BATCH_SIZE, _OVERFLOW_QUEUE), on-disk paths (INOTRACK_WAL_PATH, _STORAGE_PATH,
_GEOIP_PATH), the log level (INOTRACK_LOG_LEVEL, default info), metrics exposure
(INOTRACK_METRICS, default 1). The defaults are sized for production load and verified by load tests; change them
only if tracker-ctl doctor or the metrics clearly show a shortage.
⚠️ Separately — INOTRACK_CLICK_CONFIRM_INSERT (default true): this is not only about
performance but about whether clicks get lost silently. The ClickHouse connection is
asynchronous (async_insert=1), and without confirmation the server may report "ok" before
physically writing the batch — if ClickHouse crashes at exactly that moment, the batch
disappears without a trace, bypassing both ClickHouse and the WAL (details and measurements:
FAQ, the section on ClickHouse being unavailable). =false is a deliberate
operator choice for throughput above typical load (~500–640 thousand inserts/s instead of
~130–150 thousand/s), not something worth turning off by default.
Browser check difficulty (guard)
INOTRACK_GUARD_POW_BITS — the proof-of-work difficulty of the guard challenge, the number of
leading zero bits (2^N attempts in the browser on average). Optional, default 14. The allowed
range is 8–24: a value below 8 is raised to 8, above 24 lowered to 24, a non-number
means the default 14; in each of these cases a WARN is written to the log. The value is read
lazily — on the first challenge/cookie check, not at process start, so the WARN appears in the
log only after the first request that hits the challenge (an instance without such traffic
won't show it). Changing it requires a restart.
Next
Installation — the install guide. Honest answers to questions about reliability — the FAQ.