The last logging solution you'll ever need.
Store logs cheaply on object storage, then search them fast — without keeping a big database warm. SingleLog ships nginx logs to Tigris (S3-compatible, with free egress) and searches them with ClickHouse.
nginx + Vector ──► Tigris (gzip NDJSON, partitioned) ──► ClickHouse (Graviton)
the durable log lake s3() + a hot MergeTree
The trick is free egress: querying object storage in place is normally punished by data-transfer fees, which is why people copy logs into a warm database. Tigris removes that, so the object store stays the source of truth and ClickHouse is just cheap compute on top.
- nginx + Vector (
t4g.micro) — Vector tails the nginx access log, parses the combined format into structured fields, and writes gzip NDJSON to Tigris, partitionedyear=/month=/day=/hour=. - Tigris — the durable log lake. The bucket is created by Terraform via the official
tigrisdata/tigrisprovider. - ClickHouse (
t4g.medium) — the search layer. It reads logs straight from Tigris withs3()for ad-hoc and historical queries, and keeps the last day in a refreshable MergeTree table for sub-second interactive search.
┌───────────────────────────────┐
│ AWS Secrets Manager │
│ Tigris access key │
└───────────────┬───────────────┘
creds @boot │ creds @boot
┌──────────────────┴──────────────────┐
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ nginx EC2 · t4g.micro │ │ ClickHouse EC2 · t4g.medium │
│ │ │ │
│ demo app │ │ ┌───────────────────────┐ │
│ │ │ │ │ s3() federation │ │
│ ▼ access.log │ │ │ cold / ad-hoc ~200ms │ │
│ ┌─────────────────────┐ │ │ ├───────────────────────┤ │
│ │ Vector │ │ │ │ MergeTree hot window │ │
│ │ tail → s3 sink │ │ │ │ refreshable MV ~3ms │ │
│ └──────────┬──────────┘ │ │ └───────────┬───────────┘ │
└──────────────│────────────────┘ └──────────────│────────────────┘
│ │
write · gzip NDJSON read · free egress
S3 API · region=auto s3() over Tigris
│ │
▼ │
┌──────────────────────────────────────────────────────┴────────────────┐
│ Tigris · S3-compatible object store · t3.storage.dev │
│ bucket: singlelog-logs │
│ │
│ nginx/ year=2026/ month=06/ day=25/ hour=14/ *.log.gz │
│ (gzip NDJSON, Hive-partitioned — the queryable log lake) │
└─────────────────────────────────────────────────────────────────────────┘
Secrets Manager mints the Tigris key once; both instance roles read just that secret at boot, so no keys live in the AMIs, git, or Terraform state. The query timings are from a real end-to-end run.
- An AWS account, plus Terraform and Packer.
- A Tigris account and a bucket access key — see docs/tigris.md.
-
Build the images (one per host):
cd packer cp variables.pkrvars.hcl.example variables.pkrvars.hcl # fill in vpc/subnet/region packer init nginx.pkr.hcl && packer build -var-file=variables.pkrvars.hcl nginx.pkr.hcl packer init clickhouse.pkr.hcl && packer build -var-file=variables.pkrvars.hcl clickhouse.pkr.hcl
-
Configure Terraform:
cp terraform.tfvars.example terraform.tfvars # set my_ip_address, vpc/subnet, Tigris key -
Apply:
terraform init terraform apply
Terraform creates the Tigris bucket, stores the Tigris key in Secrets Manager, and stands up the two instances. Each instance reads the key at boot.
Connect to the ClickHouse box (clickhouse-client, or HTTP on port 8123 from your IP)
and query the hot table or Tigris directly:
-- instant, from the hot MergeTree
SELECT status, count() FROM logs GROUP BY status;
-- straight from Tigris, any time range
SELECT client, count() AS hits
FROM s3(tigris, url = 'https://t3.storage.dev/singlelog-logs/nginx/**/*.log.gz',
format = 'JSONEachRow')
GROUP BY client ORDER BY hits DESC LIMIT 20;More in docs/clickhouse.md and packer/files/clickhouse/queries.sql.
Real timings from a live run over 5,037 nginx log lines on a single t4g.medium.
Interactive search from the hot MergeTree is a few milliseconds:
-- status breakdown — 2.8 ms
SELECT status, count() AS n FROM logs GROUP BY status ORDER BY n DESC;
┌─status─┬────n─┐
│ 404 │ 4281 │
│ 200 │ 756 │
└────────┴──────┘
-- top requests — 3.0 ms
SELECT request, count() AS hits FROM logs GROUP BY request ORDER BY hits DESC LIMIT 5;
┌─request──────────────────┬─hits─┐
│ GET / HTTP/1.1 │ 756 │
│ GET /login HTTP/1.1 │ 254 │
│ GET /about HTTP/1.1 │ 253 │
│ GET /admin HTTP/1.1 │ 253 │
│ GET /api/health HTTP/1.1 │ 253 │
└──────────────────────────┴──────┘
-- text search — 3.2 ms
SELECT count() FROM logs WHERE request LIKE '%/login%'; -- 254Or query Tigris directly with no ingest at all — free egress keeps the scans cheap:
-- count straight from the gzipped logs in Tigris — 203 ms
SELECT count() FROM s3(tigris,
url = 'https://t3.storage.dev/singlelog-logs/nginx/**/*.log.gz', format = 'JSONEachRow');
-- 5037
-- status breakdown over Tigris — 261 ms
SELECT status, count() AS n FROM s3(tigris,
url = 'https://t3.storage.dev/singlelog-logs/nginx/**/*.log.gz', format = 'JSONEachRow')
GROUP BY status ORDER BY n DESC;So: ~3 ms from the hot window, or ~200 ms straight off object storage when you want the full history. Both on a box that costs about $29/mo.
Roughly ~$37/mo of AWS (nginx + ClickHouse + Secrets Manager) plus Tigris storage,
down from ~$201/mo — see MODERNIZATION.md. make cost runs an
Infracost breakdown.
CINC Auditor profiles confirm the instances are healthy after apply:
make audit-nginx HOST=<nginx_ip> KEY=~/.ssh/id_rsa
make audit-clickhouse HOST=<clickhouse_ip> KEY=~/.ssh/id_rsamake validate (Terraform), make lint (tflint), make security (Trivy). CI runs the
same on every PR; the toolchain is pinned in a Docker image (make shell).