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= or Requires=. 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 cidrsubnet and 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 resource keyword 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 data block 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:

  1. 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.

  2. 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.

  3. Reload Configuration: After the files are in place, the tool can orchestrate the systemd reload for you. Whether that’s a daemon-reload followed by systemctl 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.