Running local development services

Leveraging Nixpkgs and systemd for production-grade local services

Intro

Nix developer shells are one of my favorite Nix features. They let you spin up a full environment with every tool you need. That way, you never have to worry about manually installing dependencies or messing up your system. They’re also a lifesaver for onboarding – new team members can get to work with one command:

nix develop

But real projects often need services like databases or message queues. You usually want to run these locally so you can play with data without affecting anyone else, and this is where Nix shells currently fall short. There’s been some progress in this area, but I think we can still do better.

In this tutorial, we’ll walk through a hands-on setup to bridge this gap. We’ll build a lightweight NixOS VM that runs battle-tested Nixpkgs services managed by systemd. To follow along, all you need to know is how to set up a basic Nix flake, use a Nix dev shell, and write simple NixOS modules. By the end, you’ll have a reproducible, production-grade service stack ready for local development with a single command.

The current landscape

Running backing services locally alongside an application isn’t a new challenge. We already have container orchestrators like Docker Compose and Podman Compose, as well as native process supervisors like Hivemind or dekit.

The Nix ecosystem takes this a step further by combining dependency provisioning and process management into a single workflow via tools like devenv or devbox. (Shout out to devshell as well, though its service support is currently limited to PostgreSQL.) These are great projects – I’m a devenv user myself – but I have two gripes with the current approach:

  1. We’re reinventing the wheel

    Nixpkgs already has a massive library of service modules. Yet, tools like devenv end up re-implementing them from scratch. To me, that’s a waste of effort. You end up with subtle differences and manual porting between projects instead of just using the battle-tested definitions we already have.

  2. Yet another process manager.

    devshell starts PostgreSQL with a shell script, Devbox uses Process Compose, and devenv even has a custom process manager. Don’t get me wrong, this does the job, but it’s one more thing to learn that only exists in development. That knowledge doesn’t help you when you’re actually running things in production. And then there’s the risk of eventually running into corner cases where the two environments behave in subtly different ways. That’s what Nix was supposed to fix, right?

My ideal solution? I want to use the exact same service modules from Nixpkgs and orchestrate them with a tool I can use in production, like systemd.

Running a NixOS VM

Nixpkgs makes it incredibly easy to spin up a local VM. Let’s explore how we can leverage this to host our development services.

For simplicity, we’ll assume that you’re on Linux. Support for macOS hosts is left as an exercise for the reader. 1

First, let’s define a helper function to build the VM:

# vm-helper.nix
{ lib, ... }:
{
  nixosVM =
    {
      system ? "x86_64-linux",
      modules,
    }:
    (lib.nixosSystem { inherit system modules; }).config.system.build.vm;
}

The vm attribute contains a package with an executable that starts a QEMU virtual machine configured by modules.

The key here is that the modules argument accepts standard NixOS modules. This allows you to use the exact same configuration blocks you would use for a NixOS system.

Using the vm-helper.nix we just wrote, we can define a package that launches the VM:

# flake.nix
{
  inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
  outputs =
    { nixpkgs, ... }:
    let
      system = "x86_64-linux";
      inherit (import ./vm-helper.nix { inherit (nixpkgs) lib; }) nixosVM;
    in
    {
      packages.${system}.default = nixosVM {
        modules = [ ./dev-vm.nix ];
      };
    };
}

Configuring the guest VM

In dev-vm.nix, we define how the VM behaves. This file establishes the common configuration we want whenever we run a VM for local development. Since this is strictly for local use, we can strip away the extras: we’ll disable graphics, turn off the firewall, and even add a shortcut (Ctrl-k) to instantly shut down the VM:

# dev-vm.nix
{ pkgs, modulesPath, ... }:
{
  imports = [
    "${modulesPath}/profiles/qemu-guest.nix"
    "${modulesPath}/virtualisation/qemu-vm.nix"
  ];

  virtualisation.graphics = false;

  networking.firewall.enable = false;

  programs.bash.shellInit = ''
    bind -x '"\C-k": poweroff'
  '';

  services.getty.autologinUser = "root";

  system.stateVersion = "26.05";
}

Integrating with the development shell

Finally, we want to make launching this VM seamless. We can add a custom script directly to a devShell attribute so we don’t have to remember any long commands:

# flake.nix
{
  # ...
  outputs = { self, nixpkgs, ... }:
    let
      # ...
      pkgs = nixpkgs.legacyPackages.${system};
    in
    {
      # ...
      devShells.${system}.default = pkgs.mkShell {
        packages = [
          (pkgs.writeShellApplication {
            name = "vm";
            text = "exec run-nixos-vm";
            runtimeInputs = [ self.packages.${system}.default ];
          })
          pkgs.postgresql
          # ...
        ];
      };
    };
}

Now, whenever you’re in your devShell, just run vm. You’ll have a local NixOS VM running whatever services you need, managed exactly like they would be in production.

Adding services to our developer VM

With our basic VM in place, we can start adding the services we need.

To keep things clean, we will split the configuration into two modules:

  1. production.nix: The standard service configuration. If you run NixOS in production, this can be the exact same file you deploy to your servers. (If you deploy elsewhere, like Kubernetes, treat this as your environment-agnostic baseline).
  2. local.nix: Local development overrides (port forwards, relaxed authentication, dev tools).

Because the NixOS module system automatically merges declarations together, any setting defined in local.nix cleanly overlays onto production.nix without requiring us to touch the base service definition.

Here is the plan: we will define the service baseline, layer our local overrides on top, and then wire both modules into flake.nix.

1. Defining the base services (production.nix)

Let’s assume our stack requires PostgreSQL and FerretDB. We declare them in production.nix:

# production.nix
{ ... }:
{
  services.postgresql = {
    enable = true;
    ensureDatabases = [ "app-db" ];
    ensureUsers = [
      {
        name = "app-db";
        ensureDBOwnership = true;
      }
    ];
    enableTCPIP = true;
  };

  services.ferretdb = {
    enable = true;
    settings = {
      FERRETDB_LISTEN_ADDR = "0.0.0.0:27017";
    };
  };
}

Since NixOS uses systemd to manage services, we can easily extend these definitions if needed. For instance, if you want to run a custom setup script after the PostgreSQL database is up and running, you can simply hook into the postStart script:

# production.nix
{ ... }:
{
  # ...
  systemd.services.postgresql.postStart = "# Do something";
}

Since FerretDB can use PostgreSQL as its backend, a more realistic example would be extending the FerretDB systemd unit so that it depends on the Postgres service:

# production.nix
{ ... }:
{
  services.postgresql = {
    # ...
    ensureDatabases = [
      # ...
      "ferret"
    ];
    ensureUsers = [
      # ...
      {
        name = "ferret";
        ensureDBOwnership = true;
      }
    ];
  };
  services.ferretdb = {
    # ...
    settings = {
      # ...
      FERRETDB_HANDLER = "pg";
      FERRETDB_POSTGRESQL_URL = "postgres://ferret@localhost/ferret";
    };
  };

  systemd.services.ferretdb = {
    after = [ "postgresql.target" ];
    bindsTo = [ "postgresql.target" ];
  };
}

For more complex scenarios, you can even define custom systemd services that depend on PostgreSQL. This level of control is why leveraging NixOS modules – and the full power of systemd – is so much more effective than re-implementing services inside a separate development tool.

2. Layering local overrides (local.nix)

In production, you would lock down firewall rules, bind to internal networks, and require secure passwords. In local development, you usually want the opposite: host-accessible ports and hassle-free authentication.

Instead of hacking conditionals into our base setup, we use NixOS module composition. In local.nix, we forward ports through QEMU to our host and override PostgreSQL’s auth to trust local connections:

# local.nix
{ ... }:
{
  services.postgresql = {
    authentication = ''
      host    all    all   127.0.0.1/0    trust
    '';
  };

  virtualisation.forwardPorts = [
    {
      host.port = 5432;
      guest.port = 5432;
    }
    {
      host.port = 27017;
      guest.port = 27017;
    }
  ];
}

3. Wiring everything into the flake

Now that both modules are defined, we update flake.nix to supply them to our VM helper.

# flake.nix
{
  # ...
  outputs = { nixpkgs, ... }:
    # ...
    {
      packages.${system}.default = nixosVM { modules = [
        ./dev-vm.nix
        ./production.nix
        ./local.nix
      ]; };
    };
}

We can also add environment variables to our devShell so we can connect to Postgres and Ferret without having to remember long URLs:

# flake.nix
{
  # ...
  outputs =
    { nixpkgs, ... }:
    # ...
    {
      devShells.${system}.default = pkgs.mkShell {
        PG_URI = "postgres://app-db@localhost/app-db";
        FERRET_URI = "mongodb://127.0.0.1:27017";
        # ...
      };
    };
}

Now you’re ready to run the VM:

nix run .

Monitoring services on the VM

One of the best things about tools like Process Compose is how easily you can view service logs. While our VM approach is more robust, we still want that same level of visibility.

There are several ways to accomplish this. For a high-quality terminal experience, you can use a tool like lazyjournal. It provides a beautiful TUI for navigating systemd logs, giving you a “dashboard” feel for your VM’s services without leaving your terminal.

Add it to your local configuration and run lazyjournal in the console after starting the VM:

# local.nix
{ pkgs, ... }:
{
  # ...
  environment.systemPackages = [
    pkgs.lazyjournal
  ];
}

This is just one example of how you can keep extending your local development VM with NixOS module options to suit your needs.

Closing notes

Running a full NixOS VM sits at one extreme of the spectrum for managing development environments. With this approach, we trade runtime performance and iteration speed for full reproducibility. Compared to native process managers, a VM carries a higher resource footprint and takes a few extra seconds to boot. However, because this setup is intended for developer workstations, hardware overhead is rarely a bottleneck. Trading some spare RAM and a slightly slower initial launch for complete host isolation, zero configuration drift, and battle-tested systemd fidelity is well worth the cost.

The benefits don’t stop at local development. We can launch this exact same VM in CI to run reproducible integration tests.

While this post walked through a basic implementation, it could be refined and abstracted into a reusable library.

You can find the complete working code in the dev-services-nix-demo repository.

There’s more to explore, such as customizing the development VM to match your host environment. For example, we could set up SSH access to the VM without checking keys into version control. We will come back to that in a future post.

  1. If you are on macOS, the example repository features a darwin branch. It adds extra virtualization complexity, so we suggest walking through the main branch on Linux first. Also, remember that on macOS you need to set up the Linux builder ↩