As a common theme of this page, you may be able to tell I'm learning Nix. One of the
main reasons I've gotten into it is the power of the flake.nix. One of the things
I love the most is the build and development reproducibility. Here I'm going to cover
some of my favorite things (devShells, packages, containers, etc.) while building a
starter project in Golang.
Setting up your project
Let's assume we have a pretty blank project to start. Something you may get if you just created a new git project.
1234
➜ nix-starters git:(main) tree
.
├── LICENSE
├── README.md
With a new project like this, before we can get started with anything, we should set
up our flake.nix which will enable us to utilize the full power of Nix.
Set up your flake.nix
If you have an empty project, you will need to create a flake.nix file and fill in
some basic content. We'll start off with a file that looks like this and then expand
on it. There are comments there for you. Also you should not be running anything yet
- just load in our file and move onto the next steps to commit it.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
{
description = "🟪 Go 1.24 Project with Nix";
# Define the sources we'll use in our flake
inputs = {
# Unstable channel for latest package versions
# This provides access to the newest available Go and tools
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
# Stable channel for production-ready packages
# Useful for dependencies that require more stability
nixpkgs-stable.url = "github:NixOS/nixpkgs/release-24.11";
# Provides utility functions for working with flakes
# Simplifies handling multiple systems (x86_64-linux, aarch64-darwin, etc.)
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs-unstable, nixpkgs-stable, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
# Import both package sets for the current system
pkgs-unstable = import nixpkgs-unstable { inherit system; };
pkgs-stable = import nixpkgs-stable { inherit system; };
in {
# Default development shell with necessary tools
devShells.default = pkgs-unstable.mkShell {
buildInputs = with pkgs-unstable; [
# Core Go development tools
go_1_24 # Go compiler and runtime (version 1.24)
gopls # Official Go language server protocol implementation
gotools # Essential Go development utilities
golangci-lint # Meta-linter combining 50+ linters in one tool
delve # Powerful debugger for Go applications
git # Distributed version control system
];
# Executed when entering the development shell
shellHook = ''
# Set up project-local configuration
export GOPATH="$PWD/.go" # Project-local GOPATH
export PATH="$GOPATH/bin:$PATH" # Make go install binaries available
export GO111MODULE=on # Ensure modules mode is enabled
# Create directories if needed
mkdir -p .go/bin
echo "🟪 Go 1.24 development environment activated!"
'';
};
# Define a formatter for the flake itself
# This helps maintain consistent formatting with 'nix fmt'
# Run with: nix fmt
formatter = pkgs-unstable.nixpkgs-fmt;
}
);
}
There is a component in there devShells.default that we touch on below. Don't worry
about that quite yet. Just know you'll need it and we do a deep dive in a moment.
Commit the flake.nix file
This is a required step, please don't miss it. Without this, you will face errors when
we run nix develop
Nix flakes require the files to be tracked by git before they can be used:
12
git add flake.nix
git commit -m "feat: init nix flake"
While not the focus of this blog, it's my understanding that they require the flake.nix
to be tracked by Git for a few reasons:
-
Reproducibility: The primary goal of flakes is to provide reproducible environments. By requiring git tracking, Nix ensures that the exact state of the flake files is recorded and can be reproduced later.
-
Content addressing: Flakes use Git's content-addressing system to identify and reference dependencies. This means they can use the git commit hashes to create stable references, which is how you can use git to host and share your flakes.
-
Lock files: When you use a flake, Nix creates a
flake.lockfile that records the exact version of all dependencies. This lock file works in tandem with git to ensure the exact same dependencies are used each time. -
Prevent accidental changes: By requiring committed files, Nix prevents you from accidentally using uncommitted changes in your environment.
-
Remote fetching: The git requirement also enables Nix to easily fetch flakes from remote repositories since they're already in a Git-compatible format.
Ensure you have flakes enabled
For the purposes of this blog, we are assuming you know what flakes are and have them enabled. If you haven't enabled flakes in your Nix configuration and are still trying to follow along, you can temporarily enable them using:
1
nix --experimental-features 'nix-command flakes' develop
Or add this to your ~/.config/nix/nix.conf or /etc/nix/nix.conf to enable permanently:
1
experimental-features = nix-command flakes
Start your development shell
Now as we talked about in your flake.nix, there is a devShells.default that was
provided for you. This is going to be the money maker for you in terms of creating
a standard reproducible environment.
What is a development shell?
I guess before we start up our first shell, we should cover what it is in case you
don't have an understanding. Development shells (devShells) in Nix flakes are
isolated, project-specific environments that contain exactly the tools, libraries,
and configurations you need for a particular project. You can think of them as
purpose-built workspaces that instantly give you access to everything required for
development without polluting your global system. Yes, that's right - super powerful.
So unlike traditional virtual environments or containers, development shells:
- Provide exact, reproducible environments down to the binary level
- Can be shared across team members with perfect consistency
- Don't require heavyweight virtualization
- Can be entered and exited without restarting terminals
- Work identically across Linux, macOS, and other supported platforms
That's all I'll touch on here, but they are super powerful and fun to play around with.
Start your devShell
Now that you have an understanding of what it is, you can start up your dev shell.
As you saw in the comments of the flake, when you start up the dev shell, you'll get
an environment with everything we have defined such as go 1.24.
123
➜ nix-flake-golang git:(main) ✗ nix develop
🟪 Go 1.24 development environment activated!
davecave:nix-flake-golang daveved$
Inside the newly activated shell, you can verify we have the packages installed:
12
davecave:nix-flake-golang daveved$ go version
go version go1.24.1 darwin/arm64
Initialize your Go project
Now that we have a development shell setup with Nix enabled, we can bootstrap our Go
project. I'll just use go mod init. I'll assume you know how to do this. After this
you should end up with a file tree like this:
123456789101112
davecave:nix-flake-golang daveved$ go mod init github.com/0xdsqr/nix-starters/nix-flake-golang
go: creating new go.mod: module github.com/0xdsqr/nix-starters/nix-flake-golang
davecave:nix-flake-golang daveved$ ls
flake.lock flake.nix go.mod
davecave:nix-flake-golang daveved$ tree
.
├── flake.lock
├── flake.nix
└── go.mod
1 directory, 3 files
davecave:nix-flake-golang daveved$
You would also see a README.md and LICENSE etc. whatever was in your initial project.
Create your entry point
Now that we have our Go project bootstrapped, we can create a simple entry point for our Golang application. We'll just create a main function that prints hello world for now.
1234567891011
davecave:nix-flake-golang daveved$ mkdir -p cmd/dsqr
davecave:nix-flake-golang daveved$ cat > cmd/dsqr/main.go << 'EOF'
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
EOF
davecave:nix-flake-golang daveved$
You can use nvim or whatever you want here to create the files, code, etc. For smaller bits like this I just provide helpers if you wish.
Building your first packages
Nix offers a streamlined way to build packages. It's one of the major selling points, and once you get used to it there is no real turning back. Our goal here is not to go into all the details but instead give you some working examples that you can follow along with to learn. We'll cover some of the basics here as we're getting started with a Golang application.
Packaging with derivation
In Nix, a derivation is the fundamental building block for creating packages. Think of it as a recipe that describes:
- The inputs needed to build the package
- The build steps required
- Where the output should go
Nix offers many specialized functions for different languages. For Go, we have several options:
stdenv.mkDerivation: The general-purpose derivation builderbuildGoModule: Specialized for Go modules with dependency managementbuildGo118Module: Version-specific Go module builderbuildGoPackage: Older style builder for Go packages
For our simple example, we'll use stdenv.mkDerivation as it gives us fine-grained
control over the build process. This approach is especially useful when you're learning
how derivations work.
When you build a package with Nix, the result (output) is stored in the Nix store under
/nix/store/. Each package gets a unique hash based on all its inputs, ensuring
reproducibility. This is a core principle of Nix - the same inputs will always produce
the same outputs.
Updating your flake.nix
Let's update our flake.nix by adding two key elements:
- First, define the app name and build configuration:
123456789101112131415161718192021222324252627282930313233343536373839
# Add inside the 'let' section of your flake
# Define the application name - a string value used across the flake
appName = "dsqr";
# Define application as a variable using mkDerivation
goApp = pkgs-unstable.stdenv.mkDerivation {
name = appName; # The name of our package
src = ./.; # Use current directory as source - all files
# Build dependencies (not runtime) - these won't be in the final output
nativeBuildInputs = with pkgs-unstable; [
go_1_24 # Specific Go version to use for building
];
# The actual build commands executed during 'nix build'
buildPhase = ''
# Set up temporary Go cache and module paths
export GOCACHE=$TMPDIR/go-cache
export GOPATH=$TMPDIR/go
# Change to the application's source directory
cd cmd/${appName}
# Build the Go application
go build -o ${appName}
'';
# How to install the built artifacts into the Nix store
installPhase = ''
# Create the bin directory in the output path
mkdir -p $out/bin
# Copy the built binary to the output bin directory
cp ${appName} $out/bin/
# Ensure the executable bit is set
chmod +x $out/bin/${appName}
'';
};
- Second, add the package reference in the outputs section:
12
# Add inside the output section, where formatter is defined
packages.default = goApp;
Building your package
Now you can build your package with:
1
nix build
This will create a result symlink in your current directory that points to the built
package in the Nix store. You can run your application with:
1
./result/bin/dsqr
If you want to just run it without building it first, you can use:
1
nix run
Packaging with containers
One powerful feature of Nix is the ability to build container images without Docker. This approach provides several benefits:
- Reproducible builds: Same deterministic builds you expect from Nix
- Smaller images: More efficient layering and inclusion of only what's needed
- No Docker daemon needed: Build containers without Docker installed
- Multi-architecture support: Build for different architectures from the same machine
Nix provides several functions for building images, including:
dockerTools.buildImage: Simple single-layer imagedockerTools.buildLayeredImage: More efficient multi-layer imagedockerTools.streamLayeredImage: Stream output directly to a registry
Updating your flake.nix
Let's add a container package to our flake:
123456789101112131415161718192021222324
# Add inside the outputs section, after packages.default
packages.container = pkgs-unstable.dockerTools.buildLayeredImage {
# Basic container metadata
name = appName; # Container name (required)
tag = "latest"; # Container tag (optional, defaults to "latest")
# Contents to include in the image (as layers)
contents = [
# Include our built Go application
self.packages.${system}.default
# Add busybox for basic shell utilities
pkgs-unstable.busybox
];
# Container configuration (equivalent to Dockerfile settings)
config = {
# Command to run when container starts (like ENTRYPOINT + CMD)
Cmd = [ "${appName}" ];
# Declare ports that should be exposed (like EXPOSE in Dockerfile)
ExposedPorts = {
"8080/tcp" = { };
};
};
};
Building your container
You can build the container image with:
1
nix build .#container
This generates a container image in Docker's save format in the result file, which
you can load into Docker:
1
docker load < result
A sample output looks like this:
1234567891011121314151617181920
[dsqr@server:~/nix-starters/nix-flake-golang]$ nix build .#container
[dsqr@server:~/nix-starters/nix-flake-golang]$ docker load < result
bac1ce201147: Loading layer 2.079MB/2.079MB
73c3619fb187: Loading layer 399.4kB/399.4kB
d49827920aca: Loading layer 215kB/215kB
c8eacd12fc04: Loading layer 30.78MB/30.78MB
9d4dc07856d6: Loading layer 1.69MB/1.69MB
38e0af94c91a: Loading layer 583.7kB/583.7kB
3b0071b79b5f: Loading layer 133.1kB/133.1kB
67fe4cdb969c: Loading layer 2.929MB/2.929MB
80ed722b7b56: Loading layer 266.3MB/266.3MB
e14cdee2dc9a: Loading layer 1.423MB/1.423MB
70924302b70c: Loading layer 1.567MB/1.567MB
77846598d2f3: Loading layer 215kB/215kB
Loaded image: dsqr:latest
[dsqr@server:~/nix-starters/nix-flake-golang]$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
dsqr latest 5f36d088d013 55 years ago 291MB
Setting up executable commands
If you wish to use nix run to execute commands without running some form of nix build
first, you can use the apps feature. This allows you to define executable commands
that can be run with nix run .#myapp or simply nix run for the default app:
12345
# Add inside the output section, after packages.default
apps.default = {
type = "app"; # Specifies this is an app definition
program = "${goApp}/bin/${appName}"; # Full path to the executable
};
You can then run it with nix run and it will handle everything for you:
12
davecave:nix-flake-golang daveved$ nix run
Hello, dsqr!
This command builds the package if needed and then executes it directly.

