Arch Linux to NixOS Migration Guide

Migrating a system with many custom systemd services and Docker/docker-compose setups.


Overview

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:


Phase 1: Audit Your Current System

Before touching NixOS, document what you have.

1.1 List all custom systemd units

# 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:

1.2 Inventory Docker containers

docker 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:

1.3 Capture other system state

# 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.


Phase 2: Learn the NixOS Primitives

Do not skip this. Attempting a migration without understanding the basics will produce cargo-culted configs that are hard to maintain.

2.1 Essential concepts

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

2.2 The configuration structure

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";
}

2.3 Flakes (recommended for complex setups)

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" ];

Phase 3: Translate Systemd Services

3.1 Simple service translation

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";
  };
};

3.2 Managing the service user

users.users.myapp = {
  isSystemUser = true;
  group = "myapp";
  home = "/var/lib/myapp";
  createHome = true;
};
users.groups.myapp = {};

3.3 Secrets / environment files

Do not put secrets in configuration.nix — it ends up in the world-readable Nix store. Instead:

3.4 Systemd timers (replacing cron)

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";
  };
};

3.5 Socket activation

systemd.sockets.myapp = {
  wantedBy = [ "sockets.target" ];
  socketConfig.ListenStream = 8080;
};

Phase 4: Migrate Docker / Docker Compose

You have three paths. Evaluate each per workload.

Path A: Keep Docker, manage it with NixOS

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";
  };
};

Path B: NixOS OCI containers (declarative docker run)

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.

Path C: Arion (compose-like, Nix-native)

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" ];
  };
};

Choosing a path

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 as an alternative to Docker

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;
};

Phase 5: Networking and Firewall

5.1 Firewall

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 {
      ...
    }
  '';
};

5.2 DNS / resolv.conf

networking.nameservers = [ "1.1.1.1" "9.9.9.9" ];
# Or use systemd-resolved:
services.resolved = {
  enable = true;
  dnssec = "allow-downgrade";
};

5.3 Hostname and hosts file

networking.hostName = "myhostname";
networking.extraHosts = ''
  192.168.1.10  nas.local
'';

Phase 6: Users, Groups, and SSH

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";
  };
};

Phase 7: Kernel Modules and Hardware

# 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" ];

Phase 8: Migration Strategy

Do not try to migrate everything at once. Use this order:

Step 1: Install NixOS (fresh or alongside Arch)

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

Step 2: Get basic system working

Step 3: Migrate services one at a time

Start with the simplest, most isolated services. For each:

  1. Write the NixOS equivalent in configuration.nix
  2. Run nixos-rebuild switch
  3. Verify the service starts and functions correctly
  4. Check journalctl -u servicename for errors

Step 4: Migrate Docker stacks

Migrate one compose stack at a time. Test each thoroughly before moving to the next.

Step 5: Decommission Arch

Once all services are verified on NixOS, cut DNS/IP over and decommission.


Phase 9: Organizing Your Config (Modules)

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
];

Common Pitfalls

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.


Useful Commands

# 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

References