Unitd: A DSL and my dream of an easy configuration life.
As a DevOps engineer, most of my working day is writing configurations, or updating them, sometimes monitoring reality, and then⌠updating them again.
Problems
LSP
Markup languages are perfect for configurations. We have YAML to config mostly everything.
Then we have JSON for people who donât like tab, or using JavaScript. The elders use XML.
The odd ones use INI. And NGINX use their cool config language.
The problem is YAML doesnât always come with a schema.
RedHat created yamlls and I love it,
but only if the YAML file Iâm writing come with a schema.
And sometimes we need more than just schema suggestions. Especially when weâre talking about LSP.
systemd is a good example. Systemd uses INI files for configuration.
To me, itâs clean and makes sense for what it does.
And people have created LSP tools for it that Iâm actually using.
I rely on JFryy/systemd-lsp installed via mason-org/mason.nvim.
Itâs good! But itâs not enough for me. I want moreâmeaning, more than just an LSP.
- I want the LSP to know what units exist in the project or machine so I can reference them in
Wants=orRequires=. So I donât accidentally reference non-existent units. - I want to source the secrets to my service environment config from external services.
- I want clean, nested configuration code that feels satisfying when managing 20 different environments.
Too much? NIE, I also want to calculate
cidrsubnetand use it in configurations :). And maybe I want the same configurations for multiple VMs, like shared journald settings? Yes, bring me TERRRAFROM.
Terraform Providers
I found a provider for systemdârobbert229/terraform-provider-systemâand the code looks like:
resource "system_service_systemd" "nginx" {
name = "nginx"
enabled = true
status = "started"
depends_on = [
system_packages_apt.nginx
]
}
The problem with this provider is that it works like Terraform when applied to systemd.
It focuses on the current state of servicesâwhether theyâre enabled and running.
This makes sense if you only care about status and enabled,
but it leaves the actual creation and deployment of unit files as a separate concern.
So it wonât satisfy me, especially since I care deeply about the coding experience.
Using template_file in Terraform will make me hate my life.
Another issue is the providerâs workflow.
From what I understand, it uses SSH to connect to a server,
checks the state, and runs systemctl commands as needed.
If a command returns a non-zero code, the apply fails.
The problem is that this approach doesnât translate well to systemdâ
itâs not a cloud service.
- A service can start successfully if the configuration is correct, and then fail right after you close your laptop to grab coffee. So itâs somewhat meaningless to declare success if the service fails moments later.
- A unit in systemd is identified by its unit file name, so you can use the resource name as its ID. You donât need a state file to keep the actual unit and your code in sync. This is a key differenceâunit resources can exist independently of any state file.
- I donât want repeating the
resourcekeyword to write all my units.
For all these reasons, we should probably move away from a Terraform provider approach because its core assumptions donât translate to systemd. But HCL itself is goodâit at least solves the problems mentioned earlier:
- We can use HCL to build a tree of units, and LSP can leverage that to suggest valid references.
- We can use something like Terraformâs
datablock to fetch secrets and use them in properties. - HCL already works well. Inspired by Nginx configuration, itâs perfect for complex setups. With HCL and some creativity, we can solve these problems.
Ansible?
You might ask âWhy not Ansible?â My team and I are actually using it. Yes, itâs not only the first tool we consider, itâs what we currently use.
But as mentioned in the LSP section, Ansible doesnât make this betterâ it makes it worse.
While itâs great for installing packages like Nginx, itâs a poor experience for managing systemd unit files.
- Fetching a secret from an external source takes 5-12 lines of code.
- Using Jinja templating wonât give you systemd LSP support natively. Writing Jinja templates feels worse to me than writing React code.
That said, Ansible is better suited than Terraform for managing status and enabled.
You can see this inansible.builtin.systemd_service.
Itâs simply because Ansible was designed for orchestration tasks like this.
Nix?
Well, I did try Nix Package Manager. But to control systemdâa process with ID 1â
you need something more powerful than a package manager.
When writing Nix code:
- If I want to write systemd config the NixOS way, it requires NixOS to apply.
- If I want to write systemd config in Nix syntax, I need functions to convert it to strings, write it to files, and add commands to start services.
After trying either approach, none of my colleagues want to maintain it because what Iâve written looks worse than my actual life.
The reason I havenât tried NixOS is simple: most mainstream cloud providers donât offer NixOS images out of the box. Youâd have to upload a NixOS image and create VMs from it, which is extra work Iâd rather avoid. Plus thereâs additional cost.
Unitd
Now I want something that:
- Lets me write any systemd configuration using Terraform-like syntax with support for sourcing external data.
- Provides an LSP that suggests completions, explains field meanings (essentially docs), and knows which units can be referenced.
- Includes a tool to deploy configurations to multiple servers, warn about missing pieces, and list required binaries.
The syntax
Like I mentioned, I want something similar to Terraform using HCL.
Hereâs what the service syntax looks like:
service "nginx" {
unit {
description = "NGINX Web"
after = [NETWORK.target]
}
service {
exec_start = "/usr/sbin/nginx -g 'daemon off;'"
standard_output = JOURNALD
}
install {
wanted_by = [MULTI_USER.target]
}
}
And so on for timer, device, swap, âŚ
For the systemd ecosystem like journald, the config looks like:
journal "server" {
storage = "persistent"
compress = true
}
Similar with resolved
dns "cloudflare" {
servers = ["1.1.1.1", "1.0.0.1"]
}
And when I want to put those config in any server, the config will look like this:
host "web01" {
ssh {
user = "root"
host = "10.0.0.1"
}
enable = [service.nginx, target.web_stack]
disable = [timer.nginx_reload]
journals = [journal.server]
}
We can also add users via sysusers:
user "nginx" {
uid = 101
shell = "/usr/sbin/nologin"
}
And many more block types are documented here.
The lifecycle
This tool workflow intentionally keeps things simple and straightforward, without all the noise that cloud infrastructure tools bring. Why? Because systemd doesnât need complex state management or resource IDs across multiple environments. Weâre just moving files and reloading them.
Hereâs how I imagine it working:
-
Check and Compare: The tool connects to your remote hosts and reads the current systemd configurations. It compares them with what youâve defined in your HCL files. This is where you see the actual diff â whatâs different between your code and reality. Maybe a file is missing, or someone manually edited a service file on the production server at midnight (weâve all been there). You get a clear picture of what needs to change.
-
Compile and Deploy: Once youâre satisfied with the diff and want to apply changes, the tool will compile your HCL definitions into actual INI files. But hereâs the key â it doesnât just scp them over. Itâs smarter than that. It validates the compiled configurations against the systemd schema, warns you if there are missing binaries referenced in
ExecStart, and checks for obvious mistakes before touching any server. -
Reload Configuration: After the files are in place, the tool can orchestrate the systemd reload for you. Whether thatâs a
daemon-reloadfollowed bysystemctl restart, or just restarting specific services, it depends on what changed. Not every configuration change needs a full daemon reload, and we should be intelligent about it.
The beauty of this approach is that itâs declarative without being stubborn. Youâre not asking systemd âplease ensure this service is runningâ â youâre saying âhere are the configuration files, now put them in place.â If the service fails to start after reload, thatâs on the configuration, not on the tool. Which is completely fine.
What I implemented and the plan
You can find all my current work in the unitd repository. Iâm not starting from zero here â thereâs already a foundation, but thereâs still a lot of work ahead. Let me break down the phases:
Phase 1: Data Generation
First, I need a reliable way to extract all systemd configuration options and generate Go code from them. Currently, Iâm parsing the official systemd documentation, which works but feels fragile. Every time systemd updates, I need to manually update my definitions. The better approach â and what Iâm leaning towards â is actually parsing the systemd source code directly. That way, Iâm always in sync with what systemd actually supports. This generated data becomes the backbone of everything else: code completion, validation, and LSP suggestions.
Phase 2: Core Language Features
Once I have the systemd definitions baked into Go, I can build all the language features around them. This includes validating your HCL syntax against actual systemd options, compiling your HCL into proper INI configuration files, resolving references between services (so Wants= actually knows what units exist), and catching mistakes early. This is also where Iâd add support for variable interpolation and computed values like cidrsubnet() calculations.
Phase 3: Tool Implementation
Finally, the actual commands that users interact with: diff to preview changes, put to deploy configurations, apply to activate them, and lsp to power editor integration. Each one builds on the layers below, so once the foundation is solid, these commands become relatively straightforward.
My dream
Hereâs where I let myself dream a little.
Imagine a world where the systemd maintainers, people who know the system better than anyone, publish formal definitions of all their configuration blocks and options. Not just documentation, but machine-readable metadata. A community repository that tools like unitd can consume to auto-generate everything they need.
When that day comes, every tool that touches systemd will be consistent. Every editor will have the same systemd LSP. Every configuration tool will understand the schema identically. And developers like me will never have to write another Jinja template or error-prone YAML again.
Will that day come? I donât know. But someone has to start somewhere, and I figured it might as well be me. Even if unitd never becomes what I dream it could be, at least Iâm building something that works the way I want it to work. And maybe, if enough people find it useful and contribute, it becomes something bigger.
My realistic goal is to fully implement unitd to the point where Iâm actually using it in production, maintaining it through real use cases, and documenting the journey here on this blog. This is my first writing series, and Iâm genuinely excited about the possibilities. If youâre interested in systemd, infrastructure as code, or just solving configuration problems in a better way, I hope youâll follow along as we build this together through upcoming chapters.