Coding Agent VMs on NixOS with microvm.nix

Michael Stapelberg

NixOS에서 microvm.nix로 코딩 에이전트용 VM 만들기

저는 코딩 에이전트가 프로그램 코드를 다루는 모든 작업에서 — 프로그램 아키텍처를 파악하거나, 버그를 진단하거나, 개념 증명을 개발하는 등 — 유용한 도구라는 점을 인정하게 되었습니다. 사용 사례에 따라 에이전트가 실행하려는 명령을 하나하나 검토하는 일은 금방 지루하고 시간도 많이 드는 작업이 됩니다. 검토 없이 코딩 에이전트를 안전하게 실행하기 위해 저는 에이전트가 제 개인 파일에 접근할 수 없고, 설령 에이전트가 악성코드에 감염되더라도 큰 문제가 되지 않는 가상 머신(VM) 솔루션을 원했습니다. 그냥 VM을 버리고 새로 시작하면 되니까요.

상태를 유지하는 VM을 구축해 두고 필요할 때마다 재설치하는 방식(정말 번거롭습니다!) 대신, 저는 호스트와 명시적으로 공유한 것을 제외하고는 아무것도 디스크에 남지 않는 휘발성(ephemeral) VM 모델을 선호합니다.

microvm.nix 프로젝트를 이용하면 NixOS에서 이러한 VM을 손쉽게 만들 수 있으며, 이 글에서는 제가 VM을 설정하는 방법을 소개합니다.

참고 자료

NixOS를 처음 접하신다면 NixOS 위키백과 페이지nixos.org를 참고해 보시기 바랍니다. 저는 2025년에 Nix로 전환한 이유에 대해 발표한 적이 있고 Nix에 관한 블로그 글을 몇 편 게시했습니다.

AI 에이전트의 위협 모델을 이해하려면 사이먼 윌리슨(Simon Willison)의 “The lethal trifecta for AI agents: private data, untrusted content, and external communication”(2025년 6월)을 읽어보시기 바랍니다. 이 글에서 취하는 접근 방식은 위협 모델에서 ‘비공개 데이터’ 부분을 제거하는 것입니다.

샌드박싱 분야 전반에 대해 알고 싶다면 루이스 카르도소(Luis Cardoso)의 “A field guide to sandboxes for AI”(2026년 1월)를 참고하시기 바랍니다. 이 글에서는 여러 솔루션을 비교하지 않고, 하나의 가능한 방법만 보여드리겠습니다.

마지막으로, 직접 샌드박싱 인프라를 구축하거나 운영하고 싶지 않을 수도 있습니다. 좋은 소식은 샌드박싱이 화두가 되면서 이러한 수요를 해결하는 상용 서비스가 많이 등장하고 있다는 점입니다. 예를 들어 데이비드 크로쇼(David Crawshaw)와 조시 블리처 스나이더(Josh Bleecher Snyder)(두 분 모두 Go 커뮤니티에서 알게 된 분들입니다)는 최근 에이전트 친화적인 VM 호스팅 서비스인 exe.dev를 출시했습니다. 또 다른 예로 Sprites를 출시한 Fly.io가 있습니다.

microvm.nix 설정하기

바로 본론으로 들어가 보겠습니다! 다음 섹션에서는 제가 설정을 어떻게 구성했는지 단계별로 설명합니다.

1단계: 네트워크 준비

먼저, IP 주소 범위로 192.168.33.1/24를 사용하고 eno1 네트워크 인터페이스를 통해 NAT를 수행하는 새로운 microbr 브리지를 만들었습니다. 모든 microvm* 인터페이스는 이 브리지에 추가됩니다:

systemd.network.netdevs."20-microbr".netdevConfig = {
  Kind = "bridge";
  Name = "microbr";
};

systemd.network.networks."20-microbr" = {
  matchConfig.Name = "microbr";
  addresses = [ { Address = "192.168.83.1/24"; } ];
  networkConfig = {
    ConfigureWithoutCarrier = true;
  };
};

systemd.network.networks."21-microvm-tap" = {
  matchConfig.Name = "microvm*";
  networkConfig.Bridge = "microbr";
};

networking.nat = {
  enable = true;
  internalInterfaces = [ "microbr" ];
  externalInterface = "eno1";
};

2단계: flake.nix

그 다음, 제 flake.nixmicrovm 모듈을 새로운 input으로 추가하고(자세한 내용은 microvm.nix 문서를 참고하세요) 제 PC(midna)의 NixOS 설정에서 microvm.nixosModules.host 모듈을 활성화했습니다. 또한 모든 VM을 선언하는 새로운 microvm.nix 파일을 만들었습니다. 제 flake.nix는 다음과 같습니다:

{
  inputs = {
    nixpkgs = {
      url = "github:nixos/nixpkgs/nixos-25.11";
    };
    # For more recent claude-code
    nixpkgs-unstable = {
      url = "github:nixos/nixpkgs/nixos-unstable";
    };
    stapelbergnix = {
      url = "github:stapelberg/nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    zkjnastools = {
      url = "github:stapelberg/zkj-nas-tools";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    microvm = {
      url = "github:microvm-nix/microvm.nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    home-manager = {
      url = "github:nix-community/home-manager/release-25.11";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    configfiles = {
      url = "github:stapelberg/configfiles";
      flake = false; # repo is not a flake
    };
  };

  outputs =
    {
      self,
      stapelbergnix,
      zkjnastools,
      nixpkgs,
      nixpkgs-unstable,
      microvm,
      home-manager,
      configfiles,
    }@inputs:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs {
        inherit system;
        config.allowUnfree = false;
      };
      pkgs-unstable = import nixpkgs-unstable {
        inherit system;
        config.allowUnfree = true;
      };
    in
    {
      nixosConfigurations = {
        midna = nixpkgs.lib.nixosSystem {
          system = "x86_64-linux";
          specialArgs = { inherit inputs; };
          modules = [
            (import ./configuration.nix)
            stapelbergnix.lib.userSettings
            # Use systemd for network configuration
            stapelbergnix.lib.systemdNetwork
            # Use systemd-boot as bootloader
            stapelbergnix.lib.systemdBoot
            # Run prometheus node exporter in tailnet
            stapelbergnix.lib.prometheusNode
            zkjnastools.nixosModules.zkjbackup
            microvm.nixosModules.host
            ./microvm.nix
          ];
        };
      };
    };
}

3단계: microvm.nix

다음 microvm.nix는 두 개의 microVM을 선언합니다. 하나는 더 알아보고 싶었던 Emacs용이고, 다른 하나는 제가 잘 알고 있어 Claude의 역량을 파악하는 데 활용할 수 있는 코드베이스인 Go Protobuf용입니다:

{
  config,
  lib,
  pkgs,
  inputs,
  ...
}:

let
  inherit (inputs)
    nixpkgs-unstable
    stapelbergnix
    microvm
    configfiles
    home-manager
    ;

  microvmBase = import ./microvm-base.nix;
in
{
  microvm.vms.emacsvm = {
    autostart = false;
    config = {
      imports = [
        stapelbergnix.lib.userSettings
        microvm.nixosModules.microvm
        (microvmBase {
          hostName = "emacsvm";
          ipAddress = "192.168.83.6";
          tapId = "microvm4";
          mac = "02:00:00:00:00:05";
          workspace = "/home/michael/microvm/emacs";
          inherit
            nixpkgs-unstable
            configfiles
            home-manager
            stapelbergnix
            ;
        })
        ./microvms/emacs.nix
      ];
    };
  };

  microvm.vms.goprotobufvm = {
    autostart = false;
    config = {
      imports = [
        stapelbergnix.lib.userSettings
        microvm.nixosModules.microvm
        (microvmBase {
          hostName = "goprotobufvm";
          ipAddress = "192.168.83.7";
          tapId = "microvm5";
          mac = "02:00:00:00:00:06";
          workspace = "/home/michael/microvm/goprotobuf";
          inherit
            nixpkgs-unstable
            configfiles
            home-manager
            stapelbergnix
            ;
          extraZshInit = ''
            export GOPATH=$HOME/go
            export PATH=$GOPATH/bin:$PATH
          '';
        })
        ./microvms/goprotobuf.nix
      ];
    };
  };
}

4단계: microvm-base.nix

microvm-base.nix 모듈은 이러한 매개변수를 받아 다음을 선언합니다:

  • 네트워크 설정: 저는 systemd-networkd(8)systemd-resolved(8)을 선호합니다.
  • 다음을 위한 공유 디렉터리:
    • 워크스페이스 디렉터리(예: ~/microvm/emacs)
    • 호스트의 Nix 스토어. VM이 캐시에서 소프트웨어에 접근할 수 있도록 합니다(자주 사용됩니다)
    • 이 VM의 SSH 호스트 키
    • ~/claude-microvm. microVM에서만 사용하는 별도의 상태 디렉터리입니다.
  • 8GB 디스크 오버레이(var.img). /var/lib/microvms/<name>에 저장됩니다
  • 하이퍼바이저로 cloud-hypervisor(QEMU도 잘 작동합니다!)를 사용하며, vCPU 8개와 RAM 4GB를 할당합니다.
  • systemd가 /nix/store를 언마운트하려고 시도할 때 발생하는 교착 상태(deadlock)를 피하기 위한 우회 방법입니다.
전체 microvm-base.nix 코드 펼치기
{
  hostName,
  ipAddress,
  tapId,
  mac,
  workspace,
  nixpkgs-unstable,
  configfiles,
  home-manager,
  stapelbergnix,
  extraZshInit ? "",
}:

{
  config,
  lib,
  pkgs,
  ...
}:

let
  system = pkgs.stdenv.hostPlatform.system;
  pkgsUnstable = import nixpkgs-unstable {
    inherit system;
    config.allowUnfree = true;
  };
in
{
  imports = [ home-manager.nixosModules.home-manager ];

  # home-manager configuration
  home-manager.useGlobalPkgs = true;
  home-manager.useUserPackages = true;
  home-manager.extraSpecialArgs = { inherit configfiles stapelbergnix; };
  home-manager.users.michael = {
    imports = [ ./microvm-home.nix ];
    microvm.extraZshInit = extraZshInit;
  };

  # Claude Code CLI (from nixpkgs-unstable, unfree)
  environment.systemPackages = [
    pkgsUnstable.claude-code
  ];
  networking.hostName = hostName;

  system.stateVersion = "25.11";

  services.openssh.enable = true;

  # To match midna (host)
  users.groups.michael = {
    gid = 1000;
  };
  users.users.michael = {
    group = "michael";
  };

  services.resolved.enable = true;
  networking.useDHCP = false;
  networking.useNetworkd = true;
  networking.tempAddresses = "disabled";
  systemd.network.enable = true;
  systemd.network.networks."10-e" = {
    matchConfig.Name = "e*";
    addresses = [ { Address = "${ipAddress}/24"; } ];
    routes = [ { Gateway = "192.168.83.1"; } ];
  };
  networking.nameservers = [
    "8.8.8.8"
    "1.1.1.1"
  ];

  # Disable firewall for faster boot and less hassle;
  # we are behind a layer of NAT anyway.
  networking.firewall.enable = false;

  systemd.settings.Manager = {
    # fast shutdowns/reboots! https://mas.to/@zekjur/113109742103219075
    DefaultTimeoutStopSec = "5s";
  };

  # Fix for microvm shutdown hang (issue #170):
  # Without this, systemd tries to unmount /nix/store during shutdown,
  # but umount lives in /nix/store, causing a deadlock.
  systemd.mounts = [
    {
      what = "store";
      where = "/nix/store";
      overrideStrategy = "asDropin";
      unitConfig.DefaultDependencies = false;
    }
  ];

  # Use SSH host keys mounted from outside the VM (remain identical).
  services.openssh.hostKeys = [
    {
      path = "/etc/ssh/host-keys/ssh_host_ed25519_key";
      type = "ed25519";
    }
  ];

  microvm = {
    # Enable writable nix store overlay so nix-daemon works.
    # This is required for home-manager activation.
    # Uses tmpfs by default (ephemeral), which is fine since we
    # don't build anything in the VM.
    writableStoreOverlay = "/nix/.rw-store";

    volumes = [
      {
        mountPoint = "/var";
        image = "var.img";
        size = 8192; # MB
      }
    ];

    shares = [
      {
        # use proto = "virtiofs" for MicroVMs that are started by systemd
        proto = "virtiofs";
        tag = "ro-store";
        # a host's /nix/store will be picked up so that no
        # squashfs/erofs will be built for it.
        source = "/nix/store";
        mountPoint = "/nix/.ro-store";
      }
      {
        proto = "virtiofs";
        tag = "ssh-keys";
        source = "${workspace}/ssh-host-keys";
        mountPoint = "/etc/ssh/host-keys";
      }
      {
        proto = "virtiofs";
        tag = "claude-credentials";
        source = "/home/michael/claude-microvm";
        mountPoint = "/home/michael/claude-microvm";
      }
      {
        proto = "virtiofs";
        tag = "workspace";
        source = workspace;
        mountPoint = workspace;
      }
    ];

    interfaces = [
      {
        type = "tap";
        id = tapId;
        mac = mac;
      }
    ];

    hypervisor = "cloud-hypervisor";
    vcpu = 8;
    mem = 4096;
    socket = "control.socket";
  };
}

5단계: microvm-home.nix

microvm-base.nix는 다시 microvm-home.nix를 불러오는데, 이 파일은 home-manager를 다음과 같이 설정합니다:

  • 제 설정으로 Zsh 구성
  • 제 설정으로 Emacs 구성
  • 공유 디렉터리 ~/claude-microvm에 Claude Code 설정
전체 microvm-home.nix 코드 펼치기
{
  config,
  pkgs,
  lib,
  configfiles,
  stapelbergnix,
  ...
}:

{
  options.microvm = {
    extraZshInit = lib.mkOption {
      type = lib.types.lines;
      default = "";
      description = "Extra lines to add to zsh initContent";
    };
  };

  config = {
    home.username = "michael";
    home.homeDirectory = "/home/michael";

    programs.zsh = {
      enable = true;
      history = {
        size = 4000;
        save = 10000000;
        ignoreDups = true;
        share = false;
        append = true;
      };

      initContent = ''
        ${builtins.readFile "${configfiles}/zshrc"}
        export CLAUDE_CONFIG_DIR=/home/michael/claude-microvm
        ${config.microvm.extraZshInit}
      '';
    };

    programs.emacs = {
      enable = true;
      package = stapelbergnix.lib.emacsWithPackages { inherit pkgs; };
    };

    home.file.".config/emacs" = {
      source = "${configfiles}/config/emacs";
    };

    home.stateVersion = "25.11";

    programs.home-manager.enable = true;
  };
}

6단계: goprotobuf.nix

goprotobuf.nix는 필요하고 유용한 패키지들을 사용할 수 있게 합니다:

# Project-specific configuration for goprotobufvm
{ pkgs, ... }:
{
  # Development environment for Go Protobuf
  environment.systemPackages = with pkgs; [
    # Go toolchain
    go
    gopls
    delve
    protobuf
    gnumake
    gcc
    git
    ripgrep
  ];
}

VM 실행하기

워크스페이스 디렉터리를 만들고 SSH 호스트 키를 생성해 보겠습니다:

mkdir -p ~/microvm/emacs/ssh-host-keys
ssh-keygen -t ed25519 -N "" \
  -f ~/microvm/emacs/ssh-host-keys/ssh_host_ed25519_key

이제 VM을 시작할 수 있습니다:

sudo systemctl start microvm@emacsvm

VM은 몇 초 안에 부팅되어 ping에 응답합니다.

그런 다음 (아마도 tmux(1) 세션에서) VM에 SSH로 접속한 뒤, 공유 워크스페이스 디렉터리에서 권한 확인 없이 Claude(또는 원하는 코딩 에이전트)를 실행합니다:

% ssh 192.168.83.2
emacsvm% cd microvm/emacs
emacsvm% claude --dangerously-skip-permissions

이러한 환경에서 Claude를 실행하면 다음과 같이 보입니다:

‘권한 우회(bypass permissions)’ 모드의 Claude Code

Claude로 VM 만들기

MicroVM을 한 번 설정하는 과정을 거치고 나면, 그 과정이 번거롭게 느껴집니다.

Claude Skills가 이런 작업에 도움이 될 수 있을지 궁금했습니다. Skill은 특정 상황에서 Claude가 특정 단계를 수행하도록 지시하는 마크다운 파일입니다.

.claude/skills/create-microvm/SKILL.md를 다음과 같이 만들었습니다:

---
name: create-microvm
description: Creates a new microvm Virtual Machine on midna for running Claude in, with source code repositories and build dependencies available inside the microvm. Use when the user asks to create a new microvm.
---

Inspect the existing structure at ~/machines/midna (NixOS configuration using Flakes), which includes several MicroVMs in the ~/machines/midna/microvms/ directory.

Then, create a similar structure for the microvm the user asked to create. Be sure to consider:

1. Create a new subdirectory for this microvm, named NAME (the microvm name).
2. Create an entry in microvm.nix similar to an existing microvm's, but:
3. Change hostname to NAME
4. Change IP address (e.g., 192.168.83.3): find used ones and chose next free
5. Change workspace share to /home/michael/microvm/NAME
6. Include build dependencies for the new microvm based on user request
7. Create ssh-host-keys directory
8. Generate SSH host keys
9. Clone/download source code repositories into this microvm based on user request

Once the microvm is defined, update ~/machines/midna to pull it in. To verify, build the config (but do not deploy).

You need to git-commit changes to the microvm before ~/machines/midna can see them.

Claude Code(테스트 버전: v2.0.76 및 v2.1.15)에서 Opus 4.5 모델과 함께 이 skill을 사용하면 다음과 같은 프롬프트를 보낼 수 있습니다:

please set up a microvm for Debian Code Search (dcs). see ~/dcs for the source code (but clone from https://github.com/Debian/dcs)

그러면 Claude가 몇 분 동안 작업하며, 그 전에 확인 질문을 할 수도 있습니다. 이후 Claude는 다음과 같이 보고합니다:

dcsvm microVM이 성공적으로 설정되었습니다. 생성된 내용은 다음과 같습니다:

설정:

  • /home/michael/machines/midna/microvms/dcs.nix - 프로젝트 전용 패키지(Go 툴체인, protobuf 도구, uglify-js, yuicompressor, zopfli)
  • dcsvm을 포함하도록 /home/michael/machines/midna/microvm.nix를 업데이트했으며, 포함된 내용은 다음과 같습니다:
    • IP: 192.168.83.8
    • tapId: microvm6
    • mac: 02:00:00:00:00:07

워크스페이스:

  • /home/michael/microvm/dcs/ - 워크스페이스 디렉터리
  • /home/michael/microvm/dcs/ssh-host-keys/ - SSH 호스트 키(ed25519)
  • /home/michael/microvm/dcs/dcs/ - https://github.com/Debian/dcs에서 클론한 저장소

빌드 검증 완료 - 설정이 성공적으로 빌드됩니다.

배포 후 microVM을 시작하려면: systemctl start microvm@dcsvm

SSH로 접속하려면: ssh [email protected]

멋집니다! 제 경험상 Claude는 항상 VM 생성을 정확하게 해냈습니다.

Claude Code로 이 설정 복제하기

사실 한 단계 더 나아갈 수 있습니다. 단순히 Claude에게 새로운 MicroVM을 만들어 달라고 하는 대신, 이 전체 설정을 여러분의 NixOS 설정에 그대로 복제해 달라고 요청할 수도 있습니다!

다음과 같은 프롬프트를 시도해 보세요:

read https://michael.stapelberg.ch/posts/2026-02-01-coding-agent-microvm-nix/ — I want the exact same setup in my midna NixOS configuration please!

결론

NixOS는 도입하기 어렵다는 평이 있지만, 일단 사용하기 시작하면 몇 분 만에 새로운 프로젝트를 위한 휘발성 MicroVM을 띄우는 것처럼 강력한 작업들을 할 수 있습니다.

유지 보수 노력도 최소한입니다. 제 개인 PC를 업데이트하면 MicroVM 설정도 새로운 소프트웨어 버전을 함께 사용하게 됩니다. 필요할 때 커스터마이징도 쉽습니다.

이는 사실 코딩 에이전트에 대한 제 경험과도 닮아 있습니다. 에이전트가 기존 작업을 자동으로 더 효율적으로 만든다고 느끼기보다는, 이전에는 불가능했던 일들을 가능하게 만든다고 느낍니다(제번스 역설과 유사합니다).

2025년 동안 코딩 에이전트의 품질 향상을 지켜본 것은 흥미롭고(또 무섭기도 했습니다!) 놀라운 경험이었습니다. 2025년 초에는 LLM이 과대평가된 장난감이라고 생각했고, 사람들이 이 모델들이 생성한 텍스트나 코드를 보여줄 때 거의 모욕적으로 느껴지기도 했습니다. 하지만 거의 모든 새로운 프론티어 모델이 출시될 때마다 눈에 띄게 좋아졌고, 이제는 Claude Code의 역량과 품질에 여러 번 긍정적으로 놀랐습니다. 제가 고려하지 못했을 정당한 엣지 케이스까지 처리하는 코드를 생성하기도 했습니다.

이 글에서는 코딩 에이전트를 안전하게 실행하는(사실은 여러분의 비공개 데이터에 접근해서는 안 되는 모든 워크로드를) 한 가지 가능한 방법을 보여드렸으며, 필요에 따라 다양한 방식으로 조정할 수 있습니다.

원문은 Michael Stapelberg님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.