IaaC guide with NixOS on EC2
I've been refining the way I do IaaC and immutable infrastructure for years. Before I switched to NixOS, I had enormous Ansible playbooks that configured everything imperatively.
My playbooks are much simpler now, with most of the configuration declaratively
specified in configuration.nix
. Here are the main tools in my current
workflow:
- Packer (for building AMI images)
- Ansible (for handling credentials)
- NixOS (for setting up applications within the instance)
- Cloudformation (for deploying the AMI images)
The rest of this article is a walkthrough of how I do IaaC when I need to deploy something to AWS. I stay away from the more niche AWS services like Lambda, Amplify, ECS, etc... because it's harder to migrate an application away from those specialized services. I like having the ability to self host anything if I wanted, so I stick to EC2.
Step 1: repository structure
I'll use the example of this very website you're reading (www.fossable.org
).
First of all, the IaaC repo is always named according to the domain it
configures:
fossable.org
├── ansible.cfg
├── group_vars
│ └── all
│ └── vault
├── hosts
│ └── www.fossable.org
│ ├── cloudformation.yml
│ ├── configuration.nix
│ ├── playbook.yml
│ └── www.pkr.hcl
└── inventory.yml
group_vars/all/vault
This YAML file is encrypted in the repo and contains the secrets necessary to deploy the hosts.
ansible-vault edit group_vars/all/vault
hosts/www.fossable.org/www.pkr.hcl
This configuration file tells packer how to build the image.
packer {
required_plugins {
amazon = {
version = ">= 0.0.2"
source = "github.com/hashicorp/amazon"
}
ansible = {
source = "github.com/hashicorp/ansible"
version = "~> 1"
}
}
}
data "amazon-ami" "nixos" {
filters = {
name = "nixos/24.11.*-aarch64-linux"
root-device-type = "ebs"
virtualization-type = "hvm"
}
owners = ["427812963091"]
most_recent = true
region = "us-east-2"
}
source "amazon-ebs" "www" {
ami_name = "www.fossable.org-${formatdate("YYYY-MM-DD-hhmmss", timestamp())}"
instance_type = "t4g.large"
region = "us-east-2"
source_ami = "${data.amazon-ami.nixos.id}"
ssh_username = "root"
}
build {
sources = ["source.amazon-ebs.mail"]
# Add a bootstrap configuration to get python
provisioner "file" {
destination = "/etc/nixos/configuration.nix"
content = <<EOT
{ pkgs, ... }:
{
imports = [ <nixpkgs/nixos/modules/virtualisation/amazon-image.nix> ];
ec2.hvm = true;
environment.systemPackages = with pkgs;
[ (python3.withPackages (ps: with ps; [ requests cryptography psutil ])) ];
}
EOT
}
provisioner "shell" {
inline = ["nixos-rebuild switch"]
}
# Install final configuration
provisioner "ansible" {
playbook_file = "hosts/www.fossable.org/playbook.yml"
inventory_file = "inventory.yml"
use_proxy = false
extra_arguments = ["--extra-vars", "ansible_host=${build.Host}", "--extra-vars", "ansible_user=root", "--vault-password-file", ".password", "--diff"]
}
# Cleanup
provisioner "shell" {
inline = ["nix-collect-garbage -d", "rm -rf /etc/ec2-metadata /etc/ssh/ssh_host_* /root/.ssh"]
}
}
hosts/www.fossable.org/configuration.nix
This file configures the instance with whatever application it needs to run.
{ pkgs, ... }:
{
imports = [ <nixpkgs/nixos/modules/virtualisation/amazon-image.nix> ];
ec2.hvm = true;
# Don't replace configuration.nix with user data on boot
systemd.services.amazon-init.enable = false;
i18n.defaultLocale = "en_US.UTF-8";
networking = { hostName = "www"; };
networking.firewall.allowedTCPPorts = [ 80 443 ];
nix.optimise.automatic = true;
nix.gc = {
automatic = true;
options = "--delete-older-than 30d";
};
users = {
mutableUsers = false;
users = {
root = {
hashedPassword = "{{ hosts.www.users.root.password | password_hash }}";
};
};
};
# Never change!
system.stateVersion = "24.11";
}
Step 2: deploy
Deployment can be done from a CI pipeline or from the command line. For example, to build a new image:
packer build hosts/www.fossable.org/www.pkr.hcl
And to deploy:
aws cloudformation create-stack --stack-name www-fossable-org --template-body file://$(realpath hosts/www.fossable.org/cloudformation.yml)
Or to update a running host:
ansible-playbook -i inventory.yml --ask-vault-pass hosts/www.fossable.org/playbook.yml