I’ve been using Chef for server configuration for a while now. It works, but there’s always been friction. I don’t write Ruby anywhere else, so every time I need to update server configs, I’m context-switching into a language I’m barely literate in. Chef uses Ruby with its own DSL conventions, and while that’s arguably better than learning a completely custom language like Puppet, I still find myself fighting against patterns I don’t understand and don’t use anywhere else.
Eventually I thought: why not just use Python? It’s what I write in every day. Why maintain infrastructure code in a language I only touch when something breaks?
When I looked at my actual Chef setup, I realized I only use a small handful of resource types:
creates or only_if)That’s it. No search indexes, no complex dependency graphs, no distributed state. Just a way to describe what my servers should look like and have it happen.
So I built chef-py - a minimal replacement that covers the subset of Chef I actually use. The core idea is the same: resources describe what you want, providers handle how to make it happen.
Resources are frozen dataclasses:
@dataclass(frozen=True)
class File(Resource):
path: str
content: str
owner: str = "root"
group: str = "root"
mode: str = "0644"
And providers do the actual work. Each provider knows how to apply one type of resource, checking first if the resource is already in the desired state:
class FileProvider(Provider):
def apply(self, resource: File) -> None:
# If file exists with correct content, skip
if os.path.exists(resource.path):
with open(resource.path) as f:
if f.read() == resource.content:
return
# Otherwise write it
...
A registry maps resources to providers, so I can add new resource types without touching the core runner.
Cookbooks are just Python classes or functions that return lists of resources. Here’s the Ubuntu basics:
def ubuntu():
return [
Package(name="jq"),
File(
path="/etc/systemd/journald.conf",
content="[Journal]\nSystemMaxUse=256M\n",
mode="0644",
),
]
The Nginx cookbook is more involved - it’s a class that takes configuration and generates a bunch of resources for SSL, certbot, rate limiting, upstreams, etc. But it’s all just Python code. Loops, conditionals, string manipulation - no special syntax needed.
Roles tie it together:
def role():
return [
ubuntu(),
Nginx(domains=[...], user="me@example.com"),
Podman(ghcr_token=os.environ.get("GH_CR_RO_TOKEN")),
]
The biggest win is that I’m writing code in a language I actually know. When I need to add conditional logic or loop over some configuration, I’m not reaching for documentation or Stack Overflow. It’s just Python.
I also get:
The resource/provider pattern is the same one Chef uses - I didn’t reinvent anything there. But frozen dataclasses give immutability for free, and I don’t have to think about Ruby semantics.
Bootstrapping is straightforward. For remote servers, bootstrap_remote.sh tarballs the chef directory and sends it over SSH:
tar cz . | ssh user@host "
cd \$(mktemp -d) &&
tar xz &&
sudo -E bash run_chef_role.sh my_role
"
The role script installs Python and uv if they’re missing, then runs uv run --no-dev chef my_role. uv handles the venv and dependencies automatically. Compared to bundle install and Ruby version management, this feels pretty light.
For local testing, bootstrap_local.sh copies everything to a temp directory first, so I don’t accidentally mess up my working tree.
The --debug flag does two things: shows environment variables (hiding tokens/passwords), and dry-runs every resource so I can see what would happen without touching the server.
The Nginx cookbook also prints out every certbot and shell command when debug is enabled, which makes it easy to inspect the generated configuration before running it.
This isn’t a full Chef replacement. I left out the things that make Chef useful at scale - dynamic node discovery, handlers that fire when resources change, notifications between services. Chef has a whole distributed state layer that lets you query your infrastructure and build derived configurations. I don’t have any of that.
But here’s the thing: I never used those features anyway. My servers are independent, and I prefer explicit ordering over implicit dependencies.
The core is ~800 lines of Python, plus cookbooks. The cookbooks read like Python code because that’s what they are.
This has been gradually replacing my Chef setup for a while now. Every time I bootstrap a new server or tweak an nginx config, I’m glad I don’t have to switch mental contexts into Ruby.
I think there’s a broader lesson here: sometimes the best tool is the one you already know. Chef is powerful, but for my use case, Python with a few good primitives is more flexible and maintainable.
There’s a certain satisfaction in building something that fits your exact needs - stripping away complexity and keeping only what you use. I still use the same configuration management concepts I learned from Chef, but now they’re expressed in code I understand.
I should also mention: this kind of project only makes sense in the era of AI coding tools. Reimplementing even a subset of Chef would have been a multi-week endeavor without AI assistance - writing providers, getting the edge cases right, debugging shell commands that only fail in production. The ROI just wouldn’t be there. But with AI, I could iterate quickly, catch issues early, and end up with something that actually works without drowning in yak shaving. Tooling has finally reached the point where building your own bespoke solution is practical instead of a trap.