One File, Zero Chaos

It started, as these things do, with a key rotation.

I run a small homelab: an Ubuntu server, a pile of Docker Compose stacks, and somewhere around thirty self-hosted services: Radarr, Sonarr, Plex, Home Assistant, Grafana, ntfy, the usual suspects. And like every homelabber, I write a lot of small scripts. Check disk usage, ping an API, send a notification when a backup finishes or a disk starts making that noise disks shouldn't make.

Every one of those scripts needed something: an API key, a URL, a chatID. And every time I made the same decision: “I'll just hardcode it here for now.” Now, “for now” in a homelab is a unit of time that rounds up to forever.

The day I decided to rotate my Telegram bot token, I discovered it was copy/pasted into six different files. Rotating it meant grepping my entire scripts folder and hoping I didn't miss one. (I missed one. My disk-usage alert went silent for two weeks. The disk was fine btw.)

This is exactly the kind of sloppy chaos a homelab collects if you don't stop it early. So I stopped it.

Why not BitWarden / Vault / 1Password / ...?

Because this is a single-user homelab, not a company.

HashiCorp Vault for example is a nice piece of software that would also be, in my house, a self-hosted service whose entire job is to be a dependency for my other self-hosted services. One more thing to run, back up, unseal, and debug at 11pm when notifications stop working and I can't tell whether the problem is my script or my secrets infrastructure.

My requirements were shorter than most tools' install instructions: one place for all config and secrets, readable by any bash or Python script, and simple enough that I can read the whole thing and fix it in 30 seconds at 11pm. That last one is the real requirement. Everything else is negotiable.

So I did the least fashionable thing possible: one INI file and a tiny bash helper.

The setup

All config lives in /etc/mini/mini.conf.ini, one section per service or topic:

[telegram]
bot_token = 123456:FAKE-EXAMPLE-TOKEN
chat_id = 999999999

[plex]
url = https://plex.example.local:32400
api_token = fake-example-token

[defaults]
timeout = 20

(All illustrative, obviously. My real chatID has way more nines.)

Any script that needs a value calls mini_conf <section> <key>:

TOKEN=$(mini_conf telegram bot_token)
curl -s "https://api.telegram.org/bot$TOKEN/sendMessage" ...

That's the whole interface. The script doesn't know or care where config comes from. It just asks. Rotating a key is now a one-line edit in one file, and every script picks it up on the next run.

The script

Here's mini_conf in full. About 40 lines, no dependencies beyond bash and awk:

#!/usr/bin/env bash
# creator: pven, supported by Claude
# version: v1.3
set -euo pipefail

INI="${MINI_INI:-/etc/mini/mini.conf.ini}"

# usage: mini_conf <section> <key> [default]
mini_conf() {
  local section="$1" key="$2" def="${3-}" val=""

  # ENV override: MINI_<SECTION>_<KEY>
  local envkey="MINI_${section}_${key}"
  envkey="$(echo "$envkey" | tr '[:lower:]-' '[:upper:]_')"
  if [[ -n "${!envkey-}" ]]; then printf '%s\n' "${!envkey}"; return 0; fi

  val="$(awk -v s="[$section]" -v k="$key" -v d="$def" '
    BEGIN{ FS="="; inside=0; s=tolower(s); k=tolower(k) }
    { sub(/\r$/,"") }
    /^\s*\[/ { if (tolower($0) == s) { inside=1 } else { inside=0 } }
    /^\s*([#;]|$)/ { next }
    inside && tolower($1) ~ "^[[:space:]]*" k "[[:space:]]*$" {
      sub(/^[^=]*=/,"")
      gsub(/^[ \t]+|[ \t]+$/,"")
      print
      found=1
      exit
    }
    END{ if (!found && length(d)) print d }
  ' "$INI")"

  if [[ -n "$val" ]]; then printf '%s\n' "$val"; else printf '%s\n' "$def"; fi
}

mini_conf "$@"

Let's walk through the interesting parts.

The awk INI parser

The heavy lifting is one awk program. It splits lines on =, and tracks a single state variable: inside. Every time it sees a line starting with [, it checks whether that's the section we want and flips inside on or off. Comment lines (# or ;) and blank lines are skipped.

When we're inside the right section and field one matches our key, it strips everything up to the first =, trims surrounding whitespace, prints the value, and exits. First match wins. If we fall off the end without a match, the END block prints the default (if one was given).

Is it a complete INI parser? No. It doesn't do quoted values, multiline values, or interpolation. It also doesn't need to, because I control both ends of this transaction.

Case-insensitive everything

Notice the tolower() calls sprinkled everywhere: both the section header comparison and the key match are case-insensitive. This wasn't design foresight. This was me repeatedly writing [Telegram] in the config and mini_conf telegram ... in scripts, then spending ten minutes debugging an empty variable. After the third time, I stopped fighting my own fingers and made the tool forgive me instead.

That sub(/\r$/,"") line

Every line gets a carriage return stripped off the end. Why? Because at some point one config file made a round trip through a Windows editor, came back with CRLF line endings, and every value it returned had an invisible \r glued to the end. Nothing fails quite as quietly as an API token with a carriage return in it. One sub() later, the problem can never come back. Cheap insurance.

The env-var override (the part I'm actually proud of)

Look at the top of the function, before awk ever runs:

local envkey="MINI_${section}_${key}"
envkey="$(echo "$envkey" | tr '[:lower:]-' '[:upper:]_')"
if [[ -n "${!envkey-}" ]]; then printf '%s\n' "${!envkey}"; return 0; fi

Any config value can be overridden at runtime by setting MINI_<SECTION>_<KEY> in the environment. Section and key get uppercased, dashes become underscores, and bash's indirect expansion (${!envkey}) checks whether that variable exists. If it does, that wins and the file is never touched.

This sounds like a small thing. It is not a small thing. It means I can test a notification script against my own private chat instead of the family group:

MINI_TELEGRAM_CHAT_ID=123 ./notify.sh

No editing the config file, no “temporary” change I forget to revert, no accidentally spamming everyone at 11pm because I was debugging. One-off overrides, testing, pointing a script at a staging instance: all without touching the file that thirty other scripts depend on. Twelve-factor apps get this via environment config; my crontab gets it via three lines of bash.

(The MINI_INI variable at the top is the same trick applied to the file itself: point the whole tool at a test config when you want to.)

What this fixed, philosophically

mini_conf fits a broader rule I try to hold my homelab to: simple tools I fully understand beat powerful tools I half understand. One boring config file instead of one secret per script. A helper small enough that “read the source” is the documentation.

The chaos hasn't come back. New script? Two lines: call mini_conf, use the value. Key rotation is a one-file edit. And when something breaks at 11pm, the config layer is never the suspect. It's 40 lines of bash I can hold in my head.

The best tools are the boring ones. mini_conf has been running for months and I mostly forget it exists. For infrastructure, that's the highest compliment there is.


Written with assistance from Claude.


Want to reach out? Contact me.