simpleconf: A Go Config Service for When etcd Is Too Much
simpleconf is a lightweight configuration service in Go. It serves JSON key-path read/write over HTTP and an optional minimal TCP protocol. Optional Raft clustering provides high availability without running etcd or Consul.
I started it in 2017 for real projects that needed a shared, remotely reachable config store — smaller than etcd/Consul, no separate database server. The shape is still the same: one Go binary, one YAML file, one append-only log. Module: github.com/shaunlee/simpleconf, requires Go 1.25+.
Who it is for (and who it is not)
Use it when:
- Several services need the same JSON-ish config tree over the network
- You want a single binary on a private network, not a consensus platform
- Read-heavy config traffic; occasional writes; optional small Raft cluster later
Do not use it when:
- You need watches, leases, elections, service discovery, or multi-DC linearizability → etcd / Consul
- You must expose the API on the public internet without a hardened proxy
- You need ACLs, multi-tenant isolation, or audit integrations out of the box
- You need a write to be durable the moment it is acknowledged — see durability
Security first
simpleconf has no built-in authentication or TLS. Treat it like an internal data service:
- Bind to localhost or a private interface; put nginx/Caddy (mTLS or network policy) in front if anything untrusted can reach the host
- Do not publish
:23456to the public internet - In Raft mode, protect both the client HTTP/TCP ports and the Raft transport the same way
- Assume any client that can connect can read and write the whole document
If you need public, authenticated config delivery, terminate TLS and auth at the edge — or use a system designed for that threat model.
Quick start
git clone https://github.com/shaunlee/simpleconf
cd simpleconf
go run ./cmd/bin/main.go
Default HTTP listen: :23456. Config file: configs/config.yml:
db:
dir: data
listen: :23456
raft:
enabled: false
# TCP is off unless you set:
# tcp:
# listen: :23466
HTTP usage
Five operations on JSON key paths:
# set (raw JSON body)
curl -s -X PUT http://127.0.0.1:23456/db/product.name -d '"Demo"'
curl -s -X PUT http://127.0.0.1:23456/db/product.year -d '2026'
# read whole document or one path
curl -s http://127.0.0.1:23456/db/product
curl -s http://127.0.0.1:23456/db/product.name
# delete / clone
curl -s -X DELETE http://127.0.0.1:23456/db/product.year
curl -s -X POST http://127.0.0.1:23456/clone/product.name/product.alias
# rewrite the append-only log after heavy delete/clone churn
curl -s -X POST http://127.0.0.1:23456/vacuum
Also available: GET / for basic metadata, GET /db for the full document. Keys are dot paths (product.name) — read and write any depth without schema, reload, or restart.
TCP protocol
TCP is optional and disabled until tcp.listen is set. It targets small clients and scripts where an HTTP client is overkill.
tcp:
listen: :23466
One command per line:
| Command | Meaning |
|---|---|
= |
get the whole JSON document |
=key.path |
get one key-path value |
+key.path + next line |
set value (next line is raw JSON) |
-key.path |
delete a key path |
<from + next line >to |
clone a value |
* |
vacuum (rewrite the append-only log) |
PING |
answers +PONG |
+product.name
"Demo"
=product.name
How the store actually works
Three design choices explain both the performance numbers and the caveats. None of them are in the README, and the third one is the reason you should read the durability note before deploying this.
The document is a string, not a map
There is no parsed object graph. The whole config is one JSON string in memory, guarded by a sync.RWMutex, and every operation is a surgical edit on that string via gjson / sjson:
// read: scan the string for a dot path, return the raw slice
return gjson.Get(configuration, k).Raw
// write: splice a new value into the string
configuration, err = sjson.Set(configuration, k, v)
A GET never unmarshals anything and never allocates — which is exactly what the benchmark shows: BenchmarkGet reports 0 B/op, 0 allocs/op at ~32 ns. The trade is on the write side: every Set rewrites the document string, so cost scales with total document size rather than with the value you are writing. For a config tree — kilobytes, read constantly, written rarely — that is the right way round. For a general-purpose KV store it would not be.
The append-only log speaks the same language as the TCP protocol
data.aof is not a binary format. It is the same three verbs the TCP protocol exposes, one per line:
| On the wire (TCP) | In data.aof |
Meaning |
|---|---|---|
+key.path + value line |
+key.path + value line |
set |
-key.path |
-key.path |
delete |
| — | * + whole document |
full snapshot (written by vacuum) |
Startup is a bufio scan replaying those lines in order. That means you can read your own database with cat, and hand-repair it with a text editor if it ever comes to that — a property that gets undervalued until 3am.
Vacuum uses this too: it suspends the writer, renames data.aof to data.aof.<YYMMDDhhmmss>, opens a fresh file, and appends a single * snapshot. The old log is kept, not deleted — every vacuum leaves a timestamped backup behind, so clean those up yourself if disk matters. A graceful shutdown vacuums automatically.
Durability: what a 200 actually means
This is the one to internalize. Set takes the lock, edits the in-memory string, pushes the change onto a buffered channel, and returns. A separate goroutine drains that channel and appends to the file. There is no fsync anywhere, and the response is sent before the append has necessarily happened.
So a 200 OK means "applied in memory, queued for disk" — not "on disk". If the process is killed between the acknowledgement and the append, that write is gone. In Raft mode the log entry is replicated through hashicorp/raft first, which is a much stronger guarantee for the cluster, but each node's own data.aof is still written the same asynchronous way.
That is a defensible trade for a config store — configs are written by humans and deploy scripts, not in a hot loop, and the practical failure window is microseconds — but it is a very different contract from etcd, which acknowledges a write only after quorum has it on disk. It is also the honest answer to why the throughput numbers below look the way they do.
Raft cluster mode
For HA without a single point of failure:
raft.enabled: trueon every node- Unique
raft.node_id,raft.listen,listen,http_addr, anddb.dirper node - Identical
raft.peerslist on all nodes (id,raft_addr,http_addr) raft.bootstrap: trueonly on the first node during initial bring-up
Example three-node sketch (adjust addresses for your network):
Node 1 (raft.bootstrap: true only once, at first boot):
db:
dir: data/node1
listen: :23456
tcp:
listen: :23466
raft:
enabled: true
bootstrap: true
forward: true
node_id: "1"
listen: :23501
http_addr: "http://10.0.0.1:23456"
peers:
- "1,10.0.0.1:23501,http://10.0.0.1:23456"
- "2,10.0.0.2:23501,http://10.0.0.2:23456"
- "3,10.0.0.3:23501,http://10.0.0.3:23456"
Nodes 2 and 3: same peers list, their own node_id / listen / db.dir, and bootstrap: false.
Writes to followers are auto-forwarded to the leader when raft.forward: true (default). With forwarding off:
- HTTP:
409 {"error":"not leader","leader":"http://..."} - TCP:
-ERR not leader http://...
Two details worth knowing before you draw the network diagram:
- Forwarding is a plain HTTP call, not Raft traffic. A follower re-issues your write as a normal request against the leader's
http_addr, so that address must be reachable from every other node, and it inherits whatever proxy or firewall sits in front of it.raft.listenandhttp_addrare two separate reachability requirements. - Reads are always local and never linearizable. A
GETis answered from the node's own in-memory string with no leader check and no quorum round-trip. A follower that is briefly behind will serve slightly stale config. That is fine for config distribution and it is why read throughput is what it is — but it is not the same contract as an etcd linearizable read.
Persistence: app data in db.dir/data.aof (append-only), Raft state in db.dir/raft — a custom file-backed log store that checkpoints every 512 operations, so there is no BoltDB dependency. Raft snapshots are just the JSON document written out whole, and restore replaces the in-memory string with it. When Raft is off, a legacy peers sync mode still exists for compatibility; prefer Raft for new clusters.
What simpleconf deliberately omits
Compared with etcd / Consul KV, you do not get:
- Watch / long-poll / push notifications
- Built-in ACL, auth tokens, or TLS
- Service discovery, health checks, or DNS interfaces
- Multi-datacenter federation
That is the point: a config document with optional small-cluster HA, not a coordination platform.
Performance
Historical microbenchmarks from the README, AMD Ryzen 9 5900HX:
| Operation | ops/sec | ns/op | allocations |
|---|---|---|---|
| Get | 35,865,764 | 31.97 | 0 B/op, 0 allocs/op |
| Set | 7,825,952 | 153.10 | 96 B/op, 3 allocs/op |
| Del | 12,272,269 | 96.00 | 80 B/op, 3 allocs/op |
| Clone | 5,811,598 | 205.70 | 168 B/op, 4 allocs/op |
Load-shaped numbers (same machine, historical): 10s TCP test ~493K GET/s, ~287K SET/s, ~339K DELETE/s (500 / 100 / 100 conns). HTTP via wrk (2 threads, 10 conns) ~207K GET/s, ~144K SET/s, ~172K DELETE/s.
Measure on your own hardware. The shape is the point: a config read is a string scan with no allocation, and writes return without waiting for disk — so even the TCP path sits in the hundreds of thousands of requests per second on a laptop-class CPU.
simpleconf vs etcd vs Consul
| Store | Deployment | Writes | Reads |
|---|---|---|---|
| simpleconf (HTTP) | 1 node, Ryzen 9 5900HX | ~144K req/s (SET) | ~207K req/s (GET) |
| simpleconf (TCP) | 1 node, same hardware | ~287K req/s (SET) | ~493K req/s (GET) |
| etcd v3.x | 3 nodes, 8 vCPU + SSD each | ~50K QPS (PUT) | ~142K linearizable · ~186K serializable |
| Consul | 3 nodes, 16 vCPU each (2017) | ~16.5K req/s max ingestion | — |
Sources: simpleconf README numbers; etcd performance guide; Consul from the CoreOS dbtester series (etcd v3.1.0 vs Consul v0.7.4).
Read the table with three caveats:
- Different hardware, tools, and years. Laptop CPU vs multi-node cloud VMs; not a controlled head-to-head.
- Different durability and consistency. etcd and Consul acknowledge a write after quorum has fsynced it, and etcd's linearizable read costs a quorum round-trip. simpleconf acknowledges writes before they reach disk and serves every read from local memory — as described above. The numbers are not measuring the same promise.
- Different problem size. etcd/Consul add consensus, discovery, watches, coordination. simpleconf is a configuration store. Consul’s own guidance has long warned that very high K/V update rates are the wrong workload — all three systems are far past typical config QPS.
Bottom line: etcd and Consul are excellent distributed systems you operate (multi-node, fast disks, production RAM/CPU sizing). simpleconf trades that surface for a single binary, a YAML file, and an append-only log. Turn on embedded Raft when you need a small HA cluster without etcd’s footprint.
FAQ
Is TCP enabled by default?
No. Set tcp.listen (for example :23466).
Is there authentication?
No. Keep it on a private network and/or terminate auth at a reverse proxy.
Is a write durable once I get a 200?
No. It is applied in memory and queued for the append-only log, which is written by a separate goroutine with no fsync. In Raft mode the entry is replicated before the response, which is a much stronger guarantee, but the local file is still written asynchronously.
Can I read from any node in a Raft cluster?
Yes, and reads never go through Raft — they are answered from that node's own memory. A follower can serve slightly stale values, so do not use it where you need read-your-writes across nodes.
When should I use etcd or Consul instead?
Watches, service discovery, strong multi-node coordination, mature ACL/TLS story, or multi-DC requirements.
Does vacuum block clients?
It rewrites the append-only file; run it during low traffic after heavy delete/clone churn, not on a hot public path. It also leaves the previous log behind as data.aof.<timestamp>, so prune those if disk is tight.
Related projects
Part of the same “small, intentional API” thread as my first npm package (jst, 2011) and the two client routers — alpinejs-router and svelte-router — where the same “compile once, keep the hot path short” instinct shows up in a matcher instead of a config store. All open-source work: Projects.
Project links
- GitHub: shaunlee/simpleconf
- Module:
github.com/shaunlee/simpleconf(Go 1.25+) - License: MIT
Need a tiny internal configuration store over HTTP or TCP? Clone it, keep it off the public internet, and try the curl examples above. Issues and pull requests are welcome.