Skip to main content
This guide covers managing secrets in your homelab using SOPS (Secrets OPerationS) with age encryption.

Overview

Secrets are managed using:
  • SOPS: For encrypting and managing secret files
  • age: Modern encryption tool using SSH keys
  • agenix: NixOS integration for SOPS
  • agenix-shell: Development shell integration

Secret Architecture

User-Based Access

Secrets are organized by user and team access:
users = {
  username = "ssh-ed25519 AAAA...";
};

# Per-user secrets
userSecrets = {
  api_key = [ "username" ];
};

# Team secrets
teams = {
  cloud = {
    users = ["username" "admin"];
    secrets = ["proxmox_api_secret" "cloudflare_token"];
  };
};

Setting Up Secrets

1

Generate SSH Key (If Needed)

Create an ED25519 SSH key for encryption:
ssh-keygen -t ed25519 -C "[email protected]"
This key will be used for encrypting/decrypting secrets.
2

Configure secrets.nix

Create or edit secrets.nix in your project root:
secrets.nix
let
  secretsFunction = {lib, ...}: let
    users = {
      soriphoono = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEgxxFcqHVwYhY0TjbsqByOYpmWXqzlVyGzpKjqS8mO7";
      admin = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...";
    };

    # Per-user secrets: simple mapping of secret name -> list of users
    userSecrets = {
      github_token = [ "soriphoono" ];
      aws_credentials = [ "admin" ];
    };

    # Team secrets: for shared access across multiple users
    teams = {
      cloud = {
        users = ["soriphoono" "admin"];
        secrets = ["proxmox_api_secret" "cloudflare_token"];
      };
      
      database = {
        users = ["admin"];
        secrets = ["postgres_password" "redis_password"];
      };
    };

    # Helper: create a secret entry with public keys
    mkSecret = name: userList: {
      name = "secrets/${name}.age";
      value = {
        publicKeys = builtins.map (user: users.${user}) userList;
      };
    };

    # Collect secrets from teams
    teamSecretsList = builtins.concatLists (
      builtins.map (team: 
        builtins.map (secret: mkSecret secret team.users) team.secrets
      ) (builtins.attrValues teams)
    );

    # Collect per-user secrets
    userSecretsList = builtins.attrValues (
      lib.mapAttrs' (secret: userList: mkSecret secret userList) userSecrets
    );

    # Shell secrets for agenix-shell
    envUser = builtins.getEnv "USER";
    currentUser = if envUser == "" then "soriphoono" else envUser;
    userTeams = builtins.filter (team: builtins.elem currentUser team.users) (builtins.attrValues teams);
    userOwnSecrets = builtins.attrNames (lib.filterAttrs (_: userList: builtins.elem currentUser userList) userSecrets);

    agenix-shell-secrets = lib.listToAttrs (builtins.concatLists [
      # Secrets from teams the user belongs to
      (builtins.concatLists (builtins.map (
          team:
            builtins.map (secret: {
              name = lib.toUpper secret;
              value.file = ./secrets/${secret}.age;
            })
            team.secrets
        )
        userTeams))
      # Per-user secrets the user has access to
      (builtins.map (secret: {
          name = lib.toUpper secret;
          value.file = ./secrets/${secret}.age;
        })
        userOwnSecrets)
    ]);
  in
    {
      inherit agenix-shell-secrets;
    }
    // builtins.listToAttrs (teamSecretsList ++ userSecretsList);

  # Minimal lib for the default call
  libMinimal = {
    inherit (builtins) listToAttrs concatLists;
    mapAttrs' = f: set:
      builtins.listToAttrs (builtins.map (n: f n set.${n}) (builtins.attrNames set));
    filterAttrs = f: set:
      builtins.listToAttrs (builtins.map (n: {
        name = n;
        value = set.${n};
      }) (builtins.filter (n: f n set.${n}) (builtins.attrNames set)));
    toUpper = s: s;
  };
in
  (secretsFunction {lib = libMinimal;})
  // {
    __functor = _: secretsFunction;
  }
3

Create secrets Directory

mkdir -p secrets
4

Add User to secrets.nix

Add your SSH public key to the users attribute:
users = {
  yourname = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...";
};
5

Create a Secret File

Use agenix to create and encrypt a secret:
# Enter dev shell with agenix
nix develop

# Create/edit secret
agenix -e secrets/proxmox_api_secret.age
This opens your $EDITOR to input the secret value.
6

Enable Secrets Module in System

In your system configuration:
systems/yoursystem/default.nix
core.secrets = {
  enable = true;
  defaultSopsFile = ./secrets.yaml;  # If using YAML
};
7

Commit Encrypted Secrets

git add secrets/*.age secrets.nix
git commit -m "feat: add secret management configuration"
Only commit .age encrypted files, never plain text secrets!

Using Secrets

In NixOS Configuration

Reference secrets in your system configuration:
sops.secrets."proxmox_api_secret" = {
  sopsFile = ./secrets/proxmox_api_secret.age;
  owner = "root";
  group = "root";
  mode = "0400";
};

# Use in service configuration
services.myservice = {
  enable = true;
  apiKeyFile = config.sops.secrets."proxmox_api_secret".path;
};

In Development Shell

Secrets are automatically loaded as environment variables in the dev shell:
nix develop

# Access secrets as environment variables
echo $PROXMOX_API_SECRET

In Home Manager

For user-level secrets:
core.secrets.enable = true;

# Creates ~/.config/sops/age/keys.txt

Secret Management Workflows

  1. Update secrets.nix with user/team access
  2. Create the secret file:
    agenix -e secrets/newsecret.age
    
  3. Commit the encrypted file:
    git add secrets/newsecret.age
    git commit -m "feat: add new secret for service X"
    
  1. Edit the existing secret:
    agenix -e secrets/existing.age
    
  2. Update the value and save
  3. Commit the change:
    git add secrets/existing.age
    git commit -m "chore: rotate existing secret"
    
  4. Deploy to systems:
    nixos-rebuild switch --flake .#system
    
  1. Get user’s SSH public key
  2. Add to secrets.nix:
    users.newuser = "ssh-ed25519 AAAAC3...";
    
  3. Add user to team or grant individual access:
    teams.cloud.users = ["existinguser" "newuser"];
    
  4. Re-encrypt all secrets:
    agenix -r
    
  5. Commit changes:
    git add secrets.nix secrets/*.age
    git commit -m "chore: grant secret access to newuser"
    
  1. Remove user from secrets.nix
  2. Re-encrypt secrets:
    agenix -r
    
  3. Commit and deploy immediately

Secret Organization

Directory Structure

.
├── secrets.nix           # Secret access configuration
├── secrets/
│   ├── proxmox_api_secret.age
│   ├── cloudflare_token.age
│   ├── github_token.age
│   └── postgres_password.age
└── systems/
    └── myserver/
        └── secrets.yaml  # System-specific secrets (optional)

Naming Conventions

  • Use lowercase with underscores: api_key.age
  • Be descriptive: cloudflare_api_token.age not cf.age
  • Group by service: postgres_password.age, postgres_user.age

Templates Integration

The empty template includes secrets configuration:
nix flake init -t .#empty
This creates:
  • secrets.nix with base configuration
  • shell.nix with agenix integration
  • Pre-configured flake inputs for agenix

Security Best Practices

  • Never commit unencrypted secrets
  • Always use .gitignore for plain text secret files
  • Rotate secrets after removing user access
  • Use team-based access for shared secrets
  • Keep SSH private keys secure
  • Use different secrets for dev/staging/prod
  • Document secret purpose in secrets.nix comments
  • Regular secret rotation improves security
  • Use read-only permissions (0400) where possible

Troubleshooting

Cannot Decrypt Secret

# Verify your SSH key is loaded
ssh-add -l

# Check secrets.nix contains your public key
grep "your-key" secrets.nix

Secret Not Available in Shell

# Ensure agenix-shell is configured in flake.nix
# Exit and re-enter dev shell
exit
nix develop

Re-encrypting All Secrets

# After updating secrets.nix
agenix -r

Next Steps

Build docs developers (and LLMs) love