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. Use when converting docker-compose.yaml files to NixOS modules or creating new Arion-based services.
Scanned 2/12/2026
Install via CLI
openskills install majiayu000/claude-skill-registry---
name: docker-compose-to-nixos
description: 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. Use when converting docker-compose.yaml files to NixOS modules or creating new Arion-based services.
---
# Docker Compose to NixOS Module
This skill converts Docker Compose configurations to NixOS modules following the dendritic pattern used in this repository.
## Dendritic Pattern Overview
The dendritic pattern organizes modules by **aspect** (functionality) rather than **class** (nixos/home/shared):
- File: `modules/services/service-name.nix` (NOT `modules/nixos/services/service-name.nix`)
- Contains both nixosModules and homeModules in the same file
- Flake inputs defined in module files, materialized with `nix run '.#write-flake'`
- Every module MUST have a unique `key` attribute for deduplication
## Conversion Workflow
Follow this checklist when converting a docker-compose.yaml:
- [ ] **1. Analyze docker-compose.yaml**: Identify services, volumes, networks, secrets
- [ ] **2. Determine UID/GID allocation**: Add to `inventory/users-groups.nix` if system user needed
- [ ] **3. Create module skeleton**: Use template in this document
- [ ] **4. Add system user/group**: Reference inventory values with flakeConfig
- [ ] **5. Configure sops secrets**: Use hierarchical naming (e.g., `service/secret-name`)
- [ ] **6. Create sops template**: Environment file with placeholders
- [ ] **7. Set up directories**: systemd tmpfiles.rules
- [ ] **8. Convert services**: Map docker-compose to Arion configuration
- [ ] **9. Add Tailscale serve** (optional): For external access
- [ ] **10. Stage with git add**: Flakes only see committed/staged files
- [ ] **11. Validate**: Run `just nixOpts= eval-nixos` in a loop until fixed
- [ ] **12. Add secrets**: Use `sops set` commands to populate secret values
- [ ] **13. Run comprehensive validation**: `just eval-all`
- [ ] **14. Format**: `just lint` (may reformat, amend if needed)
- [ ] **15. Commit changes**: Follow git workflow in CLAUDE.md
## Module Template
```nix
{
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";
};
};
};
};
}
```
## Key Patterns
### 1. System User Management
**Add to `inventory/users-groups.nix`:**
```nix
{
systemUsers = {
existing-service = { uid = 2001; gid = 2001; };
new-service = { uid = 2002; gid = 2002; }; # Next sequential
};
}
```
**Reference in module with flakeConfig:**
```nix
{ 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`.
### 2. Sops Secrets Management
**Hierarchical naming with `/`:**
```nix
sops.secrets."service-name/nextauth-secret" = { };
sops.secrets."service-name/database-password" = { };
sops.secrets."service-name/api-key" = { };
```
**Create template for environment file:**
```nix
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:
```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):**
```bash
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
```
### 3. Directory Structure
**Mount strategy:**
- `/mnt/service-name/data` - Application data (persistent)
- `/mnt/service-name/var/component` - Per-component state
**Example:**
```nix
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 -"
];
```
### 4. Docker Compose Service Translation
**Docker Compose:**
```yaml
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:**
```nix
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 strings
- `volumes: ["/host:/container"]` - Absolute host paths, array format
- `env_file: [path]` - Reference sops template path
- `depends_on: ["service"]` - Array of service names
- `expose: ["7700"]` - For internal-only ports
- **NEVER use `:latest`** - Always pin to specific version
### 5. Tailscale Serve Integration
**Basic pattern:**
```nix
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.
## Image Version Pinning
**CRITICAL:** Never use `:latest` tag. Always pin to specific versions:
- ✅ `ghcr.io/karakeep-app/karakeep:0.29.1`
- ✅ `getmeili/meilisearch:v1.13.3`
- ✅ `gcr.io/zenika-hub/alpine-chrome:124`
- ❌ `archivebox/archivebox:latest`
- ❌ `postgres:latest`
Find current versions:
- Check project's GitHub releases
- Check Docker Hub/GHCR tags
- Use `docker pull image:latest && docker inspect image:latest` to find current digest
## Validation Workflow
**Iterative validation:**
```bash
# Fast iteration on current machine
just nixOpts= eval-nixos
# Fix errors, repeat until clean
# Comprehensive validation (all 12 configs, ~45s)
just eval-all
# Format and fix any linting issues
just lint
# If lint changed files, amend commit
git add . && git commit --amend --no-edit
```
## Common Patterns
### Multiple Services in One Project
```nix
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" ];
};
};
};
};
};
```
### Environment Variables vs env_file
**Use env_file for secrets:**
```nix
env_file = [
config.sops.templates.service-name-env.path
];
```
**Use environment for non-sensitive config:**
```nix
environment = {
NODE_ENV = "production";
PORT = "3000";
MEILI_NO_ANALYTICS = "true";
};
```
### Container Commands
```nix
service = {
image = "namespace/app:1.0.0";
command = [
"schedule"
"--foreground"
"--update"
"--every=day"
];
};
```
## Git Workflow
**Critical for flakes:**
1. Stage new files immediately: `git add modules/services/service-name.nix inventory/users-groups.nix`
- Flakes only see committed or staged files
2. Validate before committing
3. Check for existing unpushed commits - consider using `--fixup` if related
4. Follow commit message format in CLAUDE.md
## Troubleshooting
**"flake is dirty" warning**: Normal during development, flake sees uncommitted changes
**Evaluation fails with "attribute missing"**:
- Check if module is imported in machine configuration
- Verify inventory.usersGroups exists if using it
- Ensure all imports (sops-nix, arion) are available
**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
## Examples and References
- **Detailed examples**: See [EXAMPLES.md](EXAMPLES.md) for complete archivebox and karakeep conversions
- **Technical reference**: See [REFERENCE.md](REFERENCE.md) for docker-compose to Arion mapping tables and advanced patterns
## Quick Reference
**File locations:**
- Module: `modules/services/service-name.nix`
- UID/GID: `inventory/users-groups.nix`
- Secrets: `secrets/docker-on-nixos/secrets.yaml`
**Validation commands:**
- Fast: `just nixOpts= eval-nixos`
- Comprehensive: `just eval-all`
- Lint: `just lint`
**Secrets commands (use nested path for hierarchical structure):**
```bash
sops set secrets/docker-on-nixos/secrets.yaml '["service"]["secret"]' "$(echo '"'$(apg -x16 -m16 -MLCN -n1)'"')"
```
**Module structure:**
1. Outer: `{ inputs, self, config, ... }:` with `let flakeConfig = config; in`
2. Module: `flake.nixosModules.name = { config, lib, ... }:`
3. Key: `key = "nixos-config.modules.nixos.name";`
4. Imports: All dependencies explicitly listed
5. Config: User, secrets, dirs, arion, tailscale
No comments yet. Be the first to comment!