Infrastructure repositories tend to want an exact version of tools. It’s desirable that when I work on Terraform code on my laptop, I use the same version as will be used in production, and the same version that my colleagues use. While I like to use Nix Flakes for per-project development environments, nixpkgs is not much help here. It carries a single version of each of these tools — whichever one happened to be current when the package was last bumped — and it builds them from source. Both of those are the right defaults for a distribution and the wrong ones for pinning a team to terraform 1.9.8 specifically, verified against the checksum HashiCorp published for it.
So I’ve put together nix-hashicorp-tools: a Nix overlay containing every published release of the main HashiCorp tools, fetched from HashiCorp’s own release archives rather than built from source. At the time of writing that’s 2,120 releases across 20 products.
$ nix run github:jen20/nix-hashicorp-tools#hcnix -- list
PRODUCT RELEASES LATEST PRERELEASE SYSTEMS
terraform 446 1.15.8 1.16.0-beta1 6
vault 280 2.0.3 2.0.0-rc1 6
consul 284 2.0.2 2.0.0-rc2 6
nomad 242 2.0.4 2.0.0-rc.1 6
packer 139 1.16.0 1.12.0-alpha1 7
boundary 78 0.21.3 - 6
waypoint 40 0.11.4 - 6
terraform-ls 112 0.39.0 0.39.0-beta5 6
consul-template 88 0.42.1 - 6
envconsul 27 0.14.0 - 6
nomad-pack 11 0.4.2 0.0.1-techpreview2 5
nomad-autoscaler 30 0.5.0 0.4.0-rc.1 6
levant 6 0.4.0 0.3.0-beta1 6
serf 18 0.8.2 - 4
hcdiag 33 0.5.13 0.5.3-rc0 6
hcp 12 0.11.0 - 6
sentinel 91 0.41.0 0.40.1-beta2 6
tfc-agent 130 1.30.0 1.22.0-rc.1 2
vault-radar 43 0.51.0 - 4
vlt 10 1.0.0 1.1.0-alpha.1 6
This is based on the work done for rust-overlay and Mitchell Hashimoto’s own zig-overlay, and the shape is much the same: a repository of generated manifests, a scheduled job that keeps them current, and an overlay that turns them into derivations.
Getting a shell
The quickest way in is the template, which writes a two-file flake pinning whatever versions you name:
$ mkdir demo && cd demo && git init -q
$ nix flake init -t github:jen20/nix-hashicorp-tools
wrote: "/tmp/demo/.envrc"
wrote: "/tmp/demo/flake.nix"
The interesting part of the generated flake.nix is the package list:
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
packages = [
# Pin an exact release...
pkgs.hashicorp.terraform."1.9.8"
# ...or track the newest one.
pkgs.hashicorp.vault.latest
];
};
});
Which does what it says:
$ nix develop
$ terraform version
Terraform v1.9.8
on darwin_arm64
Your version of Terraform is out of date! The latest version
is 1.15.8. You can update by downloading from https://www.terraform.io/downloads.html
$ vault version
Vault v2.0.3 (7193f9a48ff6093ca61b3b627a8671e770428ba6), built 2026-06-17T12:39:45Z
Terraform’s own upgrade nag is right that 1.15.8 exists - and that is precisely why the version is written down in a file!
For a one-off there’s no need to write a flake at all:
$ nix run github:jen20/nix-hashicorp-tools#vault -- version
$ nix build 'github:jen20/nix-hashicorp-tools#legacyPackages.aarch64-darwin.terraform."1.9.8"'
Per-version attributes have to live under legacyPackages rather than packages, because packages may only contain derivations and this tree is three levels deep. packages gets the latest release of each product, which is what makes nix run …#vault work.
The attribute tree
pkgs.hashicorp.<product> is every release of that product which has a build for the system being evaluated, keyed by version string, plus a handful of convenience attributes: latest, latestPrerelease, versions and product.
$ nix eval 'github:jen20/nix-hashicorp-tools#legacyPackages.aarch64-darwin.terraform.versions' \
--apply 'v: { oldest = builtins.head v; count = builtins.length v; }'
{ count = 261; oldest = "1.0.2"; }
261, not 446, and starting at 1.0.2 — because that is the first Terraform release with a darwin/arm64 build. Releases that have nothing for the system you are evaluating simply do not appear, rather than appearing and then failing at fetch time.
The same rule applies one level up. A product is only present on systems where it has at least one release, so tfc-agent — which HashiCorp only builds for Linux — does not exist on macOS at all:
$ nix eval 'github:jen20/nix-hashicorp-tools#legacyPackages.aarch64-darwin' \
--apply 'builtins.attrNames'
[ "boundary" "consul" "consul-template" "envconsul" "hcdiag" "hcp" "levant" "nomad"
"nomad-autoscaler" "nomad-pack" "packer" "sentinel" "terraform" "terraform-ls"
"vault" "vault-radar" "vlt" "waypoint" ]
That evaluation takes about 0.4 seconds, which is the reason the manifests are split the way they are. A small index.json holds the per-product, per-system pointers needed to enumerate the tree; the 5,500-line manifest for Terraform is only read when something actually asks for a Terraform derivation.
latest is resolved per system
It is tempting to treat “latest” as a property of a product. It isn’t — it’s a property of a product on a system, because a release can drop a platform that an earlier one supported. Levant 0.4.0 dropped 32-bit ARM, so on armv7l-linux the newest usable Levant is still 0.3.3:
$ nix eval --impure --raw --expr '
let
pkgs = import <nixpkgs> {
system = "armv7l-linux";
overlays = [ (builtins.getFlake "github:jen20/nix-hashicorp-tools").overlays.default ];
};
in pkgs.hashicorp.levant.latest.version'
0.3.3
Change system to aarch64-darwin and the same expression yields 0.4.0. The pointers are computed per system by the updater and stored that way, so nothing has to be recomputed — or guessed at — during evaluation.
(The overlay knows seven systems: x86_64-linux, aarch64-linux, armv7l-linux, i686-linux, powerpc64le-linux, x86_64-darwin and aarch64-darwin. The flake’s own legacyPackageu covers the usual four from nix-systems/default, which is why the example above goes through the overlay rather than through a flake output.)
Licences
Terraform, Vault, Consul, Nomad, Packer and Boundary moved from MPL-2.0 to BUSL-1.1 during 2023, and nixpkgs classifies BUSL as unfree. The obvious implementation is a version cut-off per product: everything before x.y.z is MPL, everything after is BUSL.
That implementation is wrong, because the relicensing was applied to maintenance branches at different times and the licence is therefore not monotonic in version order:
$ nix eval 'github:jen20/nix-hashicorp-tools#legacyPackages.aarch64-darwin' --apply \
't: builtins.mapAttrs (_: v: v.meta.license.spdxId) {
"1.15.9" = t.consul."1.15.9";
"1.16.0" = t.consul."1.16.0";
"1.16.4" = t.consul."1.16.4";
"1.17.0" = t.consul."1.17.0";
}'
{ "1.15.9" = "BUSL-1.1"; "1.16.0" = "MPL-2.0"; "1.16.4" = "MPL-2.0"; "1.17.0" = "BUSL-1.1"; }
Consul 1.15.9 is BUSL; the later 1.16.0 through 1.16.4 are MPL; 1.17.0 is BUSL again. The releases API is no help — it reports oss for the BUSL builds too — so the updater reads the LICENSE file at each release’s own tag and records the resulting nixpkgs licence attribute in the manifest. Every derivation then carries an accurate meta.license, which is the thing anything downstream will reason about.
Which of these needs allowUnfree depends on how you consume the overlay. Through overlays.default the packages are built against your nixpkgs, so your configuration governs and BUSL releases need config.allowUnfree = true — the same gate nixpkgs’ own terraform sits behind. Through the flake’s own outputs — nix build .#terraform, nix run .#vault — unfree is already allowed, on the grounds that asking a flake whose entire purpose is HashiCorp’s official binaries for one of those binaries is itself the opt-in. meta.license remains accurate either way.
The repository contains no HashiCorp code, only URLs and checksums. Your use of the binaries is governed by HashiCorp’s terms, not by mine.
Binaries
Each derivation is an stdenvNoCC.mkDerivation around a fetchurl of the official zip, with sha256 taken directly from HashiCorp’s published SHA256SUMS — which is convenient, since those are hashes of the archive itself, exactly what fetchurl wants.
The install phase copies every regular file in the archive root that isn’t documentation, by exclusion rather than by name, so multi-binary archives such as tfc-agent (which ships tfc-agent and tfc-agent-core) need no special casing. On Linux autoPatchelfHook runs; the binaries are static Go builds so it rarely has anything to do, but the dynamic ones exist. Stripping is disabled, and on macOS you can see why:
$ codesign -dv --verbose=2 $(nix build --no-link --print-out-paths \
'github:jen20/nix-hashicorp-tools#legacyPackages.aarch64-darwin.terraform."1.9.8"')/bin/terraform
Executable=/nix/store/22z9hanw1wchbrs8blds3ss5dlz90ya5-terraform-1.9.8/bin/terraform
Identifier=terraform
Format=Mach-O thin (arm64)
Authority=Developer ID Application: Hashicorp, Inc. (D38WU7D763)
Authority=Developer ID Certification Authority
Authority=Apple Root CA
Timestamp=Oct 16, 2024 at 7:48:16 AM
TeamIdentifier=D38WU7D763
Keeping it current
The manifests are generated by a small Go program, hcnix, built on go-hashicorp-releases-client. It has three commands: update refreshes the manifests from the releases API, check validates them offline, and list prints the table at the top of this post.
$ nix develop
$ go run ./cmd/hcnix update -dry-run
$ go run ./cmd/hcnix update -product vault
$ go run ./cmd/hcnix check
checked 2120 releases across 20 products
Checksum files are verified against HashiCorp’s published OpenPGP key, which is committed to the repository. The default -signatures lenient policy reports how many releases could not be attributed to a trusted key and carries on — every release predating HashiCorp’s April 2021 signing key rotation falls into that bucket, because the key that signed them was revoked and is no longer published. A signature that is present and bad is always fatal. -signatures strict refuses anything unverifiable, and -signatures off skips the check.
A GitHub Actions workflow runs update daily, shortly after HashiCorp’s usual release window, and only proceeds if data/ actually changed. It then runs hcnix check, nix flake check, and a build of the newest release of every product before committing.
Scope
Terraform providers, Packer plugins, Vault plugins and Kubernetes sidecars are out of scope; they have their own distribution mechanisms and their own resolvers. Vagrant is excluded because it isn’t distributed as a plain archive of Go binaries. Enterprise (+ent) builds are excluded. Prereleases are addressable by exact version — terraform."1.16.0-beta1" — but never resolve as latest.
The repository is at github.com/jen20/nix-hashicorp-tools, under MPL-2.0. Issues and pull requests welcome, particularly from anyone running this on a platform I don’t have to hand.