A self-contained, single-container log aggregation system for debugging and observability. Receives logs from multiple sources, stores them efficiently in SQLite, and displays them in a real-time Web UI powered by WebSockets.
No Redis. No Elasticsearch. No Kafka. One container, one binary, one file.
docker run -d -p 8080:8080 lanbugsde/chrislog
- Multi-source ingestion — Syslog (UDP + TCP, RFC 3164 & 5424), GELF (UDP), Docker socket, HTTP JSON
- Real-time Web UI — Vue 3 frontend with WebSocket live stream; new logs appear instantly without polling
- Full-text search — SQLite FTS5 with porter stemming; automatic regex detection
- GROK enrichment — Logstash-compatible pattern set embedded in the binary; parses raw log lines into structured fields
- Log retention — configurable by age (days) and database size (bytes)
- HTTP Basic Auth — optional; enabled by setting
WEB_PASSWORD - Tiny footprint — ~13 MB Go binary, ~30 MB Docker image (Alpine), single SQLite file for all data
- Zero external dependencies — no sidecar services required
docker run -d \
--name chrislog \
--restart unless-stopped \
-p 8080:8080 \
-p 514:514/udp \
-p 514:514/tcp \
-p 12201:12201/udp \
-v chrislog_data:/data \
lanbugsde/chrislogOpen http://localhost:8080 in your browser.
services:
chrislog:
image: lanbugsde/chrislog
container_name: chrislog
restart: unless-stopped
ports:
- "8080:8080" # Web UI + HTTP ingest + WebSocket
- "514:514/udp" # Syslog UDP
- "514:514/tcp" # Syslog TCP
- "12201:12201/udp" # GELF
volumes:
- chrislog_data:/data
- /var/run/docker.sock:/var/run/docker.sock:ro # optional: Docker log collector
environment:
RETENTION_DAYS: "30"
# WEB_PASSWORD: "secret"
volumes:
chrislog_data:| Source | Protocol | Port | Format |
|---|---|---|---|
| Syslog | UDP | 514 | RFC 3164, RFC 5424 |
| Syslog | TCP | 514 | RFC 3164, RFC 5424 |
| GELF | UDP | 12201 | Graylog Extended Log Format |
| Docker | Unix socket | — | Via /var/run/docker.sock |
| HTTP | TCP | 8080 | POST /api/ingest with JSON body |
Syslog (logger)
logger -n 127.0.0.1 -P 514 --udp "Hello from syslog"GELF (curl)
echo '{"version":"1.1","host":"myapp","short_message":"Something happened","level":3}' \
| nc -u 127.0.0.1 12201HTTP JSON
curl -X POST http://localhost:8080/api/ingest \
-H "Content-Type: application/json" \
-d '{"host":"myserver","level":"error","message":"Disk almost full","tag":"diskmon"}'HTTP JSON field reference
| Field | Aliases | Description |
|---|---|---|
message |
msg, short_message |
Log message text (required) |
host |
hostname |
Source hostname |
level |
severity |
Log level string or integer (syslog severity 0–7) |
tag |
app, application |
Application or component name |
facility |
— | Syslog facility string |
timestamp |
— | Unix timestamp (seconds or milliseconds) |
Docker — mount the socket and ChrisLOG automatically streams logs from all running containers and any container that starts afterwards:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro- Live stream — WebSocket connection; the indicator in the header turns teal when connected. Click to pause/resume.
- Search — plain text uses SQLite FTS5; any query containing regex metacharacters (
. * + ? [ ] { } ( ) ^ $ | \ -) is automatically treated as a regex. - Filters — filter by level, source, tag, and time range. All filters combine with AND.
- Sort — click any column header to sort; click again to toggle direction.
- Column resize — drag the handle on any column edge. Double-click to reset to automatic width.
- Row expand — click any row to see the raw log entry; JSON payloads are rendered as a collapsible tree.
- Pagination — 200 logs per page by default (up to 1000 via
limitquery param).
| Level | Color |
|---|---|
| emergency, alert | red (bright) |
| critical, error | red |
| warning | yellow |
| notice | blue |
| info | green |
| debug | gray |
ChrisLOG ships with the complete logstash-patterns-core pattern set embedded in the binary (18 pattern files covering Apache, Nginx, syslog, Java, HAProxy, PostgreSQL, Redis, firewalls, and more). GROK patterns parse raw log messages into structured fields (hostname, level, tag, facility, message).
Set GROK_PATTERNS to a comma-separated list of pattern names. Patterns are tried in order; the first match wins. Unmatched entries pass through unchanged.
| Pattern name | Matches |
|---|---|
COMBINEDAPACHELOG |
Apache / Nginx combined access log |
COMMONAPACHELOG |
Apache common log format |
SYSLOGLINE |
Syslog line (RFC 3164 date + host + program + message) |
SYSLOG5424LINE |
Syslog RFC 5424 |
SYSLOGBASE2 |
Syslog base without message |
HAPROXYHTTP |
HAProxy HTTP log |
HAPROXYTCP |
HAProxy TCP log |
POSTGRESQLLOG |
PostgreSQL log line |
RAILSLOG |
Ruby on Rails log |
REDISTMONLOG |
Redis monitor log |
JAVALOGMESSAGE |
Java log4j / logback message |
JAVASTACKTRACEPART |
Java exception stack frame |
NAGIOSLOGLINE |
Nagios log |
MONGODBLOG |
MongoDB log |
# Apache / Nginx access logs
GROK_PATTERNS: "COMBINEDAPACHELOG"
# Mixed environment: try Apache first, fall back to generic syslog
GROK_PATTERNS: "COMBINEDAPACHELOG,SYSLOGLINE"
# HAProxy + PostgreSQL
GROK_PATTERNS: "HAPROXYHTTP,POSTGRESQLLOG,SYSLOGLINE"Mount a directory containing additional .grok pattern files (same format as Logstash):
volumes:
- ./my-patterns:/patterns:ro
environment:
GROK_PATTERN_DIR: "/patterns"
GROK_PATTERNS: "MYAPP_LOG,COMBINEDAPACHELOG"Pattern file format (/patterns/myapp):
MYAPP_LEVEL (INFO|WARN|ERROR|DEBUG)
MYAPP_LOG \[%{MYAPP_LEVEL:level}\] %{IPORHOST:hostname} %{GREEDYDATA:message}
GROK-captured fields are mapped to log entry fields using this priority order. Protocol-level values (e.g. syslog severity from the PRI byte) always take precedence over GROK-extracted values.
| Log field | GROK captures tried (left = higher priority) |
|---|---|
| hostname | SYSLOGHOST › logsource › hostname › clientip › host |
| level | SYSLOGSEVERITY › loglevel › level › severity |
| tag | SYSLOGPROG › program › prog › application › app |
| facility | SYSLOGFACILITY › facility |
| message | message › MESSAGE › msg |
All configuration is done via environment variables.
| Variable | Default | Description |
|---|---|---|
WEB_PASSWORD |
(empty) | HTTP Basic Auth password. Authentication is disabled when empty. |
WEB_USERNAME |
admin |
HTTP Basic Auth username (only used when WEB_PASSWORD is set). |
DEFAULT_FILTER_REGEX |
(empty) | Regex pre-filled in the search box on page load. |
RETENTION_DAYS |
30 |
Delete logs older than N days (0 = disabled). |
RETENTION_BYTES |
0 |
Maximum database size in bytes. Oldest logs are deleted first to stay under the limit (0 = disabled). |
DB_PATH |
/data/chrislog.db |
Path to the SQLite database file. |
STATIC_DIR |
/app/static |
Directory containing the compiled frontend assets. |
LOG_LEVEL |
INFO |
Log level for ChrisLOG's own log output. |
GROK_PATTERNS |
(empty) | Comma-separated list of Logstash-compatible GROK pattern names to apply (disabled when empty). |
GROK_PATTERN_DIR |
(empty) | Path to a directory of additional custom .grok pattern files. |
HTTP Basic Auth is enabled by setting WEB_PASSWORD. The browser will prompt for credentials on first access.
environment:
WEB_USERNAME: "admin"
WEB_PASSWORD: "changeme"The /api/ingest endpoint is intentionally not protected by authentication so that external services can ship logs without credentials. All other endpoints (Web UI, /api/logs, /api/stats, etc.) require authentication when WEB_PASSWORD is set.
Retention runs as a background goroutine every 60 seconds. Both policies can be active simultaneously.
By age — delete all logs older than RETENTION_DAYS days:
RETENTION_DAYS: "7" # keep 7 daysBy database size — when the SQLite file exceeds RETENTION_BYTES, the oldest entries are deleted in batches until the database fits within the limit:
RETENTION_BYTES: "1073741824" # 1 GB capAfter deletion, VACUUM is run automatically and the FTS5 index is optimized.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/ |
✓ | Web UI (Vue SPA) |
GET |
/api/logs |
✓ | Query logs |
POST |
/api/ingest |
— | Ingest a JSON log entry |
DELETE |
/api/logs |
✓ | Delete all logs |
GET |
/api/stats |
✓ | Database statistics |
GET |
/api/sources |
✓ | Distinct sources, levels, and tags |
GET |
/api/config |
✓ | Frontend configuration (default regex) |
GET |
/api/ws |
✓ | WebSocket live stream |
| Parameter | Type | Default | Description |
|---|---|---|---|
q |
string | — | Search query (FTS5 or regex) |
regex |
1/true |
false |
Force regex mode |
level |
string | — | Filter by exact level |
source |
string | — | Filter by source (syslog, gelf, docker, http) |
tag |
string | — | Filter by exact tag |
from |
int | — | Start timestamp (Unix ms) |
to |
int | — | End timestamp (Unix ms) |
sort_by |
string | timestamp |
Sort column |
sort_dir |
asc/desc |
desc |
Sort direction |
limit |
int | 200 |
Results per page (max 1000) |
offset |
int | 0 |
Pagination offset |
Response
{
"logs": [
{
"id": 42,
"timestamp": 1715860800000,
"source": "syslog",
"hostname": "web01",
"level": "error",
"facility": "daemon",
"tag": "nginx",
"message": "connect() failed (111: Connection refused)",
"raw": "<131>May 16 12:00:00 web01 nginx: connect() failed..."
}
],
"limit": 200,
"offset": 0
}curl -X POST http://localhost:8080/api/ingest \
-H "Content-Type: application/json" \
-d '{
"host": "myserver",
"level": "warning",
"tag": "myapp",
"message": "Cache miss rate above threshold",
"timestamp": 1715860800
}'Connect with any WebSocket client. Each new log entry is pushed as a JSON frame with the same schema as /api/logs entries.
const ws = new WebSocket('ws://localhost:8080/api/ws')
ws.onmessage = (e) => console.log(JSON.parse(e.data))ChrisLOG uses a single SQLite 3 database with WAL journal mode and FTS5 full-text search.
CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL, -- Unix milliseconds
source TEXT NOT NULL, -- syslog | gelf | docker | http
hostname TEXT,
level TEXT, -- emergency | alert | critical | error | warning | notice | info | debug
facility TEXT,
tag TEXT,
message TEXT NOT NULL,
raw TEXT, -- original raw log string
size_bytes INTEGER
);
CREATE VIRTUAL TABLE logs_fts USING fts5(
hostname, level, facility, tag, message,
content='logs', content_rowid='id',
tokenize='porter ascii'
);Indexes on timestamp, level, and source are created automatically. The FTS5 index is kept in sync via AFTER INSERT and AFTER DELETE triggers.
Requirements: Go 1.23+, Node.js 20+, gcc (for CGo / SQLite)
git clone https://github.com/lanbugs/chrislog.git
cd chrislog
# Build frontend
cd frontend && npm install && npm run build && cd ..
# Copy frontend build output
cp -r frontend/dist/* static/
# Build Go binary (CGo required for SQLite FTS5)
CGO_ENABLED=1 go build -tags fts5 -ldflags="-s -w" -o chrislog .
# Run
./chrislogdocker build -t lanbugsde/chrislog .The Dockerfile uses a three-stage build:
node:20-alpine— compiles the Vue 3 frontend with Vitegolang:1.23-alpine— compiles the Go binary with CGo enabled (required formattn/go-sqlite3+ FTS5)alpine:3.20— minimal runtime image (~30 MB)
cd frontend
npm install
npm run dev # Vite dev server on :5173, proxies /api and /ws to :8080Start the backend separately:
STATIC_DIR=./static DB_PATH=/tmp/dev.db go run . ┌─────────────────────────────────────────┐
│ ChrisLOG │
│ │
Syslog UDP/TCP ───►│ collectors/syslog.go │
GELF UDP ───►│ collectors/gelf.go ┌──────────┐ │
Docker socket ───►│ collectors/docker.go ──►│ Storage │──►│ Hub ──► WebSocket clients
HTTP POST ───►│ server/server.go │ (SQLite) │ │
│ │ WAL+FTS5│ │
│ GROK enrichment applied └──────────┘ │
│ in commitBatch() before │
│ insert + broadcast │
│ │
│ Retention goroutine (every 60s) │
└─────────────────────────────────────────┘
Concurrency model: all collectors run as goroutines and push entries into a buffered channel (writeQ, capacity 10 000). A single writer goroutine drains the channel in batches of up to 500 entries per SQLite transaction. The WebSocket hub uses a separate channel for broadcasting; each connected client gets its own 256-entry send buffer.
| Package | Purpose |
|---|---|
mattn/go-sqlite3 |
SQLite 3 driver with FTS5 support (CGo) |
gorilla/websocket |
WebSocket server |
vjeantet/grok |
Logstash-compatible GROK pattern engine |
The frontend uses Vue 3, Vite, TailwindCSS, and JetBrains Mono (self-hosted via @fontsource).
MIT — see LICENSE.
ChrisLOG — because sometimes you just need one container that catches everything.
