simpleconf: A Go Config Service for When etcd Is Too Much

· 11 min read

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 :23456 to 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:

  1. raft.enabled: true on every node
  2. Unique raft.node_id, raft.listen, listen, http_addr, and db.dir per node
  3. Identical raft.peers list on all nodes (id,raft_addr,http_addr)
  4. raft.bootstrap: true only on the first node during initial bring-up
simpleconf write path in Raft mode Four stages from an incoming PUT to the append-only log, with the alternative outcome at each stage on the right. One write, Raft mode reads never take this path — they are served locally from memory PUT /db/product.name 1 · am I the leader? no → re-issue to leader http_addr forward: false → 409 not leader 2 · raft.Apply(cmd) quorum commit across peers no quorum → error 3 · every FSM applies sjson.Set on the in-memory string client sees 200 here 4 · append to data.aof separate goroutine · no fsync after the response Steps 3 and 4 are decoupled: the acknowledgement means “applied in memory”, not “on disk”.

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.listen and http_addr are two separate reachability requirements.
  • Reads are always local and never linearizable. A GET is 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.

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.

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.