├── .editorconfig ├── .env.example ├── .envrc ├── .gitignore ├── README.md ├── flake.lock ├── flake.nix ├── main.tf ├── scripts ├── deploy.sh ├── get-ip-addresses.jq └── run.sh ├── secrets └── .gitkeep ├── terraform.tfvars └── variables.tf /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | max_line_length = 100 12 | 13 | [{go.mod,go.sum,*.go}] 14 | indent_style = tab 15 | indent_size = 4 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DIGITALOCEAN_TOKEN="" 2 | TF_VAR_do_token="${DIGITALOCEAN_TOKEN}" 3 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | dotenv_if_exists 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Terraform artifacts 2 | .terraform/ 3 | .terraform.lock.hcl 4 | terraform.tfstate* 5 | .terraform.tfstate.lock.info 6 | 7 | # Secrets 8 | .env 9 | secrets/nix_copy_droplet* 10 | 11 | # Graphviz artifacts 12 | graph.svg 13 | 14 | # Nix artifacts 15 | result 16 | 17 | 18 | # Added by cargo 19 | 20 | /target 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `nix copy` deployment example 2 | 3 | > **Warning**: this example project only works on `x86_64-linux` machines. 4 | 5 | This repo provides an example of using [`nix copy`][nix-copy] as a deployment tool. This Nix utility enables you to copy Nix [closures] from one machine to another, which provides a declarative alternative to tools like [rsync]. 6 | 7 | This example involves standing up some [DigitalOcean][do] droplets using [Terraform] and then `nix copy`ing a Nix closure to those machines and running the copied program (in this case [ponysay]). It isn't a particularly realistic example, of course, but it does suggest how you could use a setup like this to copy and start up long-running processes serving real traffic. 8 | 9 | The Terraform logic is in [`main.tf`](./main.tf) while the deployment script is in [`scripts/deploy.sh`](./scripts/deploy.sh). 10 | 11 | One of the core benefits of the approach you see here is that `nix copy` is [Nix store][store] aware. It copies only those dependencies that aren't yet present in the target machine's Nix store, while many tools need to install everything from scratch every time. In this example, each droplet has a "fresh" Nix store and the full ponysay closure needs to be copied over; but in a scenario where you were updating a target machine with some dependencies already present, the `nix copy` operation may be substantially more efficient than an equivalent non-Nix approach. 12 | 13 | ## Running this example 14 | 15 | ### Setup 16 | 17 | First, [install] Nix using the [Determinate Nix Installer][nix-installer]: 18 | 19 | ```shell 20 | curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install 21 | ``` 22 | 23 | Then create a [DigitalOcean][do] account and get an API key. 24 | 25 | Then: 26 | 27 | ```shell 28 | # Provide environment variables 29 | cp .env.example .env 30 | 31 | # Set a value for DIGITALOCEAN_TOKEN 32 | 33 | # Load Nix development environment 34 | direnv allow # or `nix develop` if you don't have direnv installed 35 | 36 | # Generate SSH keys 37 | ssh-keygen -f ./secrets/nix_copy_droplet -N '' 38 | 39 | # Add private key to SSH agent 40 | eval "$(ssh-agent -s)" 41 | ssh-add ./secrets/nix_copy_droplet 42 | 43 | # Create DigitalOcean droplets 44 | terraform apply -auto-approve 45 | ``` 46 | 47 | ### Deploy 48 | 49 | With the droplets deployed, you can run the [deployment script](./scripts/deploy.sh): 50 | 51 | ```shell 52 | ./scripts/deploy.sh 53 | ``` 54 | 55 | This script does a few things: 56 | 57 | - It gathers a list of droplet IPs from the [Terraform] state file 58 | - It uses SSH to do a few things on each droplet: 59 | - It installs Nix using the [Determinate Nix Installer][nix-installer] 60 | - It copies the closure for [ponysay] to the target machine's [Nix store][store] 61 | - It adds the package to the target machine's user profile 62 | - It pipes the string `Hello from nix copy!` to [ponysay] on the target machine, which outputs a lovely equine greeting 63 | 64 | ### Run 65 | 66 | Once you've deployed ponysay on each machine, you can run it on each of them: 67 | 68 | ```shell 69 | ./scripts/run.sh 70 | ``` 71 | 72 | This script uses SSH to pipe the string `Hello from nix copy!` to [ponysay] on the target machine, which outputs a lovely equine greeting. A different horse each time! 73 | 74 | 75 | ### Tear down 76 | 77 | Once you've run the example, spin the droplets down: 78 | 79 | ```shell 80 | terraform apply -destroy -auto-approve 81 | ``` 82 | 83 | [closures]: https://zero-to-nix.com/concepts/closures 84 | [do]: https://digitalocean.com 85 | [go]: https://go.dev 86 | [install]: https://zero-to-nix.com/start/install 87 | [nix-copy]: https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-copy 88 | [nix-installer]: https://github.com/DeterminateSystems/nix-installer 89 | [ponysay]: https://github.com/erkin/ponysay 90 | [rsync]: https://linux.die.net/man/1/rsync 91 | [store]: https://zero-to-nix.com/concepts/nix-store 92 | [terraform]: https://terraform.io 93 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1677249740, 6 | "narHash": "sha256-1Pt/IeBLGAfr5KNankKociYxF6eIo6KfMOQLCY+CBjE=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "38f87b67bc320feeedeeb4e6912a3905f176b9ab", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "id": "nixpkgs", 14 | "ref": "release-22.11", 15 | "type": "indirect" 16 | } 17 | }, 18 | "root": { 19 | "inputs": { 20 | "nixpkgs": "nixpkgs" 21 | } 22 | } 23 | }, 24 | "root": "root", 25 | "version": 7 26 | } 27 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "`nix copy` deployment example"; 3 | 4 | inputs = { 5 | # We'll tie Nixpkgs to a stable release 6 | nixpkgs.url = "nixpkgs/release-22.11"; 7 | }; 8 | 9 | outputs = 10 | { self 11 | , nixpkgs 12 | }: 13 | 14 | let 15 | allSystems = [ 16 | "x86_64-linux" # 64-bit Intel/AMD Linux 17 | ]; 18 | 19 | forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f { 20 | pkgs = import nixpkgs { inherit system; }; 21 | }); 22 | in 23 | { 24 | devShells = forAllSystems 25 | ({ pkgs }: { 26 | default = pkgs.mkShell 27 | { 28 | packages = with pkgs; [ 29 | jq 30 | openssh 31 | shellcheck 32 | terraform 33 | ]; 34 | }; 35 | }); 36 | 37 | packages = forAllSystems ({ pkgs }: { 38 | # We'll keep it simple by using a package from Nixpkgs, but this could be any package 39 | # we want: a web server, a database server, a Bitcoin miner (ew), etc. 40 | default = pkgs.ponysay; 41 | }); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "1.3.6" 3 | required_providers { 4 | digitalocean = { 5 | source = "digitalocean/digitalocean" 6 | version = "2.26.0" 7 | } 8 | } 9 | } 10 | 11 | provider "digitalocean" { 12 | token = var.do_token 13 | } 14 | 15 | resource "digitalocean_ssh_key" "default" { 16 | name = "nix-copy-ssh-key" 17 | public_key = file("./secrets/nix_copy_droplet.pub") 18 | } 19 | 20 | resource "digitalocean_droplet" "default" { 21 | image = var.do_droplet_image 22 | size = var.do_droplet_size 23 | name = "nix-copy-${count.index}" 24 | count = var.do_num_droplets 25 | ssh_keys = [digitalocean_ssh_key.default.fingerprint] 26 | } 27 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Get root project directory 5 | ROOT=$(git rev-parse --show-toplevel) 6 | 7 | NIX_INSTALLATION_SCRIPT="curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install --no-confirm" 8 | 9 | # Terraform state file 10 | TF_STATE="${ROOT}/terraform.tfstate" 11 | 12 | # Check for existence of a Terraform state file 13 | if [ ! -f "$TF_STATE" ]; then 14 | echo "No Terraform state file at $TF_STATE. Have you run 'terraform apply'?" 15 | exit 1 16 | fi 17 | 18 | # The system of the target host (Ubuntu 22.04) 19 | SYSTEM="x86_64-linux" 20 | 21 | # The desired package 22 | FLAKE_PATH=".#packages.${SYSTEM}.default" 23 | 24 | # Get the Nix store path 25 | NIX_STORE_PATH="$(nix path-info $FLAKE_PATH)" 26 | 27 | # An array of droplet IPs drawn from the Terraform state file produced by `terraform apply` 28 | DROPLET_IPS=$(jq -f "${ROOT}"/scripts/get-ip-addresses.jq < "${TF_STATE}" | tr -d '"') 29 | 30 | echo "Starting deployment" 31 | 32 | # Run the deployment script on each droplet 33 | for ip in $DROPLET_IPS; do 34 | target="root@${ip}" 35 | run="ssh -o StrictHostKeyChecking=no ${target}" 36 | 37 | echo "Installing Nix on droplet at ${ip}" 38 | $run "${NIX_INSTALLATION_SCRIPT}" 39 | 40 | echo "Copying ${FLAKE_PATH} to ${target}" 41 | nix copy \ 42 | --to ssh-ng://root@"${ip}" \ 43 | $FLAKE_PATH 44 | 45 | echo "Installing ${FLAKE_PATH} to profile" 46 | $run nix profile install "${NIX_STORE_PATH}" 47 | done 48 | -------------------------------------------------------------------------------- /scripts/get-ip-addresses.jq: -------------------------------------------------------------------------------- 1 | .resources[] | select(.type == "digitalocean_droplet") | .instances[].attributes.ipv4_address 2 | -------------------------------------------------------------------------------- /scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Get root project directory 5 | ROOT=$(git rev-parse --show-toplevel) 6 | 7 | # Terraform state file 8 | TF_STATE="${ROOT}/terraform.tfstate" 9 | 10 | # Check for existence of a Terraform state file 11 | if [ ! -f "$TF_STATE" ]; then 12 | echo "No Terraform state file at $TF_STATE. Have you run 'terraform apply'?" 13 | exit 1 14 | fi 15 | 16 | # An array of droplet IPs drawn from the Terraform state file produced by `terraform apply` 17 | DROPLET_IPS=$(jq -f "${ROOT}"/scripts/get-ip-addresses.jq < "${TF_STATE}" | tr -d '"') 18 | 19 | # Run the deployment script on each droplet 20 | for ip in $DROPLET_IPS; do 21 | target="root@${ip}" 22 | run="ssh -o StrictHostKeyChecking=no ${target}" 23 | 24 | $run "echo 'Hello from nix copy!' | ponysay" 25 | done 26 | -------------------------------------------------------------------------------- /secrets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeterminateSystems/nix-copy-deploy/e2345484773749f95760747d7c3da75558c1aa6c/secrets/.gitkeep -------------------------------------------------------------------------------- /terraform.tfvars: -------------------------------------------------------------------------------- 1 | // do_token is set using the TF_VAR_do_token environment variable in the .env file 2 | 3 | do_num_droplets = 1 4 | 5 | do_droplet_size = "s-1vcpu-1gb" 6 | 7 | do_droplet_image = "ubuntu-22-04-x64" 8 | -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | variable "do_token" { 2 | type = string 3 | description = "DigitalOcean API token" 4 | } 5 | 6 | variable "do_num_droplets" { 7 | type = number 8 | description = "The number of droplets to deploy" 9 | } 10 | 11 | variable "do_droplet_size" { 12 | type = string 13 | description = "The machine size for the droplets" 14 | } 15 | 16 | variable "do_droplet_image" { 17 | type = string 18 | description = "The DigitalOcean image for the deployed droplets" 19 | } 20 | --------------------------------------------------------------------------------