diff --git a/deploy_nixos.py b/deploy_nixos.py
new file mode 100644
index 0000000..e623f44
--- /dev/null
+++ b/deploy_nixos.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+
+import os
+from contextlib import contextmanager
+from typing import List, Dict, Tuple, IO, Iterator, Optional, Callable, Any
+from threading import Thread
+import subprocess
+
+
+@contextmanager
+def pipe() -> Iterator[Tuple[IO[str], IO[str]]]:
+    (pipe_r, pipe_w) = os.pipe()
+    read_end = os.fdopen(pipe_r, "r")
+    write_end = os.fdopen(pipe_w, "w")
+    try:
+        yield (read_end, write_end)
+    finally:
+        read_end.close()
+        write_end.close()
+
+
+class DeployHost:
+    def __init__(
+        self,
+        host: str,
+        user: str = "root",
+        port: int = 22,
+        forward_agent: bool = False,
+        command_prefix: Optional[str] = None,
+        meta: Dict[str, Any] = {},
+    ) -> None:
+        self.host = host
+        self.user = user
+        self.port = port
+        if command_prefix:
+            self.command_prefix = command_prefix
+        else:
+            self.command_prefix = host
+        self.forward_agent = forward_agent
+        self.meta = meta
+
+    def _prefix_output(self, fd: IO[str]) -> None:
+        for line in fd:
+            print(f"[{self.command_prefix}] {line}", end="")
+
+    def run_local(self, cmd: str) -> int:
+        print(f"[{self.command_prefix}] {cmd}")
+        with pipe() as (read_fd, write_fd):
+            with subprocess.Popen(
+                cmd, text=True, shell=True, stdout=write_fd, stderr=write_fd
+            ) as p:
+                write_fd.close()
+                self._prefix_output(read_fd)
+                return p.wait()
+
+    def run(self, cmd: str) -> int:
+        print(f"[{self.command_prefix}] {cmd}")
+        with pipe() as (read_fd, write_fd):
+            ssh_opts = ["-A"] if self.forward_agent else []
+            with subprocess.Popen(
+                ["ssh", f"{self.user}@{self.host}", "-p", str(self.port)]
+                + ssh_opts
+                + ["--", cmd],
+                stdout=write_fd,
+                stderr=write_fd,
+                text=True,
+            ) as p:
+                write_fd.close()
+                self._prefix_output(read_fd)
+                return p.wait()
+
+DeployResults = List[Tuple[DeployHost, int]]
+
+class DeployGroup:
+    def __init__(self, hosts: List[DeployHost]) -> None:
+        self.hosts = hosts
+
+    def _run_local(self, cmd: str, host: DeployHost, results: DeployResults) -> None:
+        results.append((host, host.run_local(cmd)))
+
+    def _run_remote(self, cmd: str, host: DeployHost, results: DeployResults) -> None:
+        results.append((host, host.run(cmd)))
+
+    def _run(
+        self, cmd: str, local: bool = False
+    ) -> DeployResults:
+        results: DeployResults = []
+        threads = []
+        for host in self.hosts:
+            fn = self._run_local if local else self._run_remote
+            thread = Thread(
+                target=fn,
+                kwargs=dict(results=results, cmd=cmd, host=host),
+            )
+            thread.start()
+            threads.append(thread)
+
+        for thread in threads:
+            thread.join()
+
+        return results
+
+    def run(self, cmd: str) -> DeployResults:
+        return self._run(cmd)
+
+    def run_local(self, cmd: str) -> DeployResults:
+        return self._run(cmd, local=True)
+
+    def run_function(self, func: Callable) -> None:
+        threads = []
+        for host in self.hosts:
+            thread = Thread(
+                target=func,
+                args=(host,),
+            )
+            threads.append(thread)
+
+        for thread in threads:
+            thread.start()
+
+        for thread in threads:
+            thread.join()
diff --git a/nix/overlays.nix b/nix/overlays.nix
index cfe7bf5..11d4281 100644
--- a/nix/overlays.nix
+++ b/nix/overlays.nix
@@ -5,7 +5,9 @@ let
       niv
       sops
       morph
+      rsync
       sources;
+    inherit (pkgs.python3.pkgs) invoke;
 
     terraform = pkgs.terraform_1_0.withPlugins (
       p: [
diff --git a/shell.nix b/shell.nix
index 39bfed3..de9b3ec 100644
--- a/shell.nix
+++ b/shell.nix
@@ -18,6 +18,8 @@ pkgs.mkShell {
     terraform
     sops
     morph
+    invoke
+    rsync
 
     (pkgs.callPackage sources.sops-nix {}).sops-import-keys-hook
   ];
diff --git a/tasks.py b/tasks.py
new file mode 100644
index 0000000..e3d4f55
--- /dev/null
+++ b/tasks.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+
+from invoke import task
+
+import sys
+from typing import List
+from deploy_nixos import DeployHost, DeployGroup
+
+
+def deploy_nixos(hosts: List[DeployHost]) -> None:
+    """
+    Deploy to all hosts in parallel
+    """
+    g = DeployGroup(hosts)
+    def deploy(h: DeployHost) -> None:
+        h.run_local(
+            f"rsync --exclude='.git/' -vaF --delete -e ssh . {h.user}@{h.host}:/etc/nixos",
+        )
+
+        config = f"/etc/nixos/{h.host.replace('.nix-community.org', '')}/configuration.nix"
+        h.run(f"nixos-rebuild switch -I nixos-config={config} -I nixpkgs=$(nix-instantiate --eval -E '(import /etc/nixos/nix {{}}).path')")
+    g.run_function(deploy)
+
+
+
+def get_hosts(hosts: str):
+    if hosts == "":
+        return [DeployHost(f"build{n + 1}.nix-community.org") for n in range(4)]
+
+    return [DeployHost(f"{h}.nix-community.org") for h in hosts.split(",")]
+
+
+@task
+def deploy(c, hosts = ""):
+    """
+    Deploy to all servers. Use inv deploy --host build01 to deploy to a single server
+    """
+    deploy_nixos(get_hosts(hosts))
+
+
+def wait_for_port(host: str, port: int, shutdown: bool = False) -> None:
+    import socket, time
+
+    while True:
+        try:
+            with socket.create_connection((host, port), timeout=1):
+                if shutdown:
+                    time.sleep(1)
+                    sys.stdout.write(".")
+                    sys.stdout.flush()
+                else:
+                    break
+        except OSError as ex:
+            if shutdown:
+                break
+            else:
+                time.sleep(0.01)
+                sys.stdout.write(".")
+                sys.stdout.flush()
+
+
+@task
+def reboot(c, hosts=""):
+    """
+    Reboot hosts. example usage: inv reboot --hosts build01,build02
+    """
+    deploy_hosts = get_hosts(hosts)
+    for h in deploy_hosts:
+        g = DeployGroup([h])
+        g.run("reboot &")
+
+        print(f"Wait for {h.host} to shutdown", end="")
+        sys.stdout.flush()
+        wait_for_port(h.host, h.port, shutdown=True)
+        print("")
+
+        print(f"Wait for {h.host} to start", end="")
+        sys.stdout.flush()
+        wait_for_port(h.host, h.port)
+        print("")
+
+
+@task
+def cleanup_gcroots(c, hosts=""):
+    g = DeployGroup(get_hosts(hosts))
+    g.run("find /nix/var/nix/gcroots/auto -type s -delete")
+    g.run("systemctl restart nix-gc")