Chapters

Introduction

Copland declares desired service state in Nix instead of maintaining hand-written init scripts. The service provider owns the reusable implementation, while a host enables it and supplies concrete configuration, storage and secrets.

The four patterns below are the mental model. foo and pkgs.foo deliberately stand for an arbitrary daemon.

Direct service declaration

At the lowest level, a complete command and one runlevel are enough. Copland generates the OpenRC script and runlevel entry.

{ pkgs, ... }:
{
  openrc.services.foo = {
    command = "${pkgs.foo}/bin/foo";
    runlevel = "default";
    respawn = true;
  };
}

command contains the complete command used to start the service, including any arguments. This example also enables automatic restarts with respawn = true.

There are no custom options, no cfg binding and no config = lib.mkIf ... module wrapper. Reusable services use the next pattern so that merely registering their provider has no runtime effect.

Service user

The service above runs as root. foo is the service name, not an account selection. Setting user = "foo" tells OpenRC to run the command as that account, which must be declared separately through users.users.foo.

If group is omitted, the account's primary group is used. The secret example below declares both the account and its group before selecting them for the service.

Automatic restarts

respawn is false by default. With respawn = true, OpenRC supervises the process and restarts it when it exits, without a limit on restart attempts. The command should keep its main process in the foreground so OpenRC can supervise it.

This is separate from restarting a changed service during a system switch. The later examples omit respawn and therefore do not restart their processes automatically after an exit.

Runlevels

Runlevels select which services to start, while dependencies such as need, after and before determine their startup order. The usual boot sequence is sysinit, then boot, then the configured default runlevel, normally default. Its services start without waiting for a user to log in.

For example, this selects server as the default runlevel and assigns foo to it:

{ config, pkgs, ... }:
{
  openrc.defaultRunlevel = "server";

  openrc.services.foo = {
    command = "${pkgs.foo}/bin/foo";
    runlevel = config.openrc.defaultRunlevel;
  };
}

Copland creates server from the service declaration. Setting openrc.defaultRunlevel makes it the runlevel entered after boot and used during system switches. Assigning a service to a custom runlevel alone does not make that runlevel start automatically.

Dependencies

Each example below is a separate Nix module. Package and executable names are placeholders for the programs you want to run.

need

Use need when a service cannot run without another service. Here, foo requires api, which requires database.

{ pkgs, ... }:
{
  openrc.services = {
    database = {
      command = "${pkgs.database}/bin/database";
      runlevel = "default";
    };

    api = {
      command = "${pkgs.api}/bin/api";
      runlevel = "default";
      dependencies.need = [ "database" ];
    };

    foo = {
      command = "${pkgs.foo}/bin/foo";
      runlevel = "default";
      dependencies.need = [ "api" ];
    };
  };
}

OpenRC starts them in the order database → api → foo. If database is missing or fails to start, neither api nor foo starts. Required services are started even if they belong to another runlevel.

after

Use after when startup order matters but the other service is not required. In this example, foo can work without api, but should start after it when both are selected.

{ pkgs, ... }:
{
  openrc.services = {
    api = {
      command = "${pkgs.api}/bin/api";
      runlevel = "default";
    };

    foo = {
      command = "${pkgs.foo}/bin/foo";
      runlevel = "default";
      dependencies.after = [ "api" ];
    };
  };
}

OpenRC handles api before foo. If api fails, foo can still start. The after declaration alone does not request that api be started.

before

Use before to express the order from the service that should start first. This example has the same startup order as the after example, with the relationship declared in api.

{ pkgs, ... }:
{
  openrc.services = {
    api = {
      command = "${pkgs.api}/bin/api";
      runlevel = "default";
      dependencies.before = [ "foo" ];
    };

    foo = {
      command = "${pkgs.foo}/bin/foo";
      runlevel = "default";
    };
  };
}

api is handled before foo, but its success is not required for foo to start. The before declaration does not start foo by itself. There is no need to also declare after in foo.

use

Use use for an optional service that should start first when it belongs to the runlevel. Here, foo uses a logger selected for default.

{ pkgs, ... }:
{
  openrc.services = {
    logger = {
      command = "${pkgs.logger}/bin/logger";
      runlevel = "default";
    };

    foo = {
      command = "${pkgs.foo}/bin/foo";
      runlevel = "default";
      dependencies.use = [ "logger" ];
    };
  };
}

OpenRC tries to start logger before foo. If the logger is missing or fails, foo still starts. Moving the logger to a separate runlevel means use no longer requests its startup when entering default.

want

Use want when an optional service should be requested even outside its assigned runlevel. Here, logger belongs to logging, but foo requests it when starting in default.

{ pkgs, ... }:
{
  openrc.services = {
    logger = {
      command = "${pkgs.logger}/bin/logger";
      runlevel = "logging";
    };

    foo = {
      command = "${pkgs.foo}/bin/foo";
      runlevel = "default";
      dependencies.want = [ "logger" ];
    };
  };
}

OpenRC tries to start logger before foo without entering the entire logging runlevel. If the logger is missing or fails, foo still starts. Unlike use, want does not leave the startup decision to the logger's runlevel membership.

Opt-in service provider

A provider exposes the settings a host may declare and translates them into the complete OpenRC service.

{
  config,
  lib,
  pkgs,
  ...
}:

let
  cfg = config.services.foo;
  configFile = pkgs.writeText "foo.toml" cfg.config;
in
{
  options.services.foo = {
    enable = lib.mkEnableOption "Foo";

    config = lib.mkOption {
      type = lib.types.lines;
      default = "";
    };
  };

  config = lib.mkIf cfg.enable {
    openrc.services.foo = {
      command = "${pkgs.foo}/bin/foo --config ${configFile}";
      runlevel = config.openrc.defaultRunlevel;
    };
  };
}

The provider can be registered for every system and remains inert until a host sets services.foo.enable = true. Changing services.foo.config creates a new immutable configuration path, which changes the service's runtime identity during activation.

Activation-time secret

Encrypted input may live beside its host-side consumer. Copland decrypts it only during activation and binds changes to the consuming service.

{
  config,
  pkgs,
  ...
}:
{
  users.groups.foo = { };

  users.users.foo = {
    isSystemUser = true;
    group = "foo";
  };

  age.secrets."foo-token" = {
    file = ./foo-token.age;
    owner = "foo";
    group = "foo";
    mode = "0400";
    restartServices = [ "foo" ];
  };

  openrc.services.foo = {
    command = "${pkgs.foo}/bin/foo --token-file ${config.age.secrets."foo-token".path}";
    user = "foo";
    group = "foo";
    runlevel = config.openrc.defaultRunlevel;
  };
}

Only the ciphertext enters the repository and Nix store. The plaintext appears at /run/secrets/foo-token with the declared identity and mode before OpenRC starts the daemon. A changed ciphertext restarts foo through its declared runtime identity.

Complete stateful service provider

The complete pattern brings application configuration, identity, secrets, persistent state and resource budgets into one provider contract.

{
  config,
  lib,
  pkgs,
  ...
}:

let
  cfg = config.services.foo;
  configFile = pkgs.writeText "foo.toml" cfg.config;
in
{
  options.services.foo = {
    enable = lib.mkEnableOption "Foo";
    package = lib.mkPackageOption pkgs "foo" { };

    config = lib.mkOption {
      type = lib.types.lines;
      default = "";
    };

    dataDir = lib.mkOption {
      type = lib.types.path;
      default = "/var/lib/foo";
    };

    dataset = lib.mkOption {
      type = lib.types.nonEmptyStr;
    };

    datasetQuota = lib.mkOption {
      type = lib.types.nonEmptyStr;
      default = "10G";
    };

    secretFile = lib.mkOption {
      type = lib.types.path;
    };

    cgroup.settings = lib.mkOption {
      type =
        with lib.types;
        attrsOf (oneOf [
          str
          int
          bool
        ]);

      default = {
        "memory.max" = "512M";
        "pids.max" = 256;
        "cpu.weight" = 100;
      };
    };
  };

  config = lib.mkIf cfg.enable {
    users.groups.foo = { };

    users.users.foo = {
      isSystemUser = true;
      group = "foo";
      home = cfg.dataDir;
    };

    age.secrets."foo-token" = {
      file = cfg.secretFile;
      owner = "foo";
      group = "foo";
      mode = "0400";
      restartServices = [ "foo" ];
    };

    zfs.datasets.${cfg.dataset} = {
      mountpoint = cfg.dataDir;
      owner = "foo";
      group = "foo";
      quota = cfg.datasetQuota;
    };

    openrc.services.foo = {
      command = "${cfg.package}/bin/foo --config ${configFile} --data-dir ${cfg.dataDir} --token-file ${config.age.secrets."foo-token".path}";
      user = "foo";
      group = "foo";
      environment.HOME = cfg.dataDir;
      dependencies.need = [ "zfs-mount" ];
      cgroup.settings = cfg.cgroup.settings;
      log = true;
      runlevel = config.openrc.defaultRunlevel;
    };
  };
}

The host supplies only its concrete choices:

{ ... }:
{
  services.foo = {
    enable = true;

    config = ''
      listen = "127.0.0.1:8080"
      workers = 4
    '';

    dataset = "tank/foo";
    datasetQuota = "20G";
    secretFile = ./foo-token.age;

    cgroup.settings."memory.max" = "1G";
  };
}

The provider owns the baseline policy; the host owns application settings, the pool-relative dataset name, encrypted material and intentional overrides. Activation installs the secret and reconciles the dataset, mountpoint, owner and quota. OpenRC then applies the cgroup v2 settings to the daemon's complete process tree.

The ZFS declaration is for service state created below an existing host-owned pool. Static datasets already declared by Disko are not repeated under zfs.datasets.