StackPanel
Containers & Deployment

Containers

Build OCI containers from your Nix configuration

Stackpanel can build OCI-compatible container images directly from your Nix configuration—no Dockerfile required. Images are built with nix2container, producing minimal, reproducible containers with only the dependencies your application actually needs.

Why Nix-Built Containers?

Traditional Dockerfiles are imperative scripts that install packages, copy files, and run commands. The result depends on the base image version, network availability during build, and the order of operations. Two builds of the same Dockerfile can produce different images.

Nix-built containers are declarative and reproducible. You describe what goes in the image, and Nix computes the exact result. The same input always produces the same image, byte-for-byte.

Additional benefits:

  • Minimal images — Only your application and its runtime dependencies. No package manager, no shell, no leftover build tools.
  • Layer caching — Nix automatically splits your image into layers based on the dependency graph. Dependencies that don't change produce identical layers, giving you efficient caching without manual multi-stage builds.
  • No base image — Images are built from scratch. No inherited vulnerabilities from a base image you don't control.
  • Shared with your devshell — The same Nix packages used in your dev environment end up in your container. No "works locally but not in Docker" surprises.

Enable Containers

Container definitions live under stackpanel.containers:

stackpanel.containers.api = {
  enable = true;
  name = "myapp-api";
  tag = "latest";

  # What goes in the image
  config = {
    entrypoint = [ "${pkgs.nodejs}/bin/node" "dist/index.js" ];
    env = {
      NODE_ENV = "production";
    };
    exposedPorts = {
      "4201/tcp" = {};
    };
  };

  # Files to include
  copyToRoot = [
    ./apps/api/dist
    ./apps/api/package.json
  ];
};

Building Images

Build a container image from the command line:

# Build a specific container
nix build --impure .#packages.x86_64-linux.container-api

# Load it into Docker
docker load < result

# Or build and load in one step
nix build --impure .#packages.x86_64-linux.container-api && docker load < result

The output is a standard OCI image that works with Docker, Podman, containerd, or any OCI-compatible runtime.

Per-App Containers

When you define apps, you can attach container definitions directly:

stackpanel.apps.api = {
  port = 1;
  root = "./apps/api";
  build = "bun run build";
};

stackpanel.containers.api = {
  enable = true;
  name = "ghcr.io/my-org/myapp-api";

  config = {
    entrypoint = [ "${pkgs.bun}/bin/bun" "run" "dist/index.js" ];
    env = {
      NODE_ENV = "production";
      PORT = "4201";
    };
    exposedPorts = {
      "4201/tcp" = {};
    };
  };
};

Multi-Stage Patterns

Nix replaces the need for multi-stage Docker builds. Instead of copying between build and runtime stages, you define separate derivations:

{ pkgs, ... }:
let
  # Build stage — produces the compiled output
  apiBundle = pkgs.stdenv.mkDerivation {
    name = "api-bundle";
    src = ./apps/api;
    buildInputs = [ pkgs.bun ];
    buildPhase = ''
      bun install --frozen-lockfile
      bun run build
    '';
    installPhase = ''
      mkdir -p $out
      cp -r dist $out/
      cp package.json $out/
    '';
  };
in
{
  stackpanel.containers.api = {
    enable = true;
    name = "ghcr.io/my-org/myapp-api";

    # Only the built output and runtime go in the image
    # Build tools (bun, node headers, etc.) are NOT included
    copyToRoot = [ apiBundle ];

    config = {
      entrypoint = [ "${pkgs.nodejs}/bin/node" "dist/index.js" ];
      workingDir = "/";
      env.NODE_ENV = "production";
    };
  };
}

The resulting image contains only Node.js and your compiled code—no build tools, no source files, no node_modules with devDependencies.

Image Layers

nix2container automatically creates efficient image layers based on the Nix store dependency graph. Each Nix store path becomes a potential layer boundary, so:

  • Runtime dependencies (Node.js, system libraries) rarely change → cached layers
  • Application code changes frequently → small top layer
  • No manual layer optimization — Nix computes the optimal layer split

This gives you Docker-like layer caching without writing COPY package.json tricks or managing multi-stage builds.

Registry Push

Push built images to a container registry:

# Build the container
nix build --impure .#packages.x86_64-linux.container-api
docker load < result

# Push to registry
docker tag myapp-api:latest ghcr.io/my-org/myapp-api:latest
docker push ghcr.io/my-org/myapp-api:latest

# Or use skopeo for direct copy
nix run nixpkgs#skopeo -- copy \
  docker-archive:./result \
  docker://ghcr.io/my-org/myapp-api:latest

For CI, the CI generation system can produce Docker build and push steps in your GitHub Actions workflow automatically.

Configuration Reference

Each container supports these options:

OptionTypeDescription
enableboolWhether to build this container
namestringImage name (including registry prefix)
tagstringImage tag (default: "latest")
config.entrypointlistContainer entrypoint command
config.cmdlistDefault command arguments
config.envattrsetEnvironment variables baked into the image
config.exposedPortsattrsetPorts to expose
config.workingDirstringWorking directory inside the container
config.userstringUser to run as
copyToRootlistFiles and derivations to include in the image
layerslistExplicit layer definitions (advanced)
maxLayersintMaximum number of layers (default: 127)

Comparing to Dockerfiles

If you're already using Dockerfiles, you can continue to do so. Stackpanel's container system is an alternative, not a replacement. Here's when each makes sense:

Use Nix containers when...Use Dockerfiles when...
You want reproducible buildsYou need specific base images (e.g., CUDA)
You want minimal image sizeYour team is more comfortable with Docker
You're already using Nix for your devshellYou need Docker-specific features (BuildKit secrets, etc.)
You want shared dependencies with your dev environmentYou're deploying to a platform that expects Dockerfiles

Stackpanel can also generate Dockerfiles through the Docker module, giving you a middle ground: Nix-generated Dockerfiles that are standard files on disk.

Troubleshooting

Image is larger than expected

Check what's being pulled in as dependencies:

nix path-info -rsSh .#packages.x86_64-linux.container-api

This shows every store path in the image closure and its size. Look for unexpected dependencies—a common issue is accidentally including build-time tools in the runtime closure.

"No such file or directory" at runtime

The container's filesystem only contains what you explicitly include via copyToRoot and what Nix determines as runtime dependencies. If a file is missing, add it to copyToRoot or ensure the derivation that produces it is in the dependency graph.

Can't run shell commands in the container

By default, Nix-built containers don't include a shell (bash, sh). If you need one for debugging:

copyToRoot = [
  apiBundle
  pkgs.bashInteractive  # Adds bash for debugging
  pkgs.coreutils        # Adds ls, cat, etc.
];

Remove these before deploying to production to keep the image minimal.

Reference

On this page