Crucible / Docs

Package an application for AOS

An AOS package is a Nix derivation built from source with the AOS package set. Adding an application normally has three parts:

  1. define the package under pkgs/;
  2. declare its typed package module when it contributes runtime behavior;
  3. include it in a system variant or publish it to a registry.

This guide builds a small service package called acme-health-agent. The example is deliberately self-contained so it can be evaluated and built without a separate source repository.

Use Review package security while choosing dependencies and native ability requests. The corresponding operator-visible boundary is documented in Understand native package runtime policy.

#Define the package

Create pkgs/acme/acme-health-agent.nix:

{
  mkDerivation,
  coreutils,
  writeShellScriptBin,
}: let
  agent = writeShellScriptBin "acme-health-agent" ''
    set -eu

    while true; do
      printf 'acme-health-agent: healthy at %s\n' \
        "$(${coreutils}/bin/date --iso-8601=seconds)"
      ${coreutils}/bin/sleep 60
    done
  '';
in
mkDerivation {
  pname = "acme-health-agent";
  version = "1.0.0";
  src = null;

  runtimeDeps = [
    agent
    coreutils
  ];

  phases = [
    {
      name = "install";
      script = ''
        mkdir -p "$out/bin"
        ln -s ${agent}/bin/acme-health-agent \
          "$out/bin/acme-health-agent"
      '';
    }
  ];

  meta = {
    description = "Acme host health agent";
    license = "Apache-2.0";
  };
}

Package files are discovered recursively. Files and directories whose names begin with _, and files named default.nix, are not published as packages. The file above therefore creates pkgs.acme-health-agent and the flake output pkg-acme-health-agent.

A discovered file that returns a callable package factory instead of a derivation must be listed in packageFactories in pkgs/default.nix. Keep the factory available through pkgs.<name> for its callers, but exclude it from packageNames, the pkg-* flake outputs, and packages.<system>.all; Nix cannot build a function. Use this explicit inventory because dynamically probing every discovered value would evaluate unrelated packages and trigger their IFDs during otherwise isolated builds.

The builder shell is POSIX sh. Keep phase scripts portable, and use explicit AOS package paths in generated scripts. Do not use /bin/bash, /usr/bin/env, host tools, or nixpkgs packages.

#Understand dependencies

Use the dependency field that matches why the package is needed:

FieldUse
buildDepsCompilers, build systems, generators, and other build-only tools
runtimeDepsLibraries and commands needed when the package runs
propagatedDepsDependencies that downstream builds must inherit

Runtime dependencies normally identify installable packages and become available to the retained module. For an internal artifact such as a bootstrap interpreter embedded in generated scripts, use an explicit closure-only dependency:

runtimeDeps = [
  { package = stdenv.bash; closureOnly = true; }
  openssl
];

The builder still consumes that exact interpreter in the declared order, and references in the output retain it in the Nix store closure. It does not become an installable package dependency or a retained module binding. Ordinary runtime libraries and commands remain ordinary dependency entries.

mkDerivation already supplies the wrapped compiler and bootstrap tools. List application-specific build tools, libraries, and runtime commands explicitly. Include tools that an upstream configure script probes before the compile even when the build phases do not invoke them directly; for example, declare Perl in buildDeps when configure rejects a missing Perl interpreter. Declare generators such as Bison even when a release archive includes generated sources, because build rules can regenerate them after unpacking or patching. Pass the generator through the upstream configure interface when one exists so the build cannot silently fall back to an undeclared host command. Make excluded optional components explicit through the upstream configure interface. Do not rely on a missing dependency to disable a component: a CMake helper can return after a failed probe while its parent directory continues generating targets with NOTFOUND include or library paths. Assert the component's disabled cache value after configure so an upstream default change cannot silently expand the package feature set. If a dependency is missing from AOS, package it from source rather than reaching into the host or importing nixpkgs.

Select a package's named output, such as library.dev, when a downstream build needs only that output's headers or package metadata. mkDerivation preserves the package's propagatedDeps on every named output, so downstream search paths still contain the transitive dependencies required by those headers and .pc files. Declare those requirements once on the producing package; do not repeat them on each consumer merely because the producer splits its outputs.

mkDerivation also enables the repository hardening profile. Keep that profile unless an upstream representation is incompatible with one specific flag. For example, code that deliberately uses a trailing one-element or zero-length array as variable-length storage can trigger false _FORTIFY_SOURCE aborts under strictflexarrays3. In that case, preserve fortify and the other hardening checks while selecting the compatible flexible-array interpretation:

hardeningDisable = ["strictflexarrays3"];
hardeningEnable = ["strictflexarrays1"];

Document the upstream data layout and reproduce the actual build-time or runtime abort before adding this exception. Do not disable all hardening to work around one incompatible flag.

Pass $NIX_BUILD_CORES to build and install commands that support parallel execution. Some bootstrap installers rebuild tools, and some build systems forward a separate job count to nested builds; pass the configured budget to those entry points as well.

When a compiler or generator fails, preserve its inputs, command, and build log, then reproduce the failure before choosing a workaround. Fix missing dependency edges or shared-output races where a reproducer demonstrates them. Do not treat a successful retry at a lower job count as a diagnosis of compiler corruption or as evidence for a permanent package-specific limit. See the build concurrency notes for packages to watch under resource pressure and the available reproduction results.

A recipe version can carry its compatibility policy: "^7.4.2", "~7.4.2", and "=7.4.2" all publish exact version 7.4.2, with caret, tilde, or equal-version requirements. Bare strict SemVer defaults to caret; other bare version schemes remain exact-only. Use one optional operator and a full version, not a compound range. The default is an authoring convention, not evidence that upstream follows SemVer. Check the interface the package exposes: use ~ for a documented patch-series guarantee, and = while a broader policy remains unverified. Compatibility of an AOS-authored module also requires review of its options, handlers, and effects; an upstream library ABI promise does not establish that contract. Keep the rationale and any upstream policy link beside the recipe's version declaration.

Keep the source URL variable exact when adding an operator:

{ mkDerivation, fetchurl, fakeHash, ... }: let
  version = "7.4.2";
in mkDerivation {
  pname = "acme-agent";
  version = "~${version}";
  src = fetchurl {
    urls = ["https://downloads.example.com/acme-agent-${version}.tar.gz"];
    hash = fakeHash;
  };
  # Dependencies and phases are declared normally.
}

For an upstream release, add fetchurl and fakeHash to the package function arguments, keep version beside the source, and replace src = null with:

{fetchurl, fakeHash}: let
  version = "1.0.0";
in {
  src = fetchurl {
    urls = ["https://downloads.example.com/acme-agent-${version}.tar.gz"];
    hash = fakeHash;
  };
}

After adding the package file, obtain its hash with:

nix run . -- prefetch --package acme-health-agent

Replace fakeHash with the printed sha256-... value. Alternatively, --update edits the package file; inspect that diff before committing. Keep the version, source URLs, and hash together in the package file. Prefer more than one trusted source URL when the upstream has a stable mirror.

Extract only the named members a package consumes from a larger vendor bundle. Some archives contain a ./ member whose mode and timestamp tar applies to the existing extraction root; a sandbox executor can allow file creation there without allowing the builder to change mount-owned root metadata. Naming the required members avoids that unrelated metadata operation and keeps unpacking bounded to the package's declared payload.

For a Bazel package built with mkBazelPackage, leave populateBCR enabled when the dependency graph uses Bazel modules. Set populateBCR = false when the package explicitly disables Bzlmod and resolves only a WORKSPACE graph. The optional empty-workspace synchronization otherwise downloads Bazel's built-in repositories for platforms and toolchains the target does not use; those bytes enlarge the fixed-output dependency closure and require needless network policy exceptions. When a pinned Git repository has a commit-identical official mirror on an already-used origin, prefer that mirror and verify the exact commit before changing the recipe. Keep fork-only commits on their canonical origin rather than substituting an unaffiliated mirror. Apply the same rule to release archives: when one upstream owns multiple official hostnames, verify that the pinned payloads are byte-identical and select the hostname already used by the dependency closure instead of widening its network-origin set.

Leave Bazel action placement to the system Bazel configuration when its local default works in the build sandbox. Do not add a global --spawn_strategy=standalone package flag: command-line package flags override the system configuration and prevent an available remote executor from receiving actions. When nested Bazel sandboxing is unavailable and a package needs standalone execution as its portable local fallback, use --spawn_strategy=remote,standalone; Bazel then consumes a remote executor from the system configuration when present and otherwise falls back locally. If one action class cannot run remotely, scope its strategy by mnemonic instead of forcing the entire build local, and document why that exception is required.

Do not retain Bazel's host_platform, internal_platforms_do_not_use, or local_config_* repositories in a fixed-output dependency closure. They describe the fetch executor rather than a source dependency. Remove those repositories and their markers in postFetch; Bazel recreates them for the executor that performs the real build.

Do not let a fixed-output dependency fetch silently fall back between native and pure-language artifacts. When an upstream package treats a declared native extension as optional, use its supported strict-build switch so compilation failure stops the fetch. This makes the dependency hash describe one artifact mode instead of whichever mode happened to build on the fetch executor.

Use equal-length store-path placeholders for binary files. A replacement with a different byte length corrupts offsets recorded in formats such as ELF, but a fixed-output dependency cannot retain store references. Restore the original equal-length path before the offline build uses the artifact.

Preserve permissions while scrubbing fetched repositories. Go module caches make their files and directories read-only to enforce content immutability, so do not make an entire cache recursively writable just to run an in-place rewriter. Render the transformed bytes into scratch space, make only the matched file writable long enough to overwrite it, and restore its exact mode. This avoids requiring write permission on the cache directory and leaves unrelated module content untouched.

When upstream commits a Cargo lockfile and generated Bazel crate repositories, fetch that checked-in graph without setting CARGO_BAZEL_REPIN. Repinning asks rules_rust to resolve compatible versions against the current registry index, so the result can change even when the package source revision does not. Run a repin only while intentionally updating the source and commit the regenerated lock and repository definitions before calculating the new dependency hash. When a local rules_rust patch changes the repository-rule input digest without changing the Cargo.lock-selected graph, advance the generated lock's exact checksum in the source patch phase and assert the new value. Do not enable repinning in production fetches merely to bypass a stale rule digest.

#Declare the native package module

A recipe may publish a configuration module directory alongside its payload:

{ mkDerivation, service-interface, ... }:
mkDerivation {
  pname = "acme-health-agent";
  version = "1.0.0";
  module = ./acme-health-agent-module; # Contains module.nix.
  moduleDeps = [ service-interface ];
  # Sources, dependencies, and phases are declared normally.
}

The interface dependency is illustrative: select the package defining your actual domain contract. Module dependencies are separate from build and runtime library dependencies. A plain dependency inherits the package's generated package.versionRequirement, so consumers need not repeat its declared range. To override the range, use moduleDeps = [ { package = service-interface; packageVersion = "^7.0"; } ];. To pin immutable source identity, use moduleDeps = [ { package = service-interface; exact = true; } ];; an equal-version range can still select another source with that semantic version. An optional recipe osVersion = "^1.0"; checks the selected host OS release; dependency resolution never selects or upgrades the OS. A payload-only package does not need a module.

The module owns ordinary option declarations and configuration. It may expose ability operations, select handlers, or configure effects through a domain manager. Definitions in different files merge through the module fixed point. The runtime abilities guide shows the complete interface, service option, package configuration, handler composition, and execution path.

Use module and moduleDeps for native configuration. The builder rejects the superseded abilities and configModule recipe fields.

#Generate and inspect package documentation

Descriptions and types belong beside mkOption declarations. The builder projects options, operation schemas, and definition provenance from the same module evaluation. Do not maintain a separate documentation attrset, service catalog, or copy of the operation schema.

The derivation exposes deployment and documentation Nix values plus two companion artifacts: deploymentArtifact/deployment.json and documentationArtifact/options.json. The first retains payload and module identities; the second is a native generated reference. Building either does not activate the package.

After exporting options.json, inspect it without Nix or a running system:

aos docs runtime options.json
aos docs runtime options.json --format html --output package-reference.html

The same reader is available in Hub at /-/runtime-abilities. It links packages to the operations they declare, handle, and configure. An evaluated aos.package.transaction document instead shows the selected execution path. See runtime inspection.

#Build and inspect the payload

Add source files to Git before using flake outputs, then use the repository build entry point:

bash ./aos-dev build package acme-health-agent --no-out-link
bash ./aos-dev build check effects --no-out-link

Use package-specific checks for program behavior, linking, protocols, and runtime health. The generic effects check verifies the infrastructure with a small fixture; it does not qualify every package or prove that a service is running. Publication and installed-package consumers still need the native migration.

#Integrate the service into a release image

The following is the existing image consumer workflow. Its integration with the native runtime transaction path remains migration work; do not infer that registering a package already switches its activation to the new machinery.

This is a release-maintainer workflow. Users of a published AOS image should install the package from a registry with apm instead. See Build and customize release images for the image build and validation process.

Register the package in a system variant:

# systems/acme-server.nix
{pkgs, ...}: {
  imports = [./server.nix];

  aos.packages.acme-health-agent = {
    package = pkgs.acme-health-agent;
    bundle = true;
    preset = true;
  };
}

bundle = true includes the package payload, authenticated module, and signed contract artifacts in the image. preset = true selects it in the initial machine-wide desired package set. A preset package must also be bundled.

Build and validate the image as described in the release-image maintainer guide linked above.

After boot, inspect both the APM state and the service:

apm list --installed --system
systemctl status acme-health-agent.service
journalctl -u acme-health-agent.service -b

#Publish and install the package

Build the store path, then publish it from the registry's authoring clone:

STORE_PATH="$(nix build .#pkg-acme-health-agent \
  --no-link --print-out-paths)"

apr publish "$STORE_PATH" \
  --registry acme \
  --key-id release

apr publish evaluates the package's target inventory and publishes its generated documentation, named outputs, and package contract from that exact record. Keep the package's version, description, homepage, license, and maintainers in its Nix meta; the registry command does not accept manual copies of those fields for ordinary packages.

Create and upload a signed registry release using the workflow in Publish packages and releases. Once the consumer has synchronized that registry, install the service by name:

apm update --system
apm install --system acme-health-agent --registry acme --dry-run
apm install --system acme-health-agent --registry acme
systemctl status acme-health-agent.service

For repeatable provisioning, include the package in a complete desired file and apply it with apm apply --system --from desired.toml. See Manage packages for the file's removal semantics.

#Ship a new version

Change the package version and source hash, build it, and run its checks. Then publish the new store path and a new registry release. On a canary host, refresh metadata and inspect the candidate:

apm update --system
apm list --upgradable --system
apm policy acme-health-agent --system

Preview and upgrade the installed runtime package:

apm upgrade --system acme-health-agent --dry-run
apm upgrade --system acme-health-agent

Verify the unit and application behavior before advancing more rollout partitions. Registry channels are monotonic: stop a bad rollout and publish a higher, corrected version rather than moving channel partitions backward.