Logwell is a self-hosted logging platform with real-time streaming, full-text search, and OTLP-compatible ingestion. Deploy in minutes, own your data.
Alpha Software — Expect breaking changes. Not recommended for production workloads.
Features • Quick Start • Usage • Deploy • Contributing • License
Logwell is a lightweight, self-hosted log aggregation platform for developers who want structured logging without the complexity of ELK or the costs of cloud services.
Use Logwell when you need:
- A simple logging backend for your side project or startup
- Full-text search across logs without managing Elasticsearch
- Real-time log streaming during development and debugging
- Complete data ownership with no vendor lock-in
Logwell is NOT for:
- High-volume production systems (10k+ logs/second) — use Loki or Clickhouse
- Teams needing RBAC, audit trails, or compliance features — use a managed service
- Distributed tracing or metrics — Logwell is logs-only
- OTLP-native ingestion — Standard OpenTelemetry protocol, no proprietary SDKs required
- PostgreSQL backend — Full-text search via tsvector, no separate search cluster needed
- Real-time streaming — SSE-powered live log tailing with batching
- Incident intelligence — Error/fatal fingerprinting with incident timeline and correlation hints
- Project isolation — Per-project API keys with separate log streams
- Zero telemetry — No phone-home, no tracking, fully air-gapped deployments supported
- Clean UI — Minimal interface with dark mode and log level color coding
Real-time log streaming demo
Real-time log viewer with level filtering |
Quick Start with pre-filled API key |
API key management and code snippets |
Log level distribution analytics |
| vs | Logwell advantage |
|---|---|
| Loki/Grafana | Built-in UI, no LogQL to learn, just PostgreSQL |
| ELK | Lightweight PostgreSQL backend, not Elasticsearch |
| Datadog/etc | Self-hosted, no per-GB pricing, own your data |
| Layer | Technology |
|---|---|
| Framework | SvelteKit |
| Database | PostgreSQL |
| ORM | Drizzle |
| Auth | better-auth |
| UI | shadcn-svelte + Tailwind CSS v4 |
| Real-time | Server-Sent Events |
| Runtime | Node.js |
- Node.js ^22.22.2, ^24.15.0, or >=26 (runtime, build, tests, and Vite+ hooks). CI and the Docker image use Node 24 LTS.
- pnpm 12 (
packageManagerpins the exact version) - Docker (for PostgreSQL)
# Clone the repository
git clone https://github.com/divkix/logwell.git
cd logwell
# Install dependencies
pnpm install
# Set up environment
cp .env.example .env
# Edit .env with your values (see Environment Variables below)
# Start PostgreSQL
docker compose up -d
# Run database migrations
pnpm run db:migrate
# Create admin user
pnpm run db:seed
# Start development server
pnpm run devOpen http://localhost:5173 and sign in with:
- Username:
admin(or yourADMIN_USERNAMEfrom.env) - Password: Your
ADMIN_PASSWORDfrom.env
Note: Development runs on port 5173 (Vite). Production builds run on port 3000.
The app and TypeScript SDK are one pnpm workspace with a shared root pnpm-lock.yaml. From the repository root:
pnpm install # both JS projects
pnpm up -r # update both within declared ranges
pnpm --filter logwell-app up # app only
pnpm --filter logwell up # TypeScript SDK onlyTo update pnpm itself, run pnpm self-update here and update the pinned version and release-asset checksums in Dockerfile.
Vite+/Vite/Vitest share versions in the root workspace catalog. Upgrade them together from the workspace root with the current global vp migrate --full --no-interactive (not one package at a time). Python (sdks/python/uv.lock) and Go (sdks/go/go.mod) remain separate ecosystems; update them with uv lock --upgrade and go get -u ./... from their respective SDK directories.
Create a .env file with the following:
# Database connection
DATABASE_URL="postgres://root:mysecretpassword@localhost:5432/local"
# Authentication secret (minimum 32 characters): openssl rand -base64 32
# Published placeholder values (e.g. the old example secret) are rejected at startup.
BETTER_AUTH_SECRET=""
# Admin user password (minimum 8 characters)
ADMIN_PASSWORD="your-admin-password"
# Admin username (optional, defaults to "admin")
# ADMIN_USERNAME="admin"
# Public URL the browser uses (required: CSRF checks and sign-in/out compare against it).
# Production without it logs a startup warning; an https ORIGIN also turns on HSTS.
ORIGIN="https://your-domain.com"
# NODE_ENV: only "development" and "test" relax validation. Unset, "production",
# "staging" and any other value get production rules (strict secret, Secure cookies).
# Log retention (optional, defaults shown)
# LOG_RETENTION_DAYS="30" # 0 = never auto-delete
# LOG_CLEANUP_INTERVAL_MS="3600000" # Cleanup job interval (1 hour); also purges deleted projects
# LOG_CLEANUP_BATCH_PAUSE_MS="100" # Pause between retention/purge delete batches (0-60000)
# INCIDENT_AUTO_RESOLVE_MINUTES="30" # Incident resolves after N quiet minutes
# Rate limiting (optional, defaults shown)
# RATE_LIMIT_INGEST_RPM="600" # Max ingest requests per minute per API key
# RATE_LIMIT_INGEST_IP_RPM="60000" # Max pre-auth /v1 requests per minute per client IP
# RATE_LIMIT_LOGIN_RPM="10" # Max login attempts per minute per IP (IPv6 grouped by /64)
# RATE_LIMIT_LOGIN_ACCOUNT_RPM="30" # Max FAILED logins per minute per account, from any address
# Database timeouts (optional, defaults shown; whole ms, 0 = server default, max 3600000)
# DB_STATEMENT_TIMEOUT_MS="30000" # Longer queries are cancelled -> 503 query_timeout
# DB_IDLE_IN_TRANSACTION_TIMEOUT_MS="60000"
# Live streams and ingest (optional, defaults shown)
# SSE_MAX_CONNECTIONS="200" # Open live streams per process; over the cap -> 429
# INGEST_MAX_FUTURE_SKEW_MS="300000" # Timestamps further ahead are clamped to receive timeAn invalid database timeout fails startup; other invalid numbers fall back to the default with a warning. .env.example documents every variable.
The two database timeouts are sent as connection startup parameters: behind PgBouncer in
transaction mode, add ignore_startup_parameters = statement_timeout,idle_in_transaction_session_timeout
(and set the timeouts on the role or database instead), or connections fail.
Generate a secure secret:
openssl rand -base64 32- Sign in to the dashboard
- Click New Project
- Enter a project name
- Copy the generated API key (
lw_...)
Logwell provides two ingestion APIs:
| API | Endpoint | Best For |
|---|---|---|
| Simple API | POST /v1/ingest |
Quick integration, any HTTP client |
| OTLP API | POST /v1/logs |
OpenTelemetry SDKs, rich metadata |
The simple API accepts flat JSON with minimal boilerplate:
curl -X POST http://localhost:5173/v1/ingest \
-H "Authorization: Bearer lw_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"level": "info", "message": "User signed in"}'Batch multiple logs:
curl -X POST http://localhost:5173/v1/ingest \
-H "Authorization: Bearer lw_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{"level": "info", "message": "Request started"},
{"level": "error", "message": "Database timeout", "metadata": {"query": "SELECT..."}}
]'Available fields:
| Field | Required | Type | Description |
|---|---|---|---|
level |
Yes | debug | info | warn | error | fatal |
Log severity |
message |
Yes | string | Log message |
timestamp |
No | ISO8601 string | Defaults to current time |
service |
No | string | Service name for filtering |
metadata |
No | object | Additional structured data |
Limits (both APIs): a batch holds at most 100 logs. JSON nested deeper than 256 levels
rejects the whole request (400 validation_error). A message (or OTLP string body) longer than
98,304 characters is stored truncated with a … [truncated, N characters] marker. A record
whose structured fields (metadata, attributes, resource, scope, non-string body) serialize to
more than 65,536 characters is rejected on its own: the response is still 200 with
accepted, rejected and errors[]. Timestamps more than INGEST_MAX_FUTURE_SKEW_MS (5
minutes) ahead of the server clock, or before year 1, are replaced with the receive time.
Node.js (no SDK needed)
await fetch("http://localhost:5173/v1/ingest", {
method: "POST",
headers: {
Authorization: "Bearer lw_YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({ level: "info", message: "Hello from Node.js" }),
});Python (no SDK needed)
import requests
requests.post('http://localhost:5173/v1/ingest',
headers={'Authorization': 'Bearer lw_YOUR_API_KEY'},
json={'level': 'info', 'message': 'Hello from Python'})Go (no SDK needed)
body := []byte(`{"level": "info", "message": "Hello from Go"}`)
req, _ := http.NewRequest("POST", "http://localhost:5173/v1/ingest", bytes.NewBuffer(body))
req.Header.Set("Authorization", "Bearer lw_YOUR_API_KEY")
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)For Node.js, browsers, and edge runtimes (Cloudflare Workers, etc.):
npm install logwellimport { Logwell } from "logwell";
const logger = new Logwell({
apiKey: "lw_YOUR_API_KEY",
endpoint: "http://localhost:5173",
});
// Log at different levels
logger.info("User signed in", { userId: "123" });
logger.error("Database failed", { host: "db.local" });
// Flush before shutdown
await logger.shutdown();Features: Zero dependencies, automatic batching, retry with backoff, TypeScript-first, opt-in source location capture.
Deno users: Install from JSR with
deno add jsr:@divkix/logwelland import from@divkix/logwell
For Python applications with automatic batching and retries:
pip install logwellimport asyncio
from logwell import Logwell
client = Logwell({
'api_key': 'lw_YOUR_API_KEY',
'endpoint': 'http://localhost:5173',
'service': 'my-app',
})
# Log at different levels
client.info('User signed in', {'user_id': '123'})
client.error('Database failed', {'host': 'db.local'})
# Flush before shutdown
asyncio.run(client.shutdown())Features: Async batching, automatic retries with backoff, child loggers, typed errors, source location capture.
For Go applications with zero external dependencies:
go get github.com/Divkix/Logwell/sdks/gopackage main
import (
"context"
"log"
"github.com/Divkix/Logwell/sdks/go/logwell"
)
func main() {
client, err := logwell.New(
"http://localhost:5173",
"lw_YOUR_API_KEY",
logwell.WithService("my-app"),
)
if err != nil {
log.Fatal(err)
}
// Log at different levels
client.Info("User signed in", logwell.M{"userId": "123"})
client.Error("Database failed", logwell.M{"host": "db.local"})
// Flush before shutdown
if err := client.Shutdown(context.Background()); err != nil {
log.Printf("Shutdown error: %v", err)
}
}Features: Zero dependencies, automatic batching, retry with backoff, child loggers, source location capture.
For applications already using OpenTelemetry, point your OTLP log exporter to POST /v1/logs with your API key in the Authorization header.
Node.js / TypeScript
npm install @opentelemetry/exporter-logs-otlp-http @opentelemetry/sdk-logsimport { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { LoggerProvider, BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
const exporter = new OTLPLogExporter({
url: "http://localhost:5173/v1/logs",
headers: { Authorization: "Bearer lw_YOUR_API_KEY" },
});
const loggerProvider = new LoggerProvider();
loggerProvider.addLogRecordProcessor(new BatchLogRecordProcessor(exporter));Python
pip install opentelemetry-exporter-otlp-proto-httpfrom opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
exporter = OTLPLogExporter(
endpoint="http://localhost:5173/v1/logs",
headers={"Authorization": "Bearer lw_YOUR_API_KEY"},
)
logger_provider = LoggerProvider()
logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter))Go
go get go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttpimport (
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
"go.opentelemetry.io/otel/sdk/log"
)
exporter, _ := otlploghttp.New(ctx,
otlploghttp.WithEndpointURL("http://localhost:5173/v1/logs"),
otlploghttp.WithHeaders(map[string]string{
"Authorization": "Bearer lw_YOUR_API_KEY",
}),
)
processor := log.NewBatchProcessor(exporter)
provider := log.NewLoggerProvider(log.WithProcessor(processor))Java
<!-- Maven -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter;
OtlpHttpLogRecordExporter exporter = OtlpHttpLogRecordExporter.builder()
.setEndpoint("http://localhost:5173/v1/logs")
.addHeader("Authorization", "Bearer lw_YOUR_API_KEY")
.build();C# / .NET
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocolusing OpenTelemetry;
using OpenTelemetry.Exporter;
builder.Logging.AddOpenTelemetry(logging =>
logging.AddOtlpExporter(options =>
{
options.Endpoint = new Uri("http://localhost:5173/v1/logs");
options.Protocol = OtlpExportProtocol.HttpProtobuf;
options.Headers = "Authorization=Bearer lw_YOUR_API_KEY";
}));Logwell derives some UI fields from common OTLP log attributes (if present):
| UI field | Preferred OTLP attribute keys |
|---|---|
sourceFile |
code.filepath, source.file |
lineNumber |
code.lineno, source.line |
requestId |
request.id, http.request_id |
userId |
enduser.id, user.id |
ipAddress |
client.address, net.peer.ip, net.sock.peer.addr |
| Command | Description |
|---|---|
pnpm run dev |
Start development server |
pnpm run build |
Build for production |
pnpm run preview |
Preview production build |
pnpm run check |
Run TypeScript checks |
pnpm run lint |
Run linter |
pnpm run lint:fix |
Fix lint issues |
| Command | Description |
|---|---|
pnpm run db:start |
Start PostgreSQL via Docker |
pnpm run db:push |
Push schema to database (dev only; prefer db:migrate) |
pnpm run db:generate |
Generate migration files |
pnpm run db:migrate |
Run migrations (under an advisory lock; loads .env if present) |
pnpm run db:studio |
Open Drizzle Studio |
pnpm run db:seed |
Create admin user |
| Command | Description |
|---|---|
pnpm run test |
Run all tests |
pnpm run test:unit |
Run unit tests |
pnpm run test:integration |
Run integration tests |
pnpm run test:component |
Run component tests |
pnpm run test:e2e |
Run E2E tests (Playwright) |
pnpm run test:coverage |
Run tests with coverage |
pnpm run test:ui |
Open Vitest UI |
CI builds before Playwright tests and launches Vite+ preview directly; wrapping preview in pnpm run leaves its process running after the tests.
Fly.io: Clone the repo and run fly launch (uses included fly.toml)
Note: PostgreSQL database required. Railway has $5/mo free credit. The Render Blueprint asks for
ADMIN_PASSWORDand uses a paid Postgres plan, because free Render Postgres expires after 30 days. On Fly.io run exactly one always-on machine (fly.tomlsetsmin_machines_running = 1and turns auto-stop off): rate limits and live streams are per process.
The easiest way to deploy Logwell with PostgreSQL:
# Set required environment variables
export BETTER_AUTH_SECRET=$(openssl rand -base64 32)
export ADMIN_PASSWORD="your-secure-admin-password"
export DB_PASSWORD="$(openssl rand -base64 24)"
export ORIGIN="https://logs.example.com"
# Optional: run a release other than the one compose.prod.yaml is pinned to
# export LOGWELL_VERSION="v1.5.0"
# Start the full stack
docker compose -f compose.prod.yaml up -d
# View logs
docker compose -f compose.prod.yaml logs -f app
# Stop the stack
docker compose -f compose.prod.yaml downcompose.prod.yaml pins the image to the release it shipped with, so a restart never pulls a
new version (and its migrations) on its own. For backups, restores, upgrades and rollbacks, see
docs/operations.md.
Migration 0013 replaces the log indexes with a plain CREATE INDEX, which blocks writes to the
log table while it builds (roughly 1-2 s per index per 2M rows). On a big table, build the new
indexes online before deploying:
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_log_project_timestamp_id ON log (project_id, timestamp, id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_log_project_level_timestamp_id ON log (project_id, level, timestamp, id);
-- Any row here is an INVALID index: DROP INDEX CONCURRENTLY it and build it again.
SELECT indexrelid::regclass FROM pg_index WHERE indrelid = 'log'::regclass AND NOT indisvalid;The migration aborts with a hint if either index exists but is invalid. Boot migrations run under a Postgres advisory lock, so replicas starting together wait for one another.
If you have an external PostgreSQL database:
# Pick a release tag (see GitHub Releases) and pull it from GitHub Container Registry
export LOGWELL_VERSION="v1.5.0"
docker pull "ghcr.io/divkix/logwell:$LOGWELL_VERSION"
# Generate the secret once and keep it: a new one signs everyone out
export BETTER_AUTH_SECRET="$(openssl rand -base64 32)"
# Run the container
docker run -p 3000:3000 \
-e DATABASE_URL="postgresql://user:pass@host:5432/db" \
-e BETTER_AUTH_SECRET \
-e ADMIN_PASSWORD="your-admin-password" \
-e ORIGIN="http://localhost:3000" \
-e NODE_ENV=production \
"ghcr.io/divkix/logwell:$LOGWELL_VERSION"# Build the image
docker build -t logwell .
# Generate the secret once and keep it: a new one signs everyone out
export BETTER_AUTH_SECRET="$(openssl rand -base64 32)"
# Run the container
docker run -p 3000:3000 \
-e DATABASE_URL="postgresql://user:pass@host:5432/db" \
-e BETTER_AUTH_SECRET \
-e ADMIN_PASSWORD="your-admin-password" \
-e ORIGIN="http://localhost:3000" \
-e NODE_ENV=production \
logwellThe app exposes a health check endpoint for monitoring:
curl http://localhost:3000/api/healthResponse:
{
"status": "healthy",
"database": "connected",
"timestamp": "2025-01-02T12:00:00.000Z",
"uptime": 3600
}- Returns
200 OKwhen healthy - Returns
503 Service Unavailable(with"status": "unhealthy") when the database is down or the probe takes longer than 3 seconds - Reports no version or build detail: the endpoint is public
pnpm run build
node ./build/index.jsThe app runs on port 3000 by default.
| Endpoint | Method | Description |
|---|---|---|
/api/health |
GET | Health check with database status |
| Endpoint | Method | Description |
|---|---|---|
/v1/ingest |
POST | Simple JSON log ingestion |
/v1/logs |
POST | OTLP/HTTP JSON log export |
| Endpoint | Method | Description |
|---|---|---|
/api/projects |
GET | List projects (logCount capped at 10,000, lastActivity) |
/api/projects |
POST | Create project |
/api/projects/[id] |
GET | Get project details |
/api/projects/[id] |
PATCH | Update project (name, retention) |
/api/projects/[id] |
DELETE | Delete project (hidden and key revoked now, data purged later) |
/api/projects/[id]/regenerate |
POST | Regenerate API key |
/api/projects/[id]/logs |
GET | Query logs |
/api/projects/[id]/logs/stream |
POST | SSE stream |
/api/projects/[id]/incidents |
GET | List incidents (open/resolved, range filters) |
/api/projects/[id]/incidents/[incidentId] |
GET | Incident detail with root-cause candidates |
/api/projects/[id]/incidents/[incidentId]/timeline |
GET | Incident timeline buckets + peak |
/api/projects/[id]/incidents/stream |
POST | SSE incident updates |
/api/projects/[id]/stats |
GET | Level distribution (from/to: default 24h, max 7 days) |
/api/projects/[id]/logs/export |
GET | Export logs as CSV or JSON (?format=csv|json, max 10,000) |
Error responses are JSON {error, message}; the codes below are the error value.
- Capped counts:
GET /api/projectsandGET /api/projects/[id]count at most 10,000 logs per project (logCountCapped/totalLogsCappedsay when the cap was hit; read a capped value as "10,000+"). Once capped, the detail'slevelCountsdescribe the newest 10,000 logs. - Logs: prefer
cursorpagination. The deprecatedoffsetis capped at 10,000, and a higher value returns400 offset_too_large. A malformed or out-of-range cursor returns400 invalid_cursor. Asearchlonger than 256 characters returns400 invalid_search. - Stats:
/statsdefaults to the last 24 hours and accepts a window of at most 7 days. A window over 7 days orfromaftertoreturns400 invalid_range. A malformed date returns400 invalid_date. The response echoes the effectivefromandto. - Incident detail: root-cause candidates and correlations cover the incident's newest 10,000
events (
analyzedEvents). - Timeouts: a query cancelled by
DB_STATEMENT_TIMEOUT_MSreturns503 {error: "query_timeout", message, id}. Narrow the query and retry. - Live streams: past
SSE_MAX_CONNECTIONSopen streams, a stream request returns429 too_many_streamswithRetry-After: 60. The dashboard waits and reconnects.
| Limitation | Impact | Workaround |
|---|---|---|
| Single-user auth | No team collaboration | Share credentials (not recommended) |
| No alerting / notifications | Incidents are detected but not pushed (no webhook/email) | Poll the incidents API or SSE stream |
| No programmatic read API | Logs are readable via the UI or session-auth endpoints only, not via API key | Use session-auth /api/projects/[id]/logs endpoint |
- Always use TLS — Run behind a reverse proxy (nginx, Caddy) with HTTPS in production
- Protect API keys — Treat
lw_*keys as secrets; they grant write access to your logs - Network isolation — Consider firewall rules to restrict
/v1/logsaccess to known sources - Behind a proxy — Set
ADDRESS_HEADERandXFF_DEPTH, or per-IP rate limits see the proxy's address - Built-in hardening — Every response sends
X-Content-Type-Options: nosniff,X-Frame-Options: DENYandReferrer-Policy: same-origin(plus HSTS whenORIGINis https). Pages carry a nonce-based Content Security Policy. Logins are limited per client network and per account (failed attempts only; a network the account signed in from before is not locked out by failures from elsewhere).
| Issue | Solution |
|---|---|
| Database connection refused | Ensure PostgreSQL is running: docker compose up -d |
| Admin seed fails | Check ADMIN_PASSWORD is at least 8 characters |
| Auth errors / startup fails | BETTER_AUTH_SECRET must be 32+ characters and not a placeholder |
| Port 5432 in use | Stop other PostgreSQL instances or change port in compose.yaml |
Using Logwell? Add the badge to your project:
[](https://github.com/divkix/logwell)Contributions are welcome! Here's how to get started:
# Fork and clone the repo
git clone https://github.com/YOUR_USERNAME/logwell.git
cd logwell
# Install dependencies
pnpm install
# Start dev environment
docker compose up -d
pnpm run db:migrate
pnpm run devBefore submitting a PR:
- Run
pnpm run check(TypeScript) - Run
vp check(lint + format + typecheck) - Run
pnpm run test(Vitest) - Use conventional commits (
feat:,fix:,docs:, etc.)
Report bugs: GitHub Issues




