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.
Links#
Add a
links
block that contains URLs to the project source, website, and documentation.
1links = {
2 website = "https://www.example.com";
3 docs = "https://docs.example.com";
4 source = "https://github.com/example/app";
5};
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