Attic on Nix Darwin

Published:
September 7, 2024

Here are some snippets of code required to configure and run attic on macOS with nix-darwin, and use it as an optional cache.

Files required

To make nix-darwin configure attic correctly, you need a configuration module, which is called attic.nix here, and a configuration atticd.toml for attic to use during runtime to use the correct paths for storage.

User creation

First, we create a user and group called attic to host the attic server on your machine. In the attic.nix file, add the following:

# attic.nix
{ config, pkgs, ... }:
{
  users.groups.attic = {
    # Adjust the gid to your liking
    gid = 603;
  };
  users.users.attic = {
    createHome = false;
    description = "attic user";
    gid = 603;
    # Adjust the uid to your liking
    uid = 603;
    isHidden = true;
  };
  users.knownGroups = [ "attic" ];
  users.knownUsers = [ "attic" ];
}

Using the above, the attic user will be hidden, and can’t log in (shell set to sbin/nologin).

Attic configuration

We create a configuration file atticd.toml, and put the following contents in there:

# atticd.toml
# Socket address to listen on, you might want to adjust the port used.
listen = "127.0.0.1:18080"

# Optionally, configure allowed hosts here
allowed-hosts = []

[database]
# Attic's database is located in /var/attic/db.sqlite
url = "sqlite:///var/attic/db.sqlite?mode=rwc"

# Whether to enable sending on periodic heartbeat queries
#
# If enabled, a heartbeat query will be sent every minute
#heartbeat = false

[storage]
# Store everything locally in /var/attic/storage
type = "local"
path = "/var/attic/storage"

# Default values from
# https://github.com/zhaofengli/attic/blob/main/server/src/config-template.toml
[chunking]
nar-size-threshold = 65536 # chunk files that are 64 KiB or larger
min-size = 16384            # 16 KiB
avg-size = 65536            # 64 KiB
max-size = 262144           # 256 KiB

[compression]
type = "zstd"
#level = 8

[garbage-collection]
# The frequency to run garbage collection at
interval = "12 hours"

The configuration is written to /etc/attic/atticd.toml using the following Nix snippet:

# attic.nix
{ config, pkgs, ... }:
{
  # ...
  environment.etc = {
    atticd = {
      source = ./atticd.toml;
      target = "attic/atticd.toml";
    };
  };
  # ...
}

Credentials file

Next, feed 32 bytes of random data into base64, and store them in a secret, read-only file only accessible by attic:

openssl rand 32 |
  base64 |
  sudo tee /etc/attic/secret.base64 > /dev/null
sudo chown attic:attic /etc/attic/secret.base64
sudo chmod 400 /etc/attic/secret.base64

Attic service file

Next, we need to tell nix-darwin to install a launchd service using the following snippet in the same attic.nix file:

# attic.nix
{ config, pkgs, ... }:
let
  logPath = "/var/log/atticd";
  attic-client = pkgs.attic-client;
  attic-server = pkgs.attic-server;
in
{
  environment.systemPackages = [
    attic-client
    attic-server
  ];
  launchd.daemons.attic = {
    script = ''
      ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64="$(cat /etc/attic/secret.base64)"
      export ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64
      exec ${attic-server}/bin/atticd --config /etc/attic/atticd.toml
    '';
    serviceConfig = {
      KeepAlive = true;
      StandardOutPath = "${logPath}/attic.stdout.log";
      StandardErrorPath = "${logPath}/attic.stderr.log";
      UserName = "attic";
    };
  };
}

The above launchd daemon script reads the attic secret token into an environment variable and starts the attic server using the configuration stored in atticd.toml.

Ensure that an attic runtime directory is created:

sudo mkdir -m700 /var/attic
sudo chown attic:attic /var/attic

Configuring the client

Import attic.nix into your main nix-darwin configuration:

# darwin-configuration.nix
{ config, pkgs, ... }:
{
  imports = [
    # ...
    ./attic.nix
    # ...
  ];
}

Then, rebuild your nix-darwin system using darwin-rebuild switch. On my system, I use the following invocation:

darwin-rebuild switch --flake $DOTFILES/nix/generic

Create a JWT for a cache named after your computer’s name to be used by your local Nix builder:

sudo -u attic \
  ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64="$(sudo -u attic cat /etc/attic/secret.base64)" \
  atticadm make-token \
  --config /etc/attic/atticd.toml \
  --sub "$(hostname)" \
  --validity "1 month" \
  --pull "$(hostname)-*" \
  --push "$(hostname)-*" \
  --delete "$(hostname)-*" \
  --create-cache "$(hostname)-*" \
  --configure-cache "$(hostname)-*" \
  --configure-cache-retention "$(hostname)-*" \
  --destroy-cache "$(hostname)-*"

The above JWT has very broad permissions, please adjust to your liking. A token will be output in your shell session.

Finally, try logging in with the generated token:

# Port configured in atticd.toml
attic login "$(hostname)" http://127.0.0.1:18080 "$YOUR_TOKEN"

Did it work? Great! In order for the Nix builder to be able to use attic as its cache, it needs to have credentials available in a netrc file, and have the cache’s public key listed as a trusted key.

First, we retrieve the public key and netrc file. The above attic login invocation created a config.toml file in $HOME/.config/attic, which conveniently contains the JWT token for a netrc file.

# This will try to grab the netrc information that attic created after logging
# in
sed -n -E -e 's/token = "(.+)"/machine .+\npassword \1/p' \
  $HOME/.config/attic/config.toml |
  sudo tee /etc/nix/netrc
sudo chmod 440 /etc/nix/netrc
# Let me know if the above sed expression worked

The public key can be listed using attic cache info:

attic cache info "$(hostname)-default"

This will output something like the following:

               Public: false
           Public Key: XXXXXXX-default:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Binary Cache Endpoint: https://XXXXXXXXXXXXX:18080/XXXXXXX-default
         API Endpoint: https://XXXXXXXXXXXXX:18080/
      Store Directory: /nix/store
             Priority: 41
  Upstream Cache Keys: ["cache.nixos.org-1"]
     Retention Period: Global Default

We can then combine the above information and put it into attic.nix:

# attic.nix
{ ... } :
let
  # make sure to insert the correct hostname here:
  hostname = "your-hostname";
  # Insert the public key from above
  public-key = "";
  # Make sure the hostname, port, and cache name are correct
  cache-url = "http://127.0.0.1:18080/${hostname}-default";
in
{
  nix.settings.substituters = [ cache-url ];
  nix.settings.trusted-public-keys = [

    "${hostname}-default:${public-key}"
  ];
  nix.settings.trusted-substituters = [ cache-url ];
  # This file was created using the sed script above
  nix.settings.netrc-file = "/etc/nix/netrc";
}

Testing the cache

Now, rebuild nix-darwin one more time. Every time you run nix commands after that, the local attic cache will be consulted first. Try it with any command:

# This will look up hello in your local cache first
nix run nixpkgs#hello

Troubleshooting

Does Nix complain that your local cache is not a binary cache? Check that you can access the attic cache using curl first:

# Might have copy the netrc file somewhere user-readable
curl --netrc-file /etc/nix/netrc -v -n \
  "http://localhost:18080/$(hostname)-default/nix-cache-info"

Normally, the result will look like this:

[...]
< content-type: text/x-nix-cache-info
< date: Sat, 07 Sep 2024 08:15:50 GMT
< content-length: 51
<
WantMassQuery: 1
StoreDir: /nix/store
Priority: 41
[...]

This way, you can see if the credentials are correct or not, and if attic is accessible at all.

Furthermore, monitor the attic logs under /var/log/atticd for any error messages. A normal log output will look like this:

==> /var/log/atticd/attic.stderr.log <==
[...]
Attic Server 0.1.0 (release)
Running migrations...
Starting API server...
Listening on 127.0.0.1:18080...

==> /var/log/atticd/attic.stdout.log <==

Further reading

I would be thrilled to hear from you! Please share your thoughts and ideas with me via email.

Back to Index