Back to writing

Building Denia: A Docker-Free Single-Node PaaS

rustlinuxinfrastructurepaas

Denia project cover

I have used Docker for a long time. It is useful, familiar, and supported by almost every deployment tool. But when I wanted to run a few services on a machine that I own, I started looking at everything around the container itself.

There was a daemon, a reverse proxy, a certificate companion, a registry, configuration files, and a collection of scripts to connect them. None of those parts was impossible to operate. The problem was that a small self-hosted setup slowly started to feel like a miniature platform team.

That was the starting point for Denia: a Docker-free, single-node PaaS that runs services on its own Linux runtime. The project is open source, written in Rust, and distributed as one binary.

The constraint: one machine

Denia is not trying to replace Kubernetes. It does not schedule workloads across a cluster, and it does not provide a managed control plane. Its first version targets a smaller and more specific problem: I have one Linux machine, I want to run my services, and I want the operational path to stay understandable.

That constraint is useful because it makes trade-offs visible. There is no multi-node consensus problem to solve, no global scheduler, and no hosted service to hide the machine from the operator. Denia can focus on the lifecycle of a service on one host.

Replacing the container runtime

The most important design decision is that Denia does not call Docker, containerd, or runc to launch workloads. Its runtime uses Linux primitives directly:

  • User, PID, mount, UTS, and IPC namespaces
  • cgroup v2 for resource control
  • no_new_privs and a reduced capability set
  • A private overlay root filesystem for each replica

This is more work than shelling out to a container runtime, but it keeps the execution model inside the project. A replica is still an isolated workload with resource limits and a filesystem, but there is one fewer daemon in the path.

This also comes with a clear security boundary. Denia is not an untrusted sandbox. A daemon compromise should be treated as a host-level incident, just as it would be with other host-level container infrastructure. I would run workloads from people I trust on the node, and use a separate machine for untrusted code.

One binary owns the boring parts

The runtime is only one part of the problem. A service still needs a way to receive traffic, get a certificate, scale, and expose useful diagnostics.

Denia keeps those pieces in the same binary:

  • An in-process Pingora proxy handles L7 ingress on ports 80 and 443.
  • ACME issues and renews per-domain certificates without certbot.
  • The autoscaler uses CPU and memory signals and can scale a service down to zero.
  • A host resource ledger prevents the node from promising more capacity than it has.
  • An embedded OCI registry gives the node a local artifact path.
  • The web console can open a shell in a running replica through a single-use ticket.

The goal is not to make infrastructure disappear. It is to make the ownership obvious. When I need to understand how traffic reaches a service, which deployment is active, or where a secret comes from, the answer is inside Denia instead of spread across several external components.

The deployment flow

I wanted deployment to start from the project directory instead of requiring a local Docker daemon or a pre-deploy commit. The client flow looks like this:

denia auth --url https://your-node.example.com
denia push

The project can keep the deployment defaults in a small .denia manifest:

project    = "default"
service    = "api"
dockerfile = "Dockerfile"
context    = "."

denia push packs the working tree while respecting .gitignore and .dockerignore, uploads it to the node, and lets the node build the Dockerfile with BuildKit. After the build, Denia starts the new deployment and waits for its HTTP health check before promoting traffic. The previous deployment remains available for rollback.

That health gate matters more to me than a fancy deployment dashboard. A deploy is not successful because an image was built. It is successful when the new process is healthy and the router can safely send traffic to it.

Secrets and operations

I also wanted the control plane to distinguish normal environment configuration from secrets. Service environment variables can be part of the service configuration, while secret values are stored in SOPS-encrypted files. SQLite keeps references to those files rather than storing the raw values in the database.

The same principle applies to observability. Denia reads runtime information from cgroup v2, procfs, and statvfs, then exposes service logs, access logs, and resource metrics through the API and console. A single-node tool does not need a distributed tracing platform to answer every question, but it does need enough local information to explain why a service is unhealthy.

What I learned from keeping it small

The biggest lesson is that “single node” is not the same as “simple.” Even one machine has process isolation, resource accounting, TLS, routing, credentials, rollbacks, upgrades, and failure modes. Keeping the scope small does not remove those problems; it lets me work through them without also building a cluster scheduler.

It also made the boundaries more honest. Denia requires a Linux host with cgroup v2 and systemd. Git-based builds need BuildKit. The first release is not multi-node, not managed, and not a security boundary for arbitrary third-party workloads. Those limitations are part of the product definition, not footnotes to hide.

For now, Denia is the platform I wanted for a box I control: one Rust binary, a small set of Linux primitives, and a deployment flow that stays close to the service source. You can visit the project site, read the source, or explore the architecture decisions.