Migrating a system with many custom systemd services and Docker/docker-compose setups.
NixOS replaces the traditional "configure files in /etc, install packages, write systemd units" workflow with a declarative configuration. Everything — packages, services, users, networking — is described in Nix files and applied atomically. The mental shift is the hardest part.
Key things to internalize before starting:
/etc files directly on NixOS. They are generated.configuration.nix (or a flake) is the single source of truth.nixos-rebuild switch is atomic and rollback-able.Before touching NixOS, document what you have.
# User-level units
systemctl --user list-units --type=service --all
# System-level units
systemctl list-units --type=service --all | grep -v '@'
# Find non-package-owned units
find /etc/systemd/system /usr/local/lib/systemd/system -name '*.service' -o -name '*.timer' -o -name '*.socket' 2>/dev/null
For each service, record:
After= / Requires= dependenciesCapabilityBoundingSet, ProtectSystem, or other hardening optionsdocker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
docker volume ls
docker network ls
For each container, find its compose file or docker run invocation. Capture:
.env files)depends_on relationships# Explicitly installed packages (pacman)
pacman -Qqe > ~/arch-explicit-packages.txt
# AUR packages
pacman -Qqm > ~/arch-aur-packages.txt
# Cron jobs
crontab -l > ~/arch-crontab.txt
sudo crontab -l >> ~/arch-crontab.txt
# Open ports / firewall rules
ss -tlnp
sudo iptables-save > ~/arch-iptables.txt
# Kernel modules
lsmod > ~/arch-lsmod.txt
cat /etc/modules-load.d/* >> ~/arch-modules.txt
# Fstab / mounts
cat /etc/fstab > ~/arch-fstab.txt
# Hosts / DNS
cat /etc/hosts /etc/resolv.conf
# Users and groups
getent passwd | grep -v nologin | grep -v false
getent group
# SSH authorized keys (per user)
find /home /root -name authorized_keys 2>/dev/null -exec cat {} \;
Keep these files — they are your migration reference.
Do not skip this. Attempting a migration without understanding the basics will produce cargo-culted configs that are hard to maintain.
| Arch concept | NixOS equivalent |
|---|---|
pacman -S pkg |
environment.systemPackages = [ pkgs.pkg ] |
/etc/systemd/system/foo.service |
systemd.services.foo = { ... } |
/etc/environment |
environment.variables = { ... } |
useradd |
users.users.foo = { ... } |
docker run / compose |
virtualisation.oci-containers.containers.foo or Arion |
crontab |
services.cron or systemd.timers |
iptables / nftables |
networking.firewall.* or networking.nftables |
A minimal starting point:
# /etc/nixos/configuration.nix
{ config, pkgs, ... }:
{
imports = [ ./hardware-configuration.nix ];
boot.loader.grub.device = "/dev/sda";
networking.hostName = "myhostname";
environment.systemPackages = with pkgs; [ vim git curl ];
system.stateVersion = "25.11";
}
Flakes give you pinned inputs, better reproducibility, and are now the standard for serious configs.
# flake.nix
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }: {
nixosConfigurations.myhostname = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ ./configuration.nix ];
};
};
}
Enable flakes in your initial configuration.nix:
nix.settings.experimental-features = [ "nix-flakes" "nix-command" ];
An Arch unit like:
# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network.target
[Service]
User=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/server --port 8080
Restart=always
Environment=NODE_ENV=production
Environment=PORT=8080
EnvironmentFile=/etc/myapp/env
[Install]
WantedBy=multi-user.target
Becomes:
systemd.services.myapp = {
description = "My App";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "myapp";
WorkingDirectory = "/opt/myapp";
ExecStart = "/opt/myapp/bin/server --port 8080";
Restart = "always";
EnvironmentFile = "/etc/myapp/env"; # secrets file, managed outside Nix
};
environment = {
NODE_ENV = "production";
PORT = "8080";
};
};
users.users.myapp = {
isSystemUser = true;
group = "myapp";
home = "/var/lib/myapp";
createHome = true;
};
users.groups.myapp = {};
Do not put secrets in configuration.nix — it ends up in the world-readable Nix store. Instead:
EnvironmentFile pointing to a path outside the store (e.g. /etc/myapp/env, /run/secrets/myapp)systemd.timers.myapp-backup = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "daily";
Persistent = true;
};
};
systemd.services.myapp-backup = {
description = "MyApp backup";
serviceConfig = {
Type = "oneshot";
ExecStart = "/opt/myapp/scripts/backup.sh";
};
};
systemd.sockets.myapp = {
wantedBy = [ "sockets.target" ];
socketConfig.ListenStream = 8080;
};
You have three paths. Evaluate each per workload.
The simplest migration path. Enable Docker and keep your compose files as-is.
virtualisation.docker = {
enable = true;
autoPrune.enable = true;
autoPrune.dates = "weekly";
};
users.users.youruser.extraGroups = [ "docker" ];
Then continue using docker compose up -d manually or via a systemd service:
systemd.services.myapp-compose = {
description = "MyApp docker-compose stack";
after = [ "docker.service" "network-online.target" ];
requires = [ "docker.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
WorkingDirectory = "/opt/myapp";
ExecStart = "${pkgs.docker-compose}/bin/docker-compose up -d --remove-orphans";
ExecStop = "${pkgs.docker-compose}/bin/docker-compose down";
};
};
For single containers without compose dependencies, virtualisation.oci-containers is clean:
virtualisation.oci-containers = {
backend = "docker"; # or "podman"
containers.nginx = {
image = "nginx:1.27";
ports = [ "80:80" "443:443" ];
volumes = [
"/var/lib/nginx/html:/usr/share/nginx/html:ro"
"/var/lib/nginx/conf:/etc/nginx/conf.d:ro"
];
environment = {
NGINX_HOST = "example.com";
};
environmentFiles = [ "/etc/nginx/secrets.env" ];
extraOptions = [ "--network=host" ];
};
};
This generates a systemd service per container. No compose file needed.
Arion lets you write compose-like configs in Nix, using Docker or Podman. Good for multi-container stacks you want fully declarative.
# In your flake inputs:
# inputs.arion.url = "github:hercules-ci/arion";
services.arion.projects.mystack.settings = {
services.db.service = {
image = "postgres:16";
environment.POSTGRES_PASSWORD = "secret";
volumes = [ "pgdata:/var/lib/postgresql/data" ];
};
services.app.service = {
image = "myapp:latest";
depends_on = [ "db" ];
ports = [ "8080:8080" ];
};
};
| Situation | Recommended path |
|---|---|
| Many existing compose files, want minimal change | Path A (keep compose) |
| Isolated single containers | Path B (oci-containers) |
| Multi-container stack, want full Nix declaration | Path C (Arion) |
| Can replace container with native NixOS service | Drop Docker entirely |
Podman runs rootless by default and integrates more cleanly with systemd:
virtualisation.podman = {
enable = true;
dockerCompat = true; # makes `docker` command an alias
defaultNetwork.settings.dns_enabled = true;
};
NixOS has a simple stateful firewall wrapper:
networking.firewall = {
enable = true;
allowedTCPPorts = [ 80 443 8080 ];
allowedUDPPorts = [ 53 ];
# For ranges:
allowedTCPPortRanges = [{ from = 8000; to = 8100; }];
};
For complex nftables rules from Arch:
networking.nftables = {
enable = true;
ruleset = ''
# paste your nftables ruleset here
table inet filter {
...
}
'';
};
networking.nameservers = [ "1.1.1.1" "9.9.9.9" ];
# Or use systemd-resolved:
services.resolved = {
enable = true;
dnssec = "allow-downgrade";
};
networking.hostName = "myhostname";
networking.extraHosts = ''
192.168.1.10 nas.local
'';
users.users.dan = {
isNormalUser = true;
extraGroups = [ "wheel" "docker" "video" "audio" ];
shell = pkgs.zsh;
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAA... dan@workstation"
];
};
# Allow sudo for wheel
security.sudo.wheelNeedsPassword = false; # or true
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "no";
};
};
# Modules to load at boot
boot.kernelModules = [ "kvm-amd" "v4l2loopback" ];
boot.extraModulePackages = [ config.boot.kernelPackages.v4l2loopback ];
# Module options
boot.extraModprobeConfig = ''
options v4l2loopback devices=1 video_nr=1 card_label="OBS Cam" exclusive_caps=1
'';
# Blacklist modules
boot.blacklistedKernelModules = [ "nouveau" ];
Do not try to migrate everything at once. Use this order:
If you can afford downtime, fresh install is cleanest. If not, you can install NixOS alongside Arch on a separate partition and test before cutting over.
Use nixos-generate-config to auto-detect hardware:
nixos-generate-config --root /mnt
nixos-rebuild switch works.Start with the simplest, most isolated services. For each:
configuration.nixnixos-rebuild switchjournalctl -u servicename for errorsMigrate one compose stack at a time. Test each thoroughly before moving to the next.
Once all services are verified on NixOS, cut DNS/IP over and decommission.
As your config grows, split it into modules:
/etc/nixos/
flake.nix
configuration.nix # imports everything
hardware-configuration.nix
modules/
services/
myapp.nix
nginx.nix
postgres.nix
docker/
mystack.nix
users.nix
networking.nix
secrets.nix
Each module is just a file that returns { config, pkgs, ... }: { ... }. Import them in configuration.nix:
imports = [
./hardware-configuration.nix
./modules/users.nix
./modules/networking.nix
./modules/services/myapp.nix
./modules/docker/mystack.nix
];
Mutable state in /var: NixOS manages configuration but not runtime state. Databases, uploaded files, and similar data live in /var/lib/ and are yours to manage. Back them up before migrating.
Packages not in nixpkgs: If an Arch AUR package has no nixpkgs equivalent, you will need to write a Nix derivation or use pkgs.stdenv.mkDerivation. Check search.nixos.org first — coverage is broad.
nixos-rebuild fails on first switch: Common causes are syntax errors in .nix files (nix-instantiate --eval configuration.nix helps), missing imports, or package name differences. Use nixos-rebuild switch --show-trace for details.
Docker networking vs NixOS firewall: Docker manipulates iptables directly. If networking.firewall.enable = true, Docker container ports may be exposed even if not in allowedTCPPorts. Set virtualisation.docker.extraOptions = "--iptables=false" and manage rules manually if this is a concern.
stateVersion: Set this to the NixOS version you installed with and do not change it. It controls backwards-compatibility defaults, not the packages you get.
Files in the Nix store are read-only: Any service that tries to write to its own install path will fail. Ensure WorkingDirectory and data paths point to /var/lib/servicename or similar mutable locations.
# Apply configuration
nixos-rebuild switch
# Test without making permanent (reverts on reboot)
nixos-rebuild test
# Build but don't switch (validate config)
nixos-rebuild build
# Roll back to previous generation
nixos-rebuild switch --rollback
# or at boot: select previous generation in GRUB
# List generations
nix-env --list-generations --profile /nix/var/nix/profiles/system
# Search packages
nix search nixpkgs firefox
# Inspect a generated systemd unit
systemctl cat myapp.service
# View service logs
journalctl -u myapp.service -f
# Garbage collect old generations
nix-collect-garbage -d