A single-binary AWS + EKS observability tool with two modes:
frugal server # web dashboard + data collectors (default when no subcommand)
frugal agent # push host metrics + logs from EC2 / EKS nodes to a serverIt collects metrics for AWS managed services (EC2, RDS, DocumentDB,
ElastiCache/Valkey, AmazonMQ ActiveMQ + RabbitMQ, OpenSearch, S3, ALB, NLB, EKS
control plane, Container Insights) — via CloudWatch where that's the only source,
and via free native endpoints (Redis INFO, OpenSearch stats, the RabbitMQ
management API, RDS Performance Insights) where one exists. It also collects live
pod/node CPU + memory from EKS clusters, ingests host metrics + logs pushed by a
bundled agent, streams pod logs, and serves a live dashboard — no Prometheus, no
external database, no third-party agents required.
CloudWatch API ─────┐
Native endpoints ───┤
RDS Perf Insights ──┼─▶ collectors ─▶ in-memory ring buffers ─▶ HTTP + SSE ─▶ live dashboard
EKS APIs ───────────┤ (Go) (+ optional SQLite) (embedded HTML)
Agent push (/proc) ─┘
Run the prebuilt multi-arch image from GHCR — no build, no Go toolchain:
# Docker
docker run -d --name frugal -p 8080:8080 \
-e FRUGAL_SECRET_KEY=$(openssl rand -hex 32) \
-e FRUGAL_DATA_DIR=/data \
-v frugal-data:/data \
ghcr.io/tools-plus/frugal:latest
# …or Docker Compose (uses the same GHCR image)
FRUGAL_SECRET_KEY=$(openssl rand -hex 32) \
docker compose -f docker-compose.prod.yml up -dThen open http://localhost:8080, log in as admin / admin, set a new password, and configure what to collect under Admin ▸ Settings.
- Images are published for amd64 + arm64; pin a release with
:vX.Y.Zinstead of:latest. FRUGAL_SECRET_KEYencrypts stored credentials — keep it stable across restarts (a changed key can't decrypt existing secrets).- To build from source instead, see the Developer view.
In server mode the process runs as two parts:
- Web server — comes up immediately from a tiny bootstrap config
(
listen,data_dir,secret_key,auth). It always serves the login, dashboard, and APIs, even before anything is configured. - Data-collection service — a supervised subsystem started from the runtime config (AWS/EKS/native targets, credentials, retention, ingest token). It only starts when credentials are available, and it is torn down and relaunched when you change settings — without restarting the web server.
Runtime config is edited in the dashboard (Admin ▸ Settings) and stored,
encrypted, in the control database — not in server.json.
frugal pulls from five kinds of source. Only CloudWatch GetMetricData is
billable — everything else is free. Each series' origin is encoded in its ID
prefix (cw|, nv|, pi|, k8s|, ag|), so you can always tell what a chart
is costing you.
| Source | Prefix | How | Cost | Resolution |
|---|---|---|---|---|
| CloudWatch | cw| |
ListMetrics + GetMetricData (batched ≤500/call) |
💰 ~$0.01 / 1k metrics | 300s default |
| Native — Valkey / ElastiCache | nv| |
INFO over the Redis protocol |
free | seconds |
| Native — OpenSearch | nv| |
_cluster/health + _nodes/stats |
free | seconds |
| Native — AmazonMQ (RabbitMQ) | nv| |
management HTTP API | free | seconds |
| RDS Performance Insights | pi| |
pi:GetResourceMetrics — DB load / active sessions |
free (7-day retention) | 60s |
| EKS pods / nodes | k8s| |
metrics-server (metrics.k8s.io) + kubelet log API |
free | ~15s |
| EC2 / host agent | ag| |
frugal agent reads /proc, pushes to the server |
free | seconds |
CloudWatch is the only source for ALB, NLB, S3, RDS/DocDB host CPU+RAM, and the EKS control plane — there's no free alternative for those.
For the three services that have both a CloudWatch namespace and a native
endpoint — ElastiCache, OpenSearch, and AmazonMQ — frugal collects both by
default. The AWS collector discovers every default namespace (which includes
AWS/ElastiCache, AWS/ES, AWS/AmazonMQ), and in parallel the discovery
layer auto-finds those same resources' endpoints and starts the free native
pollers on them. The result is two series per metric family — a paid cw|… and a
free nv|… — which is handy for side-by-side comparison but means you're still
paying CloudWatch for services you could monitor for free.
To go free-only for those services, narrow the AWS namespaces in
Admin ▸ Settings so the paid namespaces (AWS/ElastiCache, AWS/ES,
AWS/AmazonMQ) are dropped, and let the native pollers cover them. There is no
automatic suppression — trimming the namespace list is the opt-out.
Resource discovery itself (DescribeCacheClusters, ListDomainNames /
DescribeDomains, ListBrokers / DescribeBroker, DescribeDBInstances) uses
free AWS control-plane APIs; only CloudWatch GetMetricData is billed.
frugal agent makes zero AWS API calls. It reads host CPU / memory / network
/ load from /proc, tails log globs, and HTTP-pushes both to your frugal server
over the bearer-token'd /api/ingest* endpoints — it never calls CloudWatch and
never uses PutMetricData. So collecting EC2 / host metrics via the agent costs
$0, compared with:
| Getting EC2 host metrics via… | CloudWatch cost |
|---|---|
frugal agent (/proc → your server) |
$0 — no AWS API calls |
CloudWatch AWS/EC2 namespace (GetMetricData) |
💰 per metric read |
CloudWatch agent / PutMetricData custom metrics |
💰 custom-metric + API charges |
The only possible cost is ordinary network egress, and only if the agent and server sit in different AZs / regions or talk over the internet; same-region, in-VPC traffic to your own server is free.
GetMetricData is billed per metric requested per poll (~$0.01/1,000), so
cost = metrics × polls. frugal gives you these levers in Admin ▸ Settings:
- Cost mode presets — one click sets the poll interval + period: frugal (10-min poll, 5-min resolution — matches free basic monitoring), balanced (5-min, the default), detailed (1-min). Because a single call returns every datapoint in its window for one charge, a longer poll interval cuts cost without losing resolution — only freshness.
- Live cost estimate — the AWS section shows the estimated monthly CloudWatch spend for your current interval and discovered metric count, updating as you change the interval.
- Native supersedes CloudWatch — an opt-in toggle that drops the paid namespaces a native poller already covers for free (ElastiCache/OpenSearch/ AmazonMQ), so you don't pay to collect the same metrics twice. Enable it only once the native pollers are healthy — there's no CloudWatch fallback for a superseded namespace.
- Daily metrics are auto-throttled — S3 storage metrics (which AWS publishes once a day) are fetched hourly rather than every poll, so they cost a fraction of what a normal-cadence poll would.
Bootstrap — the few things needed before the server can start and find its
storage. These live in server.json, and every key can be overridden by an
environment variable (the env wins):
server.json key |
env override | meaning |
|---|---|---|
listen |
FRUGAL_LISTEN |
bind address (default :8080) |
data_dir |
FRUGAL_DATA_DIR |
directory for the SQLite databases |
secret_key |
FRUGAL_SECRET_KEY |
key that encrypts stored credentials (see below) |
auth.enabled |
FRUGAL_AUTH_ENABLED |
require login (default true) |
auth.db_path |
FRUGAL_AUTH_DB_PATH |
control-db path (default <data_dir>/auth.db) |
{
"listen": ":8080",
"data_dir": "./data",
"secret_key": "CHANGE_ME_ENCRYPTS_STORED_CREDENTIALS",
"auth": { "enabled": true, "db_path": "" }
}Runtime — everything about what to monitor and how: AWS (region,
credentials, namespaces, poll intervals), Kubernetes (contexts/clusters),
native targets (Valkey/OpenSearch/RabbitMQ), retention, and the ingest_token.
You configure this in Admin ▸ Settings in the dashboard; it's stored
encrypted in the control DB and hot-applied (the collector service
restarts, the web server keeps serving). Saving a change takes effect live —
no process restart — except retention_points (in-memory ring size), which
applies on the next restart.
secret_key encrypts every credential stored in the control DB (AWS keys,
native passwords, ingest token) with AES-256-GCM. Keep it out of source control
(server.json is gitignored); in production prefer the FRUGAL_SECRET_KEY env
var or a secret manager. Without a key the server still runs and the login
works, but credentials can't be stored or used until you set one.
Migration: on first boot, if the control DB has no config yet, frugal seeds the runtime config from any
aws/kubernetes/native/ingest_token/ retention fields present inserver.json. So an existing fullserver.jsonkeeps working — it's imported once, then the Settings UI is the source of truth.
In an EKS cluster (recommended):
deploy/k8s.yaml already points at the public image
ghcr.io/tools-plus/frugal:latest, so there's nothing to build:
# edit deploy/k8s.yaml: set the IRSA role ARN (annotation on the ServiceAccount)
# and, for persistence, a volume for data_dir. Pin :vX.Y.Z if you prefer.
kubectl apply -f deploy/k8s.yaml
kubectl -n frugal port-forward svc/frugal 8080:80To ship your own build instead, docker build -t <your-registry>/frugal:tag .,
push it, and set that image in deploy/k8s.yaml.
- IRSA gives the pod CloudWatch access with no long-lived keys — leave AWS
keys blank in Settings and the default credential chain is used. The IAM
policy needs only
cloudwatch:ListMetricsandcloudwatch:GetMetricData. - The Service is ClusterIP on purpose. There's a built-in login, but keep the dashboard behind port-forward / VPN / an authenticating ingress anyway.
- Mount a volume at
data_dirso the control DB (users, roles, encrypted config) and metric history survive restarts.
On an EC2 host / VM / locally: run the binary with a server.json (or the
env overrides), providing at least data_dir and secret_key.
- Open the dashboard → you're prompted to log in. First-time setup seeds
admin/adminand forces you to set a new password. - Go to Admin ▸ Settings and configure what to collect:
- AWS — region, namespaces, poll intervals, and either static keys (encrypted at rest) or leave blank to use IRSA/instance role.
- Kubernetes — kubeconfig contexts, or direct clusters (
api_url+ bearer token). In-cluster, the pod's ServiceAccount is used automatically. - Native targets — Valkey/ElastiCache, OpenSearch, RabbitMQ endpoints.
- Retention and the ingest token (used by agents; generate one here).
- Save — collectors start immediately.
Requirement for EKS metrics: metrics-server installed in the cluster (the
EKS metrics-server addon — if kubectl top pods works, you already have it).
Agents push host CPU/memory/disk/network/load (from /proc) and tail log globs
to the server, using the ingest token you set in Settings.
- EC2:
deploy/frugal-agent.service(systemd unit) - EKS:
deploy/agent-daemonset.yaml(DaemonSet on every node; withkube_logs: trueit ships each container's logs from/var/log/containers, so pod logs work even when the server runs outside the cluster) - Agent config:
agent.example.json, or envFRUGAL_SERVER_URL+FRUGAL_TOKEN(the token must match the server's ingest token).
The easiest way to run frugal locally is the bundled docker-compose.yml: it
builds the binary from the Dockerfile and runs it in a container — the dev
environment matches production, and there's no Go toolchain to install.
export FRUGAL_SECRET_KEY=$(openssl rand -hex 32) # encrypts stored credentials
docker compose up --build
# open http://localhost:8080 → log in as admin/admin → set a new password →
# configure AWS / EKS / native targets under Admin ▸ Settings.- All state (users, roles, encrypted runtime config, metric history) persists in
the
frugal-datanamed volume, so it survivesdocker compose down/up. - To use your local AWS profile instead of entering keys in the UI, uncomment
the
~/.aws:/root/.aws:romount indocker-compose.ymland leave the AWS keys blank in Settings (exportAWS_REGION/AWS_PROFILEas needed). - To connect to EKS, configure Kubernetes under Admin ▸ Settings (see "How it collects" for the node/pod-metrics requirements).
Prerequisites: Go ≥ 1.24 (required by the AWS SDK service clients), and for EKS work kubectl access plus
metrics-server in the cluster.
git clone https://github.com/tools-plus/frugal && cd frugal
go mod download
# minimal local bootstrap config
cat > server.json <<'JSON'
{
"listen": ":8080",
"data_dir": "./data",
"secret_key": "dev-only-key",
"auth": { "enabled": true }
}
JSON
go run ./cmd/frugal -config server.json
# open http://localhost:8080 → log in as admin/admin, set a password,
# then configure AWS/EKS/native under Admin ▸ Settings.Tips for local development:
- Skip the login while iterating with
"auth": { "enabled": false }(orFRUGAL_AUTH_ENABLED=false). The Settings UI is then open without a login. - AWS: set static keys in Settings, or export
AWS_REGION/AWS_PROFILEand leave keys blank to use your local credential chain. - Kubernetes: put your kubeconfig context name(s) in Settings ▸ Kubernetes
(
*= every context). frugal runs a supervisedkubectl proxyper context for you; a manually-run proxy + a directapi_urlcluster entry also works. - Seed instead of clicking: for a repeatable dev setup, put
aws/kubernetes/nativeblocks inserver.json— they seed the control DB on first boot (delete<data_dir>/auth.dbto re-seed). - CGO: the SQLite driver (control DB + metric persistence) needs
CGO_ENABLED=1(the default). Agent cross-builds withCGO_ENABLED=0still compile — they just omit the driver they don't use.
Build and test:
go build ./... # builds server + agent (CGO on)
go test ./... # all tests
go test ./internal/store/ # one package
go vet ./...
docker build -t frugal . # multi-stage, distrolessLayout: cmd/frugal (entrypoint / two-part wiring), internal/collector
(supervised collector service), internal/awsmetrics · internal/native ·
internal/k8s (collectors), internal/auth (users/roles/sessions + encrypted
config), internal/secret (AES-GCM), internal/server (HTTP + SSE + access
control), internal/store · internal/logstore · internal/db (hot stores +
SQLite), web/ (embedded dashboard: index.html + js/ ES modules).
The login is enabled by default. Users, roles, sessions, and the encrypted
runtime config all live in the control DB (<data_dir>/auth.db).
- Passwords are bcrypt-hashed; login issues an
HttpOnlysession cookie (7-day). Change your own password from the Profile menu. - Roles are a name + a set of services (EKS, RDS, S3, …). A scoped role
sees only those services, read-only. Built-ins:
admin(manage users/roles + everything) andviewer(all services, read-only). Create scoped roles (e.g.db-team→ RDS, DocDB, ElastiCache) under Admin ▸ Roles and assign them under Admin ▸ Users. - New users get a temporary password and must change it at first login. The
built-in
adminuser's role is locked; the last admin can't be deleted or demoted. - Service access is enforced server-side on every data path — series list, data/history, pods, logs, and the live SSE stream — so a scoped user can't reach another team's services even via the raw API. Admins (and auth-disabled mode) bypass the filter.
- Agent push endpoints (
/api/ingest*) use the sharedingest_tokenbearer token, not the login (agents can't do interactive auth).
| Endpoint | What |
|---|---|
GET / · GET /login |
dashboard (session required) · login page |
POST /api/login · POST /api/logout · POST /api/change-password |
session lifecycle |
GET /api/me |
auth status: enabled, authenticated, user, role, is_admin, must_change |
GET/POST /api/settings |
runtime config (admin only; secrets are write-only) |
GET/POST /api/users, DELETE /api/users/{name}, POST /api/users/{name}/password, .../role |
user management (admin only) |
GET/POST /api/roles, POST /api/roles/{name}, DELETE /api/roles/{name} |
role management (admin only) |
GET /api/series?filter= |
known series, filtered to the caller's allowed services |
GET /api/series/data?id= |
full ring buffer for one series |
GET /api/history?id=&from=&to= |
on-demand CloudWatch fetch for long ranges (unix seconds) |
GET /api/stream |
SSE: every new point, filtered to allowed services |
GET /api/pods |
pod inventory (in-memory cache — instant) |
GET /api/status |
collector health: targets, last error/poll, clusters |
GET /api/logs?namespace=&pod=&container=&tail= |
SSE: live pod log tail |
GET /api/agentlogs?source=host/<name>&tail= |
SSE: live agent-shipped log tail |
POST /api/ingest · POST /api/ingest/logs |
agent metric/log push (bearer token) |
/login, /api/login, /api/me, and static assets are public; everything
else requires a session (and /api/users*, /api/roles*, /api/settings
require an admin) when auth is enabled.
Series IDs are pipe-delimited and predictable:
k8s|pod|default/web-7f9c|cpu_cores, cw|RDS|CPUUtilization|mydb.
AWS — one collector, one API. ListMetrics per namespace discovers
resources (a new RDS instance appears within the discovery interval);
GetMetricData (batched ≤500 queries/call) fetches. Cost is ~$0.01 per 1,000
metrics; slow poll_interval_seconds to cut it. S3 storage metrics are emitted
once per day by AWS, so frugal polls those over a multi-day window at daily
resolution and their charts show one point per day.
EKS — a ~100-line REST client (no client-go) hitting the cluster APIs:
multiple clusters at once (each labeled), drill-down cluster → control plane /
nodes / workloads (from pod ownerReferences) → pods, pod/node CPU+memory from
metrics.k8s.io, inventory from the core API, and live log tails via
pods/{pod}/log?follow=true streamed to the browser over SSE.
Clusters are found from three sources (deduplicated by name): EKS
auto-discovery (on by default — eks:ListClusters + DescribeCluster, then a
token minted per cluster, so node/pod metrics work straight from AWS creds with
no kubeconfig), an uploaded kubeconfig (every context becomes a cluster;
EKS exec-auth contexts are tokened automatically), and direct API entries
(api_url + bearer token). Note: the EKS control-plane charts come from
CloudWatch's AWS/EKS namespace and appear from creds alone; node/pod
metrics need a live connection — a reachable cluster endpoint (public, or in-VPC
for private endpoints), the IAM principal mapped in the cluster's access
entries, and metrics-server installed.
Storage — SQLite is the system of record, memory is the hot path. With
data_dir set, the server ensures <data_dir>/frugal.db on start, hydrates the
in-memory stores from it so the dashboard serves data immediately on restart,
and persists collected data in batched background transactions. History is
pruned to db_retention_hours, logs to log_retention_lines per source. For
ranges beyond the ring buffer (24h/3d/7d) the dashboard fetches from CloudWatch
via /api/history.
Live updates — every new point fans out to connected dashboards over SSE
(/api/stream), filtered per connection to the viewer's allowed services.
- More CloudWatch metrics: add entries to the
defaultsmap ininternal/awsmetrics/cloudwatch.go. Daily-granularity namespaces go indailyNamespaces. - Container-level metrics: scrape kubelet's cAdvisor endpoint
(
/api/v1/nodes/{node}/proxy/metrics/cadvisor) — the REST client already hasGetJSON; add a Prometheus-text parser. - Alerting: subscribe to the store (
store.Subscribe()) and evaluate thresholds — the fan-out layer is already there. - New rail service: keep
NS2SVC/svcOfinweb/js/state.jsandns2svc/serviceOfininternal/server/access.goin sync (they classify series for the rail and for role-based access).
- go.mod targets Go 1.24 (the AWS SDK service clients used for discovery
require it); the Dockerfile builds on
golang:1.24. - OpenSearch metrics live under the legacy
AWS/ESnamespace. - ALB/NLB metrics are per-load-balancer and per-target-group; both dimension sets are discovered automatically.
Contributions are welcome — see CONTRIBUTING.md for how to build, test, and open a pull request. Please report security vulnerabilities privately per SECURITY.md, not via public issues.
frugal is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). Copyright © 2026 tools-plus. See LICENSE for the full text.
