OFFSET偏移
← All articlesOFFSET / FIELD NOTESNO. 073

Dividing deployment responsibilities between Terraform and NixOS

Manage cloud resources with Terraform, then deploy NixOS system configuration through a controlled process.

English translation · Code and commands follow the Chinese original.

IN THESE READING PATHSNixOS virtualization and deployment ↗
About 9 min readStart reading

Version scope: The examples below retain the tutorial's NixOS 24.05 context; that release is no longer maintained. For a real project, choose a supported release, pin the nixpkgs revision, and check the corresponding options. system.stateVersion is a compatibility baseline and should not be changed mechanically with each upgrade. These commands are teaching examples and have not been run or verified on the target system.

This article draws on nix.dev's approach to infrastructure and system deployment. It includes extended examples and is not a word-for-word translation of that tutorial. The official tutorial itself also uses an older release, so update the pinned inputs before use. Reference: Deploying NixOS using Terraform

Combine Terraform's infrastructure management with NixOS system configuration for a complete IaC (Infrastructure as Code) workflow.

Why choose Terraform + NixOS?#

Feature Terraform NixOS Combined benefit
Infrastructure management ✅ Excellent ❌ Out of scope Cloud-platform resources
System configuration ❌ Out of scope ✅ Excellent Declarative OS
Reproducibility Declare resources and track state Pin inputs and build the system closure Cloud resources still depend on platform state, images, and external services
Version control Good Excellent Complete history

Architecture overview#

┌─────────────────┐      ┌─────────────────┐      ┌───────────────┐
│  Terraform      │──────▶│  Cloud Provider │──────▶│  NixOS        │
│  - VM/Network   │      │  - AWS/GCP      │      │  - configure  │
│  - Firewall     │      │  - Disk/IP      │      │  - user       │
└─────────────────┘      └─────────────────┘      └───────────────┘
                              │
                              ▼
                         NixOS 安装镜像

Basic configuration#

1. Preparing a NixOS image

If a suitable NixOS AMI is already available on AWS, there is no need to first build an automated installer ISO that destructively modifies a disk. Custom installation media are appropriate for controlled environments without ready-made images; this is an installation path separate from cloud-resource creation. The full partitioning, formatting, mounting, configuration-generation, and installation workflow is retained below, but execution at boot is replaced with a manually started oneshot service.

Prerequisites: This is a dedicated, empty test disk, and its data has been backed up. The disk identity, UEFI mode, and partition sizes have been verified. The ISO contains target-configuration.nix with the correct bootloader, filesystems, and SSH public key, and no real secrets. Choose the disk's stable by-id path and corresponding partition paths for your target; replace the example placeholders. This example requires sufficient disk capacity to reserve the final 8 GiB for swap.

# install-image.nix
{ pkgs, modulesPath, ... }:
{
  imports = [
    (modulesPath + "/installer/cd-dvd/installation-cd-minimal.nix")
  ];

  systemd.services.example-nixos-install = {
    description = "Install NixOS on an explicitly selected empty test disk";
    # 没有 wantedBy:不会开机自动清盘,确认后才手动启动
    path = with pkgs; [ parted e2fsprogs dosfstools util-linux systemd nixos-install-tools ];
    serviceConfig.Type = "oneshot";
    script = ''
      set -eu
      disk='/dev/disk/by-id/<EMPTY_TEST_DISK>'
      rootpart='/dev/disk/by-id/<EMPTY_TEST_DISK>-part1'
      swappart='/dev/disk/by-id/<EMPTY_TEST_DISK>-part2'
      esp='/dev/disk/by-id/<EMPTY_TEST_DISK>-part3'
      test -b "$disk"
      # 目标分区不得处于已挂载状态;由操作者在启动前核对 lsblk
      parted "$disk" -- mklabel gpt
      parted "$disk" -- mkpart primary 512MiB -8GiB
      parted "$disk" -- mkpart primary linux-swap -8GiB 100%
      parted "$disk" -- mkpart ESP fat32 1MiB 512MiB
      parted "$disk" -- set 3 esp on
      partprobe "$disk"
      udevadm settle

      mkfs.ext4 -L nixos "$rootpart"
      mkswap -L swap "$swappart"
      mkfs.fat -F 32 -n boot "$esp"
      mount "$rootpart" /mnt
      mkdir -p /mnt/boot
      mount "$esp" /mnt/boot
      swapon "$swappart"

      nixos-generate-config --root /mnt
      # 配置来自随 ISO 固定的本地源,保留生成的 hardware-configuration.nix
      cp ${./target-configuration.nix} /mnt/etc/nixos/configuration.nix
      nixos-install --no-root-passwd
      # 不自动重启:先检查安装结果、SSH 用户和可用恢复介质
    '';
  };
}

pkgs.systemd provides udevadm in the service PATH. --no-root-passwd only skips setting a root password during installation; it does not automatically create an administrator account that can log in. The target configuration must declare identities and authorization in advance. The example above does not automatically verify the disk or backups and cannot serve as a general-purpose unattended installer. Only after verification should you trigger installation with systemctl start example-nixos-install.service, then check the logs and the results under /mnt.

If you need to download configuration over the network, wait for network-online.target, use HTTPS downloads with error handling, and verify a trusted hash or signature. Being able to ping a public domain does not establish that the configuration is trustworthy. A more robust approach is to bake the reviewed configuration into the image.

2. Terraform configuration

The following retains the historical AWS provider 5.x configuration scope. Before creating main.tf, verify the region, AMI owner, architecture, username, and user-data support through official NixOS and cloud-platform sources. The provider documentation, .terraform.lock.hcl, and actual AMI determine the interface. The snippets contain placeholders and cannot be applied as-is. Create main.tf:

# 提供商配置(以 AWS 为例)
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-west-2"
}

# 获取 NixOS AMI
data "aws_ami" "nixos" {
  most_recent = true # 发现用;实际交付应记录并固定解析出的 AMI ID
  owners      = [ var.nixos_ami_owner ]  # 从官方渠道核验的镜像发布账号

  filter {
    name   = "name"
    values = [ var.nixos_ami_name ] # 核验过的版本名;最终部署宜固定 AMI ID
  }
  
  filter {
    name   = "architecture"
    values = [ "x86_64" ]
  }
}

# 创建 VPC
resource "aws_vpc" "nixos_vpc" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  
  tags = {
    Name = "nixos-vpc"
  }
}

# 子网
resource "aws_subnet" "nixos_subnet" {
  vpc_id                  = aws_vpc.nixos_vpc.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "us-west-2a"
  map_public_ip_on_launch = true
  
  tags = {
    Name = "nixos-subnet"
  }
}

# 安全组
resource "aws_security_group" "nixos_sg" {
  name_prefix = "nixos-sg"
  vpc_id      = aws_vpc.nixos_vpc.id

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.admin_cidr] # 仅可信管理网络
  }

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# SSH 密钥
resource "aws_key_pair" "deployer" {
  key_name   = "deployer-key"
  public_key = trimspace(file(pathexpand("~/.ssh/id_ed25519.pub")))
}

# VPC 的公网子网需要 Internet Gateway 与默认路由
resource "aws_internet_gateway" "nixos" {
  vpc_id = aws_vpc.nixos_vpc.id
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.nixos_vpc.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.nixos.id
  }
}

resource "aws_route_table_association" "public" {
  subnet_id = aws_subnet.nixos_subnet.id
  route_table_id = aws_route_table.public.id
}

# 由操作者提供,不内置账号与真实网络身份
variable "nixos_ami_owner" { type = string }
variable "nixos_ami_name" { type = string }
variable "admin_cidr" { type = string }

# NixOS 配置(仅在已确认 AMI 支持这一 user_data 格式时使用)
locals {
  nixos_config = <<-EOT
    { config, pkgs, ... }: {
      imports = [
        <nixpkgs/nixos/modules/virtualisation/amazon-image.nix>
      ];
      
      ec2.hvm = true;
      
      networking.hostName = "nixos-server";
      
      services.openssh = {
        enable = true;
        settings.PermitRootLogin = "no";
      };
      
      users.users.admin = {
        isNormalUser = true;
        extraGroups = [ "wheel" ]; # docker 组具有接近 root 的权限,按实际需求另行授予
        openssh.authorizedKeys.keys = [
          "${trimspace(file(pathexpand("~/.ssh/id_ed25519.pub")))}"
        ];
      };
      
      security.sudo.wheelNeedsPassword = false;
      
      services.nginx = {
        enable = true;
        virtualHosts.default = {
          root = "$${pkgs.writeTextDir "index.html" "NixOS web service"}";
        };
      };
      
      system.stateVersion = "24.05";
    }
  EOT
}

# EC2 实例
resource "aws_instance" "nixos" {
  ami                    = data.aws_ami.nixos.id
  instance_type          = "t3.medium"
  subnet_id              = aws_subnet.nixos_subnet.id
  vpc_security_group_ids = [aws_security_group.nixos_sg.id]
  key_name               = aws_key_pair.deployer.key_name
  
  user_data = local.nixos_config
  
  root_block_device {
    volume_size = 20
    volume_type = "gp3"
  }
  
  tags = {
    Name = "nixos-server"
  }
}

# 输出
output "public_ip" {
  value = aws_instance.nixos.public_ip
}

output "ssh_command" {
  value = "ssh admin@${aws_instance.nixos.public_ip}"
}

In the Nix expression, $${...} is Terraform's escape for a literal ${...}, which is ultimately evaluated by Nix. Terraform's ${trimspace(file(...))}, by contrast, is interpolated during the Terraform stage. Do not mix the two interpolation layers. An AMI's EC2 user-data mechanism depends on the image configuration: not every AMI automatically applies a supplied Nix configuration. If support has not been verified, remove user_data and deploy the built system closure over SSH. Put only public configuration in publicly exposed user-data and state, never private keys, API tokens, or passwords.

The security.sudo.wheelNeedsPassword = false setting above permits passwordless sudo for the wheel group. It is appropriate only under a reviewed policy for automated administrator accounts. Stricter environments should restrict the allowed commands or use a deployment method with interactive authentication.

Usage#

1. Initialize

terraform init

2. Plan

terraform plan -out=deployment.tfplan

3. Apply

init initializes the provider/backend; plan calculates proposed changes; only apply actually creates, modifies, or destroys cloud resources and may incur charges. Review the saved plan, target account, and network rules first, then apply that same plan.

terraform apply deployment.tfplan

4. Connect to the server

ssh admin@$(terraform output -raw public_ip)

Managing configuration with Flakes#

Terraform manages cloud resources, while the flake independently defines the system. There is no universal hashicorp/terraform-provider-nixos flake-module interface that can simply be dropped in. Dedicated third-party integrations are an option, but verify each one's actual repository, interface, and maintenance status. Here, a conventional NixOS flake makes the division of responsibilities explicit:

# flake.nix
{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; # 历史例子,实际选受支持版本
  outputs = { self, nixpkgs }: {
    nixosConfigurations.server = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        ./configuration.nix
        (nixpkgs + "/nixos/modules/virtualisation/amazon-image.nix")
      ];
    };
  };
}
# 构建 closure,不激活云主机
nix build .#nixosConfigurations.server.config.system.build.toplevel

# 已确认 SSH 用户与权限后,单独进行远端临时激活;24.05 参数示例
nixos-rebuild test --flake .#server \
  --target-host admin@<CLOUD_HOST> --use-remote-sudo
# 通过实际运行与 SSH 检查后,才采用 switch 持久部署

The historical official nix.dev tutorial uses tweag/terraform-nixos and its aws_image_nixos and deploy_nixos modules. It illustrates the same division of responsibilities, but its old release and historical revision do not guarantee current-version compatibility. If client and target architectures differ, an appropriate Linux builder or cross-compilation setup is also needed.

Managing multiple environments#

Development / testing / production

# environments/dev.tfvars
instance_type = "t3.small"
server_count  = 1

# environments/prod.tfvars  
instance_type = "t3.xlarge"
server_count  = 3

These variables must first be declared in the main configuration and actually used by resources; otherwise, tfvars will not automatically change the instance type or count. Use separate state/backends and credentials for each environment.

Usage:

terraform plan -var-file=environments/prod.tfvars -out=prod.tfplan
# 审阅计划后实际应用
terraform apply prod.tfplan

Comparison with NixOps#

Dimension Terraform NixOS deployment tools such as NixOps
Cloud-resource management Define resources such as VMs, networks, and storage through cloud providers Integrate resources through the tool version and its backends/plugins; capabilities vary with the ecosystem
NixOS configuration Connect through verified user-data support, external modules, or a separate deployment workflow Focus on NixOS closures and system deployment; the tool's interface determines the integration method
State management State, locking, and remote backends require separate design Each tool has its own state and deployment-management approach
Team collaboration Depends on the backend, permissions, approvals, and team processes Also depends on the tool ecosystem, shared state, and permission workflows
Selection criteria Existing infrastructure providers and cloud-governance requirements NixOS configuration, node management, and the team's familiarity with the tools

This is not a ranking of tool stability or collaboration capabilities. Before choosing, verify the tool version, cloud backend, and recovery workflow you will use.

Best practices#

  1. Separate infrastructure from configuration: Terraform manages cloud resources; NixOS manages the system
  2. Use remote state: Configure an S3 backend to store Terraform state
  3. Pin versions: Pin the NixOS version and AMI ID
  4. Manage secrets: Use AWS Secrets Manager or HashiCorp Vault
# 使用远程状态存储
terraform {
  backend "s3" {
    bucket = "<STATE_BUCKET>"
    key    = "nixos/terraform.tfstate"
    region = "us-west-2"
    encrypt = true
  }
}

State may contain sensitive values. Marking a value sensitive primarily controls how it is displayed; it does not remove the data from state. A remote backend also needs access controls, encryption, versioning, concurrency locks, and recovery procedures. Do not upload state or plan artifacts as if they were ordinary public source code. Roll back cloud resources using reviewed older configuration and a new plan, rather than assuming that a NixOS generation rollback will restore cloud networks or disks.

References & further reading

  1. nix.dev historical Terraform tutorial ↗
  2. AWS provider EC2 instance ↗
  3. AWS provider route table ↗
  4. Terraform sensitive data ↗

Adapted from technical notes. Examples use generic names.

Continue along a reading path

NixOS virtualization and deployment

  1. 01Validating NixOS configuration with QEMU
  2. 02Building a bootable NixOS ISO
  3. 03Building and running container images with dockerTools
  4. 04Deploying NixOS over SSH
  5. 05Dividing deployment responsibilities between Terraform and NixOS
  6. 06Designing layered desktop and development environments
CONTINUE READING / NEXT ARTICLEDesigning layered desktop and development environments

Choose between the host, Flatpak, Distrobox, virtual machines, and servers based on dependency and permission boundaries.