A high-performance microservice for generating, storing, and serving interactive charts using Chart.js and Node.js.
- Interactive charts (bar, line, pie, and more) stored and served via REST API
- PNG export and embeddable HTML output
- API-key authentication, rate limiting, CORS, Helmet
- Redis caching and Swagger/OpenAPI docs
- Dockerized with multi-stage builds
Node.js · TypeScript · Express · PostgreSQL · Redis · Chart.js + Puppeteer · Docker
git clone <repository-url>
cd rawchart-service
cp .env.example .env # edit secrets
docker compose up -dServices start on:
- API: http://localhost:3000
- Swagger docs: http://localhost:3000/api/docs
- PostgreSQL:
localhost:5433 - Redis:
localhost:6380
Health check: curl http://localhost:3000/api/health
Use docker-compose.coolify.yml instead of the standalone file. It drops fixed container names, keeps Postgres/Redis on the internal network only, and exposes the app on port 3000 so Coolify's proxy can route a domain + TLS to it.
Set the environment variables in Coolify's UI rather than committing a .env. DB_PASSWORD and API_KEY are required and should stay Runtime-only (not Available at Buildtime, so they aren't baked into image layers) — the app won't start without them (zod env validation rejects a short/empty API_KEY, and Postgres refuses to boot without a password). If you expose the app publicly, set ALLOWED_ORIGINS to your domain. Everything else has a sensible default.
The standalone docker-compose.yml is unchanged and still works anywhere with a hand-written .env.
All settings are loaded from .env — see .env.example for the complete, documented list. Most important:
| Variable | Purpose |
|---|---|
API_KEY |
Required for protected endpoints (x-api-key header) |
DB_* |
PostgreSQL connection |
REDIS_URL |
Redis connection string |
ALLOWED_ORIGINS |
CORS allowlist |
PORT |
HTTP port (default 3000) |
EXPIRED_CHART_CLEANUP_ENABLED |
Periodically delete charts past expires_at (true/false, default true) |
EXPIRED_CHART_CLEANUP_INTERVAL_MS |
How often the cleanup sweep runs (default 3600000 = 1h) |
EXPIRED_CHART_RETENTION_MS |
Grace period after expires_at before deletion; 0 = delete immediately |
Generate a chart:
curl -X POST http://localhost:3000/api/charts/generate \
-H "Content-Type: application/json" \
-H "x-api-key: $API_KEY" \
-d '{
"title": "Sales Report",
"chartType": "bar",
"data": {
"labels": ["Jan", "Feb", "Mar"],
"datasets": [{ "label": "Sales", "data": [100, 200, 150] }]
}
}'Full endpoint reference, authentication details, and more examples: docs/api.md.
- Local setup without Docker, scripts, and project layout: docs/development.md
- Interactive API explorer: http://localhost:3000/api/docs
Charts support two built-in themes: light (default) and dark. Pass the theme field when generating a chart:
curl -X POST http://localhost:3000/api/charts/generate \
-H "Content-Type: application/json" \
-H "x-api-key: $API_KEY" \
-d '{
"title": "Sales Report",
"chartType": "bar",
"theme": "dark",
"data": { "labels": ["Jan", "Feb"], "datasets": [{ "label": "Sales", "data": [100, 200] }] }
}'Built-in (in code): themes live in src/config/themes.ts (BUILTIN_THEMES). Add an entry there for a permanent, shipped theme. Validation and both renderers pick it up automatically. (The Swagger theme enum in the route annotations is a static list — update it there too if you want the new theme listed in the API docs.)
Without a code change (deployment config): set the CUSTOM_THEMES environment variable to a JSON object of themes. They are merged over the built-ins at startup (a custom theme with the same name overrides the built-in). Adding or changing a theme this way needs only a restart — no rebuild.
{
"brand": {
"background": "#0b1020",
"text": "#e6e8ee",
"mutedText": "#9aa3b2",
"grid": "#243049",
"palette": ["#7aa2ff", "#ff6b6b", "#34d399", "#fbbf24", "#a78bfa", "#f472b6"]
}
}Colors must be hex (#rgb/#rrggbb), rgb(...), or rgba(...); palette needs at least one color; theme names match [a-z0-9_-]+. An invalid CUSTOM_THEMES aborts startup with a clear error (fail-fast).
Charts without a shareToken (public) can be embedded directly as an iframe:
<iframe src="http://localhost:3000/api/charts/{hash}/embed" width="800" height="600" frameborder="0"></iframe>For charts with a shareToken, append it as a query parameter:
<iframe src="http://localhost:3000/api/charts/{hash}/embed?token={shareToken}" width="800" height="600" frameborder="0"></iframe>The embed endpoint serves a self-contained HTML page (no external dependencies) with its own Content-Security-Policy header, safe for use in third-party sites.
Charts created with an expiresAt are hidden from all read endpoints once they expire, but their rows stay in the database until deleted. The service deletes them automatically: a background sweep runs inside the app process (no cron job, systemd unit, or server-side setup required) — it starts one pass immediately on boot and then repeats on a timer.
Configure it entirely via .env:
| Variable | Default | Purpose |
|---|---|---|
EXPIRED_CHART_CLEANUP_ENABLED |
true |
Master on/off switch. Set to false to disable the sweep entirely. |
EXPIRED_CHART_CLEANUP_INTERVAL_MS |
3600000 (1h) |
How often the sweep runs. |
EXPIRED_CHART_RETENTION_MS |
0 |
Grace period after expires_at before a row is deleted. 0 means delete as soon as it expires — it is not an off-switch (use EXPIRED_CHART_CLEANUP_ENABLED=false for that). |
Notes:
- The timer lives in the running process, so cleanup only happens while the app is up. After a restart it catches up on the first pass.
DELETElets PostgreSQL reuse the freed space; autovacuum reclaims it in the background. No manualVACUUMis needed under normal load.- Charts without an
expiresAtare never touched — they live until explicitly deleted viaDELETE /api/charts/:hash.
MIT — see LICENSE.