How to create an application recipe#

Important

For the list of all available configuration options for application recipes, see the application options reference.

Before writing a recipe, spend some time understanding the software:

  • Does it provide any GUI or CLI programs?

  • Does it run any services?

  • Does it depend on any external services (for example, a database)?

Recipe file#

Create the application recipe directory and recipe file:

mkdir recipes/apps/<app-name>
touch recipes/apps/<app-name>/recipe.nix

Add the recipe file to Git:

git add recipes/apps/<app-name>/recipe.nix

Important

Nix can only see files tracked by Git. If the file is not added to Git, the recipe will not be recognized.

Metadata#

Start the recipe with the following content:

 1{
 2  pkgs,
 3  ...
 4}:
 5
 6{
 7  apps.app-name = {    # lowercase with hyphens
 8    displayName = "My App";
 9    description = "Short description of the app.";
10    usage = ''
11      Usage instructions in markdown format.
12
13      ```bash
14      echo "with syntax highlighting"
15      ```
16    '';
17
18    # More configuration to be added here.
19    # ...
20  };
21}

Important

Ensure that the remainder of the application recipe is done within the apps.app-name block to avoid nasty infinite recursion errors.

NGI grants#

If the project is supported by an NGI grant via NLnet, add a grants block.

In the Forge, we track: Commons, Core, Entrust and Review grants. While the first three are current fund themes, Review encompasses all non-current NGI funds (e.g. Assure, Discovery, PET, …).

See NLnet - Thematics Funds for more information and search in NLnet project page to find grants assigned to a particular project.

 1ngi.grants = {
 2  Commons = [
 3    "Example 1"
 4    "Example 2"
 5  ];
 6  Core = [
 7    "Example 1"
 8    "Example 2"
 9  ];
10};

Icon#

Add a custom icon if one is available. Otherwise, a fallback icon will be used.

1icon = ./icon.svg;

Note

Hexagonal icons for NLnet-supported projects can be found in the NLnet Hex Gallery (designed by NLnet and Gust). These designs are adaptations of the upstream projects’ logos and are licensed the same as the original logo.

Programs#

Add a programs block if the application provides GUI or CLI programs.

Runtimes#

Forge supports two program runtimes.

“program” runtime#

The program runtime is suitable for a single-executable GUI program, which is launched automatically when the runtime command is executed.

1programs = {
2  runtimes.program.enable = true;
3  mainPackage = pkgs.package-gui;  # launched automatically
4};

Launch a program using the program runtime:

nix run .#apps.<app-name>.program

“shell” runtime#

The shell runtime drops the user into a shell environment where one or more GUI or CLI programs are available. Programs must be launched manually.

1programs = {
2  runtimes.shell.enable = true;
3  packages = [
4    pkgs.package-gui  # GUI program available in the shell environment
5    pkgs.package-cli  # CLI program available in the shell environment
6  ];
7};

Enter the shell runtime environment and run a program:

nix shell .#apps.<app-name>

program-cli

Services#

Add a services block if the application provides services. It can contain one or more service components.

Start with runtime-independent service configuration. The only required option is process.command, which specifies the executable to start the service.

Additional arguments are passed using the process.argv option.

Services typically also need a configuration file, which can be supplied via the process.configData option. Configuration files are installed to $XDG_CONFIG_HOME directory, which can be referenced directly in process.argv.

 1services = {
 2  components.my-service = {
 3    process.command = pkgs.package;
 4    process.argv = [ "--config" "$XDG_CONFIG_HOME/service.toml" ];
 5    process.configData."service.toml" = {
 6      source = ./service.toml;
 7      path = "service.toml";
 8    };
 9  };
10};

Additionally, environment variables can be used to configure the service using the process.environment option.

1services = {
2  components.my-service = {
3    ...
4    process.environment = {
5      LOG_LEVEL = "info";
6    };
7    ...
8  };
9};

Service user and state directory (persistent data directory) are automatically created and managed by Forge but can be customized using process.user and process.stateDir options if required.

1services = {
2  components.my-service = {
3    ...
4    process.user = "root";
5    process.stateDir = "/var/lib/foo";
6    ...
7  };
8};

Endpoints exposed to the users of the app must be described using the endpoints option. Each endpoint is named and has a port, a transport-level protocol (tcp - default, or udp) and an application-level appProtocol (http - default, or any other protocol name such as https, websocket or postgresql). Ports used only between the components and resources of the app must not be configured as endpoints.

Endpoints are forwarded to the host by the container runtime and when a nixos system is launched in a VM, and they are opened in the firewall of deployed machines. The host port is the same as the endpoint port, except for privileged ports (below 1024), which are forwarded to 10000 + port (for example 443 is forwarded to 10443). Privileged ports can’t be bound on the host without root permissions, and the 10000 offset keeps the forwarded ports clear of the ephemeral port range. Forwarded ports which are blocked by the web browsers are moved to the next unblocked port (for example 80 is forwarded to 10081, because 10080 is blocked, and 6000 is forwarded to 6001). The host port can be changed using the endpoint forwardedPort option, which doesn’t accept the ports blocked by the web browsers.

 1services = {
 2  components.my-service = {
 3    ...
 4    endpoints = {
 5      web.port = 8080;
 6      events = {
 7        port = 8081;
 8        appProtocol = "websocket";
 9      };
10    };
11    ...
12  };
13};

Endpoints can be referenced by name, for example in the usage text:

 1let
 2  app = config.apps.my-app;
 3in
 4{
 5  apps.my-app = {
 6    usage = ''
 7      Web interface: http://localhost:''${toString app.services.components.my-service.endpoints.web.forwardedPort}
 8    '';
 9  };
10}

A pre-start script can be configured using the process.preStart option. The pre-start script runs before service startup, including restarts. This is useful for tasks like database migrations.

1services = {
2  components.my-service = {
3    ...
4    process.preStart = ''
5      program makemigrations && program migrate
6    '';
7    ...
8  };
9};

Dependencies between multiple services (service components) can be configured using the dependsOn option.

 1services = {
 2  components.my-service-A = {
 3    ...
 4    ...
 5  };
 6  components.my-service-B = {
 7    ...
 8    dependsOn = [ "my-service-A" ];
 9    ...
10  };
11};

Health checks#

A health check can be configured for a service component using the healthcheck option. When enabled, dependent services wait for the component to become healthy before starting.

The test command is executed directly (no shell) so binary paths must be fully specified. Use string interpolation to reference Nix store paths.

 1services = {
 2  components.my-service-A = {
 3    process = {
 4      ...
 5    };
 6
 7    healthcheck = {
 8      enable = true;
 9      test = [ "${lib.getExe pkgs.curl}" "-fs" "http://localhost:8080/health" ];
10      interval = "3s";
11      timeout = "3s";
12      startPeriod = "10s";
13      retries = 10;
14    };
15  };
16};

Additional resources#

Additional resources required by services - such as a database or reverse proxy can be configured using the resources option.

Resources are shared by all service components. By default, a resource has the backend role and starts before the components. A resource with the frontend role, such as a reverse proxy, starts after the components.

 1services = {
 2  components.my-service = {
 3    ...
 4    process.command = pkgs.my-service;
 5    ...
 6  };
 7
 8  resources.database.nixosConfig = {
 9    services.postgresql.enable = true;
10  };
11
12  resources.reverse-proxy = {
13    role = "frontend";
14    endpoints.web.port = 8080;
15    nixosConfig = {
16      services.nginx.enable = true;
17    };
18  };
19};

Resources are enabled by default. A resource can be disabled, for example when the application should use an external database instead:

1services.resources.database.enable = false;

Runtimes#

Forge supports two service runtimes. At least one runtime must be enabled to start the service.

Each runtime provides a setup option for configuring a one-time preparation script at startup — for example, to create directory structures or copy configuration files.

“container” runtime#

The container runtime runs each service component in a separate container using a Podman Compose file automatically generated from the recipe configuration. A generated script is the container entrypoint. It restarts the service after a failure and stops the service when the container stops.

Each component runs in separate single-process container, while resources run in a full NixOS systemd container.

Both components and resources can be extended with container runtime-specific configuration.

Enable the runtime, configure a setup script and resource configuration:

 1services = {
 2  components.my-service = {
 3    ...
 4  };
 5
 6  runtimes.container = {
 7    enable = true;
 8
 9    components.my-service.setup = ''
10      mkdir /var/lib/my-service/data
11    '';
12
13    resources.database.nixosConfig = {
14      services.postgresql.enableTCPIP = true;
15    };
16  };
17};

Run a configured service in bubblewrap:

nix run .#apps.<app-name>.services.my-service

The service runner prints the configuration files and the command that it uses to start the service.

Example of the output:

nix run .#apps.tau.services.tau-tower

Config: tau/tower.toml -> /nix/store/l8w2spmpwm1cif4wsnjajr4q39287k2w-config.toml
Running: /nix/store/vgi1b5730cvdpv72iddlc3w1di0bsy0k-tau-tower-0.2.2-beta-unstable-2026-03-14/bin/tau-tower

Note

This command is intended for quick iteration during application recipe development. The service runs inside a bubblewrap sandbox without user namespace support, so operations requiring user or group management — such as chown user:user — will fail.

Inspect bubblewrap command:

cat $(nix build .#apps.<app-name>.services.<service> --no-link --print-out-paths)/bin/<app-name>-<service>-sandbox

When services work as expected, run the entire application in containers:

nix run .#apps.<app-name>.container

Debugging#

Build container artifacts (required for next steps):

nix build .#apps.<app-name>.container --out-link build-container

Inspect run scripts:

tree ./build-container/

Inspect compose file:

podman-compose -f ./build-container/<app-name>/compose.yaml config

Inspect container image:

./build-container/bin/build-oci-images

podman load < <service-name>.tar
podman inspect localhost/<service-name>

Inspect a running container:

nix run .#apps.<app-name>.container
podman inspect <app-name>_<TAB>

Inspect service in a running container:

nix run .#apps.<app-name>.container
podman-compose -f build-container/<service-name>/compose.yaml exec <service-name> sh

# Run commands in container
# ...

“nixos” runtime#

The nixos runtime runs all services inside a NixOS machine. Each service component maps to a systemd unit.

All components and resources are running in a single NixOS system.

Components, resources, and the NixOS system itself can be extended with runtime-specific configuration using the nixosConfig option.

Enable the runtime, configure a setup script, and add resource configuration:

 1services = {
 2  components.my-service = {
 3    ...
 4  };
 5
 6  runtimes.nixos = {
 7    enable = true;
 8
 9    setup = ''
10      mkdir /var/lib/my-service/data
11    '';
12
13    nixosConfig = {
14      services.postgresql.authentication = ''
15        local all all trust
16      '';
17    };
18  };
19};

Run the application in a NixOS VM:

nix run .#apps.<app-name>.vm

Debugging#

Build VM artifacts (required for next steps):

nix build .#apps.<app-name>.vm --out-link build-vm

Inspect run scripts:

tree ./build-vm/

Inspect service in a running VM:

nix run .#apps.<app-name>.vm

# Run commands in VM
journalctl --unit <service-name>.service
systemctl show <service-name>.service

Tests#

Add test scripts to verify that programs and services work correctly:

 1test.programs = {
 2  script = ''
 3    program --help | grep "Usage: program"
 4  '';
 5};
 6
 7test.services = {
 8  script = ''
 9    curl="curl --retry 5 --retry-max-time 120 --retry-all-errors"
10
11    $curl --location localhost:8180 | grep --quiet "My App" >/dev/null
12  '';
13};

Run all tests:

nix build .#apps.<app-name>.test --print-build-logs

Run program test:

nix build .#apps.<app-name>.test-programs --print-build-logs

Run service test:

nix build .#apps.<app-name>.test-services-container --print-build-logs
nix build .#apps.<app-name>.test-services-nixos --print-build-logs

Re-exporting packages#

If exposing a package already in nixpkgs, consider re-exporting the package using the new build.builders.identity. This will allow the application to be accessed under the pkgs attribute path (i.e. pkgs.<app-name>). To do this, outside of the apps.<app-name> attribute set, add the following block:

1pkgs.<app-name> = {
2  build.builders.identity = {
3    enable = true;
4    derivation = pkgs.pkgsOriginal.<app-name>;
5  };
6};

Note

pkgs.pkgsOriginal will always provide packages from the original source of the pkgs attribute set (nixpkgs in almost in all cases). This is to clearly differentiate between packages provided in the forge (in recipes/pkgs), and packages from the original pkgs.

Examples#

Browse the example application recipes, or create your own Forge project to try them out:

nix flake init --template github:ngi-nix/forge#provider