Converts Docker Compose configurations to NixOS modules using the dendritic pattern with Arion. Creates modules with system users, sops secrets, Arion docker-compose config, and Tailscale integration...
This skill converts Docker Compose configurations to NixOS modules following the dendritic pattern used in this repository.
The dendritic pattern organizes modules by aspect (functionality) rather than class (nixos/home/shared):
modules/services/service-name.nix (NOT modules/nixos/services/service-name.nix)nix run '.#write-flake'key attribute for deduplicationFollow this checklist when converting a docker-compose.yaml:
inventory/users-groups.nix if system user neededservice/secret-name)ncf eval nixos in a loop until fixedsops set commands to populate secret valuesncf eval alljust lint (may reformat, amend if needed){
inputs,
self,
config,
...
}:
let
flakeConfig = config;
in
{
flake.nixosModules.service-name =
{ config, lib, ... }:
let
inherit (lib) mkDefault;
in
{
key = "nixos-config.modules.nixos.service-name";
# Explicit imports for all dependencies
imports = [
inputs.sops-nix.nixosModules.sops
inputs.arion.nixosModules.arion
self.nixosModules.tailscale
self.nixosModules.inventory
];
config = {
# User/group creation
users.groups.service-name = {
gid = flakeConfig.inventory.usersGroups.systemUsers.service-name.gid;
};
users.users.service-name = {
isSystemUser = true;
group = "service-name";
uid = flakeConfig.inventory.usersGroups.systemUsers.service-name.uid;
};
# Sops secrets
sops.secrets."service-name/secret1" = { };
sops.secrets."service-name/secret2" = { };
# Sops template for environment variables
sops.templates."service-name-env".content = ''
SECRET1=${config.sops.placeholder."service-name/secret1"}
SECRET2=${config.sops.placeholder."service-name/secret2"}
PUID=${toString flakeConfig.inventory.usersGroups.systemUsers.service-name.uid}
PGID=${toString flakeConfig.inventory.usersGroups.systemUsers.service-name.gid}
'';
# Directory structure
systemd.tmpfiles.rules = [
"d /mnt/service-name 0755 service-name service-name -"
"d /mnt/service-name/data 0755 service-name service-name -"
];
# Arion docker-compose configuration
virtualisation.arion.backend = "docker";
virtualisation.arion.projects.service-name = {
serviceName = "service-name-docker-compose";
settings = {
services = {
main-service = {
service = {
image = "namespace/image:pinned-version"; # NEVER use :latest
container_name = "service-name";
restart = "unless-stopped";
ports = [ "3000:3000" ];
volumes = [
"/mnt/service-name/data:/data"
];
env_file = [
config.sops.templates.service-name-env.path
];
};
};
};
};
};
# Tailscale serve (optional)
services.tailscale.serve = {
enable = mkDefault true;
services.service-name = {
serviceName = "service-name";
protocol = "https";
target = "localhost:3000";
};
};
};
};
}
Add to inventory/users-groups.nix:
{
systemUsers = {
existing-service = { uid = 2001; gid = 2001; };
new-service = { uid = 2002; gid = 2002; }; # Next sequential
};
}
Reference in module with flakeConfig:
{ inputs, self, config, ... }:
let
flakeConfig = config; # CRITICAL: Access flake-level data
in
{
flake.nixosModules.service-name = { config, lib, ... }: {
config = {
users.groups.service-name = {
gid = flakeConfig.inventory.usersGroups.systemUsers.service-name.gid;
};
users.users.service-name = {
isSystemUser = true;
group = "service-name";
uid = flakeConfig.inventory.usersGroups.systemUsers.service-name.uid;
};
};
};
}
Why flakeConfig? The outer config parameter is at the flake level (same level as self, inputs). The inner config parameter inside flake.nixosModules.service-name is at the NixOS module level. Use flakeConfig to access flake-level inventory data, matching the pattern in modules/lxc.nix and modules/inventory.nix.
Hierarchical naming with /:
sops.secrets."service-name/nextauth-secret" = { };
sops.secrets."service-name/database-password" = { };
sops.secrets."service-name/api-key" = { };
Create template for environment file:
sops.templates."service-name-env".content = ''
NEXTAUTH_SECRET=${config.sops.placeholder."service-name/nextauth-secret"}
DATABASE_URL="postgresql://user:${config.sops.placeholder."service-name/database-password"}@host/db"
API_KEY=${config.sops.placeholder."service-name/api-key"}
PUID=${toString flakeConfig.inventory.usersGroups.systemUsers.service-name.uid}
PGID=${toString flakeConfig.inventory.usersGroups.systemUsers.service-name.gid}
'';
IMPORTANT: SOPS YAML Structure
Secrets with / in Nix (like service-name/secret) MUST be structured as nested YAML:
# CORRECT - nested structure
service-name:
nextauth-secret: value
database-password: value
api-key: value
# WRONG - flat structure (will cause build errors)
service-name/nextauth-secret: value
service-name/database-password: value
Add actual secrets with sops set (using nested path):
sops set secrets/docker-on-nixos/secrets.yaml '["service-name"]["nextauth-secret"]' "$(echo '"'$(apg -x16 -m16 -MLCN -n1)'"')"
sops set secrets/docker-on-nixos/secrets.yaml '["service-name"]["database-password"]' "$(echo '"'$(apg -x16 -m16 -MLCN -n1)'"')"
sops set secrets/docker-on-nixos/secrets.yaml '["service-name"]["api-key"]' '""' # Empty for manual population
Mount strategy:
/mnt/service-name/data - Application data (persistent)/mnt/service-name/var/component - Per-component stateExample:
systemd.tmpfiles.rules = [
"d /mnt/service-name 0755 service-name service-name -"
"d /mnt/service-name/data 0755 service-name service-name -"
"d /mnt/service-name/var 0755 service-name service-name -"
"d /mnt/service-name/var/database 0755 service-name service-name -"
"d /mnt/service-name/var/cache 0755 service-name service-name -"
];
Docker Compose:
services:
app:
image: namespace/app:1.2.3
container_name: myapp
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./data:/app/data
environment:
SECRET: ${SECRET}
depends_on:
- database
Arion Configuration:
services = {
app = {
service = {
image = "namespace/app:1.2.3"; # ALWAYS pin version
container_name = "myapp";
restart = "unless-stopped";
ports = [ "3000:3000" ];
volumes = [
"/mnt/service-name/data:/app/data"
];
env_file = [
config.sops.templates.service-name-env.path
];
depends_on = [ "database" ];
};
};
};
Key differences:
ports: ["3000:3000"] - Array of stringsvolumes: ["/host:/container"] - Absolute host paths, array formatenv_file: [path] - Reference sops template pathdepends_on: ["service"] - Array of service namesexpose: ["7700"] - For internal-only ports:latest - Always pin to specific versionBasic pattern:
services.tailscale.serve = {
enable = mkDefault true;
services.service-name = {
serviceName = "service-name"; # Will be svc:service-name
protocol = "https";
target = "localhost:3000";
};
};
This exposes the service at https://svc:service-name on the Tailscale network.
CRITICAL: Never use :latest tag. Always pin to specific versions:
ghcr.io/karakeep-app/karakeep:0.29.1getmeili/meilisearch:v1.13.3gcr.io/zenika-hub/alpine-chrome:124archivebox/archivebox:latestpostgres:latestFind current versions:
docker pull image:latest && docker inspect image:latest to find current digestIterative validation:
# Fast iteration on current machine
ncf eval nixos
# Fix errors, repeat until clean
# Comprehensive validation (all configs)
ncf eval all
# Format and fix any linting issues
just lint
# If lint changed files, amend commit
git add . && git commit --amend --no-edit
virtualisation.arion.projects.service-name = {
serviceName = "service-name-docker-compose";
settings = {
services = {
# Main app
app = {
service = {
image = "namespace/app:1.0.0";
depends_on = [ "database" "cache" ];
# ...
};
};
# Database
database = {
service = {
image = "postgres:16.1";
expose = [ "5432" ]; # Internal only
volumes = [
"/mnt/service-name/var/database:/var/lib/postgresql/data"
];
environment = {
POSTGRES_PASSWORD = config.sops.placeholder."service-name/db-password";
};
};
};
# Cache
cache = {
service = {
image = "redis:7.2.3";
expose = [ "6379" ];
};
};
};
};
};
Use env_file for secrets:
env_file = [
config.sops.templates.service-name-env.path
];
Use environment for non-sensitive config:
environment = {
NODE_ENV = "production";
PORT = "3000";
MEILI_NO_ANALYTICS = "true";
};
service = {
image = "namespace/app:1.0.0";
command = [
"schedule"
"--foreground"
"--update"
"--every=day"
];
};
Critical for flakes:
git add modules/services/service-name.nix inventory/users-groups.nix--fixup if related"flake is dirty" warning: Normal during development, flake sees uncommitted changes
Evaluation fails with "attribute missing":
UID/GID conflicts: Check inventory/users-groups.nix for next available ID
Secrets not decrypted: Ensure sops secrets file exists and is properly encrypted for the target machine's key
File locations:
modules/services/service-name.nixinventory/users-groups.nixsecrets/docker-on-nixos/secrets.yamlValidation commands:
ncf eval nixosncf eval alljust lintSecrets commands (use nested path for hierarchical structure):
sops set secrets/docker-on-nixos/secrets.yaml '["service"]["secret"]' "$(echo '"'$(apg -x16 -m16 -MLCN -n1)'"')"
Module structure:
{ inputs, self, config, ... }: with let flakeConfig = config; inflake.nixosModules.name = { config, lib, ... }:key = "nixos-config.modules.nixos.name";