Getting Started

This introductory guide will help you run your first build with zb and explain the basic concepts you will need to use zb. zb is a build system that manages your dependencies so that you can be confident that a build that works on one machine works on any machine with the same operating system and CPU architecture.

Prerequisites

This guide assumes:

  • Familiarity with the command-line interface for your operating system. (For example, Terminal.app on macOS, Command Prompt or PowerShell on Windows, etc.)

  • Knowledge of at least one programming language. We will be building a C program in this tutorial, but you do not need to know C or have any specific version of developer tools installed. Installing tools automatically is a key feature of zb!

  • zb uses the Lua programming language to configure builds. Learning Lua is helpful for using zb. zb uses the Lua 5.4 language, with some standard libraries omitted to limit the complexity of builds. As such, any learning resources for Lua will be applicable to zb.

The standard library currently supports:

  • x86_64-unknown-linux

  • aarch64-apple-macos

Installation

If you haven’t already, follow the installation instructions.

First Steps

Let’s start with a C “Hello, World” program. Open your editor of choice and enter the following into a new file hello.c:

/* hello.c */
#include <stdio.h>

int main() {
  printf("Hello, World!\n");
  return 0;
}

Now let’s learn how to build hello.c into an executable with zb.

We will write a small Lua script that describes the build, and then use the zb command-line interface (CLI) to run the script.

Out of the box, zb only knows how to run programs and download source. However, zb has a standard library that can be fetched to provide tools for some common programming languages. In your editor, enter the following into a new file zb.lua in the same directory as hello.c (we’ll walk through this code in a moment):

-- zb.lua

-- Download the standard library.
local zb <const> = fetchArchive {
  url = "https://github.com/256lights/zb-stdlib/releases/download/v0.1.1/zb-stdlib-v0.1.1.tar.gz";
  hash = "sha256:ee4c78f4b1915c7dafb0d55e8cd6f20fe82396a21a6ab2add9bb879fb9301bc2";
}

-- Import modules from the standard library.
local stdenv = import(zb.."/stdenv/stdenv.lua")

-- Copy the source to the store.
local src = path {
  path = ".";
  name = "hello-source";
  filter = function(name)
    return name == "hello.c"
  end;
}

-- Create our build target.
-- Replace with your system, if necessary.
-- One of:
-- x86_64-unknown-linux
-- aarch64-apple-macos
local buildSystem <const> = "x86_64-unknown-linux"

hello = stdenv.makeDerivation {
  pname = "hello";
  src = src;

  buildSystem = buildSystem;

  buildPhase = "gcc -o hello hello.c";
  installPhase = '\z
    mkdir -p "$out/bin"\n\z
    mv hello "$out/bin/hello"\n';
};

Now we can build the program with zb build. Note that the first time you run zb build, will take a while, since it is building the standard library tools from source. (Because zb build artifacts can be safely shared among machines, there are plans to speed this up. #43 tracks this work.)

zb build 'zb.lua#hello'

zb build takes in a URL of Lua file to run. The fragment (i.e. everything after the #) names a variable to build. In this case, we’re building hello. zb build will automatically look for a global called hello defined inside zb.lua.

At the end, zb build will print the path to the directory it created, something like /opt/zb/store/2lvf1cavwkainjz32xzja04hfl5cimx6-hello. As you might expect from the installPhase we used above, it will be inside the bin directory we created inside the output directory.

% /opt/zb/store/2lvf1cavwkainjz32xzja04hfl5cimx6-hello/bin/hello
Hello, World!

In the next few sections, we’ll explain the zb.lua script in more detail.

Derivation Basics

The first section downloads the standard library from GitHub:

local zb <const> = fetchArchive {
  url = "https://github.com/256lights/zb-stdlib/releases/download/v0.1.1/zb-stdlib-v0.1.1.tar.gz";
  hash = "sha256:ee4c78f4b1915c7dafb0d55e8cd6f20fe82396a21a6ab2add9bb879fb9301bc2";
}

fetchArchive() is a built-in global function that returns a derivation that extracts the tarball or zip file downloaded from a URL.

A derivation in zb is a build step: a description of a program — called a builder — to run to produce files. Derivations can depend on the results of other derivations. Creating a derivation does not run its builder; creating a derivations records how to invoke its builder. We use zb build to run builders, or as we’ll see in a moment, the import() function will implicitly run the builder.

Finally, derivations can be used like strings. For example, derivations can be concatenated or passed as an argument to tostring. Such a string is a placeholder for the derivation’s output file or directory, and when used for other derivations, it implicitly adds a dependency on the derivation.

Modules and Imports

The next section loads a Lua module from the zb standard library:

local stdenv = import(zb.."/stdenv/stdenv.lua")

Every Lua file that zb encounters is treated as a separate module. The import() built-in global function returns the module at the path given as an argument. This is similar to the dofile and require functions in standalone Lua (which are not supported in zb), but import is special in a few ways:

  • import will load the module for any given path at most once during a run of zb.

  • import does not execute the module right away. Instead, import returns a placeholder object that acts like the module. When you do anything with the placeholder object other than pass it around, it will then wait for the module to finish initialization.

  • Globals are not shared among modules. Setting a “global” variable in a zb module will place it in a table which is implicitly returned by import if the module does not return any values.

  • Everything in a module will be “frozen” when the end of the file is reached. This means that any changes to variables or tables (even locals) will raise an error.

Together, these aspects allow imports to be reordered or run lazily without fear of unintended side effects.

One other interesting property of the import() function is that if you use a path created from a derivation, it will build the derivation. So zb.."/stdenv/stdenv.lua" will build the zb derivation and then import the stdenv/stdenv.lua file inside the output.

Making the Source Available to the Build

The path() built-in function imports files for use in a derivation:

local src = path {
  path = ".";
  name = "hello-source";
  filter = function(name)
    return name == "hello.c"
  end;
}

The filter function allows us to create an allow-list of files in the folder to use. Changing any file inside a source causes the derivation to be rebuilt on the next zb build, so minimizing the number of files is important for faster incremental builds.

Creating a Derivation

Finally, we declare a hello variable with a derivation value:

-- Replace with your system, if necessary.
-- One of:
-- x86_64-unknown-linux
-- aarch64-apple-macos
local buildSystem <const> = "x86_64-unknown-linux"

hello = stdenv.makeDerivation {
  pname = "hello";
  src = src;

  buildSystem = buildSystem;

  buildPhase = "gcc -o hello hello.c";
  installPhase = '\z
    mkdir -p "$out/bin"\n\z
    mv hello "$out/bin/hello"\n';
};

stdenv.makeDerivation is a function that returns a derivation. It provides GCC and a minimal set of standard Unix tools. If the source contains a Makefile, then it uses that to build. However, for our simple single-file program, we provide a buildPhase directly. buildPhase specifies a snippet of Bash script that builds the program in the source directory. The installPhase specifies a snippet of Bash script to copy the program to $out, the path to where the derivation’s output must be placed.

Using Dependencies

Now that we know the basics, let’s see how to pull in a C library from the internet. Add the following to the end of zb.lua:

sqlite3 = stdenv.makeDerivation {
  pname = "sqlite3";
  version = "3.50.1";
  src = fetchurl {
    url = "https://www.sqlite.org/2025/sqlite-autoconf-3500100.tar.gz";
    hash = "sha256:00a65114d697cfaa8fe0630281d76fd1b77afcd95cd5e40ec6a02cbbadbfea71";
  };

  buildSystem = "aarch64-apple-macos";
}

Like before, we can build it with zb build zb.lua#sqlite3. Because the source archive includes a configure script and a Makefile, then stdenv.makeDerivation knows how to build the package. You can see that the resulting directory includes bin/sqlite3, a lib directory, and an include directory.

Now let’s see how to compile the SQLite Quickstart example. Create another file, hello_sql.c, in the same directory as zb.lua:

/* hello_sql.c */

#include <stdio.h>
#include <sqlite3.h>

static int callback(void *NotUsed, int argc, char **argv, char **azColName){
  int i;
  for(i=0; i<argc; i++){
    printf("%s = %s\n", azColName[i], argv[i] ? argv[i] : "NULL");
  }
  printf("\n");
  return 0;
}

int main(int argc, char **argv){
  sqlite3 *db;
  char *zErrMsg = 0;
  int rc;

  if( argc!=3 ){
    fprintf(stderr, "Usage: %s DATABASE SQL-STATEMENT\n", argv[0]);
    return(1);
  }
  rc = sqlite3_open(argv[1], &db);
  if( rc ){
    fprintf(stderr, "Can't open database: %s\n", sqlite3_errmsg(db));
    sqlite3_close(db);
    return(1);
  }
  rc = sqlite3_exec(db, argv[2], callback, 0, &zErrMsg);
  if( rc!=SQLITE_OK ){
    fprintf(stderr, "SQL error: %s\n", zErrMsg);
    sqlite3_free(zErrMsg);
  }
  sqlite3_close(db);
  return 0;
}

Then add to the end of zb.lua:

local strings = import(zb.."/strings.lua")

hello_sql = stdenv.makeDerivation {
  pname = "hello-sql";
  src = path {
    path = ".";
    name = "hello-sql-source";
    filter = function(name)
      return name == "hello_sql.c"
    end;
  };

  buildSystem = "aarch64-apple-macos";

  C_INCLUDE_PATH = strings.makeIncludePath {
    sqlite3
  };
  LIBRARY_PATH = strings.makeLibraryPath {
    sqlite3
  };

  buildPhase = "gcc -o hello-sql -lsqlite3 hello_sql.c";
  installPhase = '\z
    mkdir -p "$out/bin"\n\z
    mv hello-sql "$out/bin/hello-sql"\n';
}

This is mostly the same as our hello example from before, but we do a few new things:

  • We import the strings.lua file from the standard library. The strings.makeIncludePath and strings.makeLibraryPath functions join the elements in the table with colons and appends /include or /lib, respectively, to each element.

  • We set the C_INCLUDE_PATH and LIBRARY_PATH environment variables to point to the sqlite3 derivation. As you’ll recall, derivations can be used like strings and automatically introduce a dependency from hello_sql to sqlite3.

Wrapping Up

In this guide, we wrote a simple build configuration for a single-file C program. Then, we built SQLite from source, and finally, we built a C program that used SQLite as a dependency.

Throughout this tutorial, there have been links to reference documentation. The language reference describes the flavor of Lua that zb understands, as well as its built-in functions. The standard library repository includes other packages and utility functions that can be useful.

Here are the final versions of the files:

zb is still in early development. If you have questions or feedback, see the support guide. If you’re interested in getting involved, see the contributing guide.