From Laptop to Production: Deploying ICDB Where It Belongs

Single static binary. SQLite on disk. Postgres and Kubernetes when the team grows. The same ICDB runs on your laptop and your production cluster — the binary is a flag away, and moving your data is one command: icdb migrate-db.


One binary, four shapes

Most threat-intel platforms ask you to stand up a database server, a search index, a message broker, and a worker pool before you can paste your first IOC. ICDB ships as a single static binary that bundles all of those concerns.

That binary doesn’t change between deployment modes. Your laptop, your team server, your production Postgres-backed cluster: same binary, different flags. What does need a deliberate step is your data — moving a laptop’s SQLite database into a production Postgres isn’t automatic, and this post walks the whole path, including the one command that does it.

Local mode: ./icdb serve

The simplest shape, and the one we recommend you stay in until you have a reason to leave it.

tar xJf icdb-linux-amd64.tar.xz
./icdb serve --admin-email=you@example.com

What you get from that one command:

  • An HTTP server on :8181 serving the web UI and the REST API.
  • A SQLite database (ic.db) created in the working directory, with the full schema migrated.
  • Two keypairs generated on first boot: an Ed25519 identity key (identity.pem) that signs your tenant’s outbound data, and an X25519 encryption key (encryption.pem) that other tenants use to encrypt inbound consortium traffic to you. Together they prove tenant identity and protect relay payloads.
  • An admin user seeded against your email. In hobbyist/local mode the generated password appears in the startup banner; in operator/enterprise modes it prints to stdout exactly once. Copy it before the log scrolls.

That’s a working ICDB. Investigations, ICQL, detections, consortium sharing, every feature in the product. Backups are cp ic.db ic.db.bak. Migrations on new releases run automatically at boot.

For a single operator or a small team evaluating the platform, this is production. We run it ourselves for the pid4 dogfood instance behind a Tailscale node.

Team mode: Docker Compose

When more than two or three people share the install — or you want Postgres-grade backups and replication — pull the canonical customer-compose.yml from pid4.io/ic/customer-compose.yml into a project directory:

mkdir ic && cd ic
curl -O https://pid4.io/ic/customer-compose.yml

cat > .env <<EOF
ICDB_ADMIN_EMAIL=admin@yourco.example
ICDB_JWT_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 24)
EOF

docker compose -f customer-compose.yml up -d

The first up takes an extra half-minute: rather than pulling a prebuilt image, the compose file builds a thin Alpine wrapper around the same signed binary the downloads page serves, fetched from pid4.io/ic/ and checksum-verified during the build. One artifact to trust instead of two, and the container can never drift from the tarball. Upgrading is build then up -d: the build re-downloads only when a new release has actually been published and otherwise finishes from cache in a second or two, so you can run it on a schedule without burning bandwidth. Pin a specific release with ICDB_VERSION=0.202.1 in your .env if you’d rather move deliberately.

The compose file declares two named volumes: ic-postgres (the threat-intel database) and ic-data (identity keys + error log). Both must be backed up; the full snippet ships in the comment block at the top of the file. What changed from local mode: Postgres replaces SQLite, restart policies bring the server back on host reboot, and the JWT secret is stable so your team’s sessions survive deploys. Literally the same binary inside the container, same UI, same REST surface.

One thing compose won’t do for you: if you’re coming from a laptop that already has data in ic.db, docker compose up starts with an empty Postgres. Bring your data across with migrate-db (next section) before you start relying on the compose instance.

Production: Postgres, systemd, Kubernetes

There is no “enterprise edition.” When you outgrow a single binary on a laptop — more people sharing the install, Postgres-grade backups and replication, an orchestrator you already run — you take the same binary (or the same container image) and point it at Postgres.

The flags that matter:

  • --host=0.0.0.0 --port=8181: bind broadly so the reverse proxy can reach it.
  • --jwt-secret=$(openssl rand -hex 32): required for stable sessions. Without it, every restart invalidates every session. Don’t ship without it.
  • --public-url=https://ic.yourco.com: the canonical URL the server uses in invite links, OAuth callbacks, and consortium handshakes.
  • --db-host=…, --db-port=5432, --db-name=…, --db-username=…, --db-password=$(…): switches storage to Postgres. Point at managed Postgres if you’ve got one (RDS, Cloud SQL, whatever). If your Postgres terminates TLS, keep the default; for an in-cluster Postgres on a private network, add --db-sslmode=disable.
  • --identity-key-path=/etc/icdb/identity.pem, --encryption-key-path=/etc/icdb/encryption.pem: point at your existing keys so the instance keeps its identity across rebuilds (more on this below).

Run ./icdb help enterprise for the full flag table. Every flag also accepts an ICDB_* env var (--admin-emailICDB_ADMIN_EMAIL, etc.) for env-first deployment styles.

Moving your data: icdb migrate-db

Here’s the part a lot of “just change a flag” write-ups skip. Pointing --db-host at a fresh Postgres gives you an empty database — it does not carry your SQLite data across. SQLite and Postgres store types differently (booleans, JSON, UUIDs, timestamps), so a raw sqlite3 .dump | psql doesn’t just fail — it silently corrupts your graph, landing JSON values as text that query predicates can no longer match.

ICDB ships a first-class migration for exactly this:

# 1. Quiesce the source and take a consistent snapshot.
#    (a live SQLite file can produce torn reads mid-copy)
sqlite3 ic.db ".backup ic-snapshot.db"

# 2. Copy the data into the (empty) Postgres, type-correct.
icdb migrate-db --from-sqlite ic-snapshot.db \
  --db-host localhost --db-port 5432 --db-name ic \
  --db-username ic --db-password "$PGPASSWORD" --db-sslmode disable

migrate-db copies every row through ICDB’s own schema, so booleans land as boolean, JSON as jsonb, IDs as uuid, and timestamps as timestamptz — exactly what the query engine expects. It prints a per-table row count and a summary. When the target role is a superuser (the role a self-hosted POSTGRES_USER is), it imports faithfully: long-lived SQLite databases accumulate rows whose foreign keys SQLite never enforced, and the migration preserves them as-is rather than choking on them.

Then boot serve against Postgres and confirm your clusters, nodes, and detections are all present before you decommission the SQLite file. The full operator procedure — including the identity-key and audit-ledger notes below — lives in the sqlite-to-postgres runbook.

What you carry, in every shape

Three files — five if you run a licensed tier. In a migration they travel with you; they aren’t just backed up. All of them live in the working directory next to the binary.

  1. The database: ic.db in local mode, the Postgres volume or RDS snapshot in production. Moved with migrate-db as above. This is the bulk of your threat-intel data.
  2. identity.pem: your tenant’s Ed25519 signing key. It must be present at the new install (mount it, --identity-key-path), not merely archived — everything you’ve already shared into a consortium was signed with it, and a fresh key means your peers can no longer verify your provenance. Store a copy offline too.
  3. encryption.pem: your tenant’s X25519 encryption key. Required to decrypt inbound consortium data destined for your tenant. Same rule: carry it to the new host.

If you’re on a paid tier, two more — and they’re a pair:

  1. license.jwt: your license. ICDB reads it from /etc/intelconsortium/license.jwt by default (or the working directory in local mode).
  2. license-keys.json: the kid → public-key map that verifies the license. It’s easy to forget because the binary already trusts the vendor key — but if your license is self-signed (as operator/self-serve licenses are), the binary can’t validate license.jwt without this map. Carry both or the new instance silently falls back to Community.

The audit ledger (immudb) is separate from the relational database. Your relational audit_log rows migrate with migrate-db; the tamper-proof ledger tape stays with the original instance unless you carry its state directory too.

With those files and the binary, you can rebuild your entire ICDB instance on cold hardware in minutes.

The promise of one binary

The reason we obsess about the single-binary shape isn’t aesthetic. It’s that the laptop-to-production path is boring. There’s no v2 architecture lurking on the other side — the thing that ran on your laptop is the thing that runs in prod, just with different flags and a Postgres behind it.

Moving the binary is a flag. Moving your data is one command — migrate-db — not a rewrite, and not a silent dump that corrupts your graph. You can prototype locally, hit the same REST endpoints in CI, and promote to your cluster carrying your investigations, your identity, and your history intact.