podspawnpodspawn

Podfile Overview

Complete reference for podfile.yaml -- the declarative configuration that defines your dev environment.

A Podfile is a YAML file that declaratively describes a development environment. Instead of manually installing packages, cloning repos, and configuring services inside a container, you write a Podfile and let podspawn build and provision everything for you.

When you run podspawn create, podspawn reads your Podfile, generates a Dockerfile from it, builds a Docker image, creates a container from that image, and then runs your lifecycle hooks inside it. The result is a ready-to-use development environment that is reproducible across machines and teammates.

Why Podfiles exist

Dockerfiles are designed for production images. They are imperative, order-dependent, and require you to think about layer caching, apt cleanup, and multi-stage builds. Podfiles are designed for development. You declare what you want -- a base image, some packages, a shell, some environment variables -- and podspawn handles the translation to a Dockerfile internally.

This separation means:

  • Developers write a short YAML file and get a working environment.
  • Podspawn handles the Dockerfile generation, image building, caching, user setup, and container provisioning.
  • The generated Dockerfile is an implementation detail you never need to see or maintain.

File search order

When you run podspawn create, podspawn searches for a Podfile in the following order, relative to the project directory:

podfile.yaml
podfile.yaml
podfile.yaml
PriorityPathUse case
1.podspawn/podfile.yamlProject-level config, tucked into a dotdir
2podfile.yamlProject-level config at the root
3~/.podspawn/podfile.yamlUser-level default (created by the setup wizard)

The first match wins. If no Podfile is found, podspawn looks for a devcontainer.json as a fallback (see devcontainer fallback below). If nothing is found, the command fails.

The setup wizard (curl -sSfL https://podspawn.dev/up | bash) generates ~/.podspawn/podfile.yaml based on your choices. This acts as your default Podfile for any project that does not have its own. Project-level Podfiles always take precedence.

Precedence

Configuration is resolved in this order, from highest to lowest priority:

  1. Project Podfile (.podspawn/podfile.yaml or podfile.yaml)
  2. devcontainer.json (.devcontainer/devcontainer.json or .devcontainer.json)
  3. User default Podfile (~/.podspawn/podfile.yaml)

There is no merging. Whichever file is found first is used in its entirety.

Complete YAML schema

Here is a Podfile using every available field:

# Inherit from a base Podfile (alternative to specifying base directly)
# extends: ubuntu-dev

# Base Docker image (required unless extends is set)
base: ubuntu:24.04

# Session name override (default: derived from directory name)
# name: my-project

# Packages to install
# Plain names use apt-get. Versioned packages use name@version syntax.
packages:
  - git
  - curl
  - jq
  - nodejs@22
  - python@3.12
  - go@1.22
  - rust@stable

# Default shell for interactive sessions
shell: /bin/zsh

# Environment variables baked into the image
env:
  EDITOR: nvim
  NODE_ENV: development

# Dotfiles repository to clone into ~/dotfiles
dotfiles:
  repo: https://github.com/youruser/dotfiles.git
  install: ./install.sh

# Repositories to clone into the container on creation
repos:
  - url: https://github.com/yourorg/backend.git
    path: /workspace/backend
    branch: main
  - url: https://github.com/yourorg/frontend.git
    path: /workspace/frontend

# Companion service containers (databases, caches, etc.)
services:
  - name: postgres
    image: postgres:16
    ports: [5432]
    env:
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data

  - name: redis
    image: redis:7
    ports: [6379]

# Ports to expose from the dev container
ports:
  expose: [3000, 8080]
  strategy: auto  # auto | manual | expose

# Resource limits
resources:
  cpus: 4.0
  memory: 8g

# Run once when the container is first created
on_create: |
  npm install
  pip install -r requirements.txt

# Run every time the container starts (including reattach)
on_start: echo "ready"

# Additional Dockerfile RUN commands, executed after packages and before hooks
extra_commands:
  - apt-get install -y libssl-dev
  - ldconfig

# How host directory is available inside the container (for podspawn dev)
mount: bind  # bind | copy | none

# Session lifecycle mode
mode: grace-period  # grace-period | destroy-on-disconnect | persistent

# Mount target inside container (default: /workspace/<dirname>)
# workspace: /workspace/myapp

Field reference

Every field in the Podfile schema, with types, defaults, and validation rules.

Top-level fields

FieldTypeRequiredDefaultDescription
basestringyes--Docker image to use as the base. Any valid image reference works (ubuntu:24.04, node:22-slim, ghcr.io/org/image:tag).
packagesstring[]no[]Packages to install. Plain names use apt-get. Versioned names use name@version syntax. See Package resolution.
shellstringno/bin/bashShell for interactive sessions. Must be an absolute path (e.g. /bin/zsh, /bin/fish).
envmap[string]stringno{}Environment variables set via ENV in the generated Dockerfile. Static values only -- variables containing ${PODSPAWN_*} are deferred to runtime.
dotfilesobjectnonullDotfiles repository config. See dotfiles.
reposobject[]no[]Repositories to clone at container creation. See repos.
servicesobject[]no[]Companion service containers. See services.
portsobjectno--Port exposure config. See ports.
resourcesobjectno--CPU and memory limits. See resources.
on_createstringno""Shell script run once at container creation. Executed with sh -c.
on_startstringno""Shell script run every time the container starts. Executed with sh -c.
extra_commandsstring[]no[]Additional shell commands added as RUN instructions in the generated Dockerfile, after package installation.
extendsstringno--Inherit from a base Podfile. Accepts local path, registry name, or GitHub URL. See Extending Podfiles.
namestringnodir basenameOverride the session name used by podspawn dev.
mountstringnobindHow host directory is available in container: bind (live sync), copy (one-time copy), none.
modestringnograce-periodSession lifecycle: grace-period, destroy-on-disconnect, persistent.
workspacestringno/workspace/<dir>Mount target path inside the container. Must be absolute.

dotfiles

FieldTypeRequiredDescription
repostringyesGit URL to clone into ~/dotfiles (/home/<username>/dotfiles).
installstringnoCommand to run after cloning, executed from the dotfiles directory (e.g. ./install.sh, make).

Dotfiles are cloned at container creation time, not during image build. This means they are always fresh and do not invalidate the image cache.

repos

FieldTypeRequiredDescription
urlstringyesGit URL of the repository.
pathstringnoAbsolute path inside the container where the repo will be cloned. If omitted, Git's default naming is used. Must start with /.
branchstringnoBranch to check out. Defaults to main. Cloned with --single-branch for speed.

services

FieldTypeRequiredDescription
namestringyesUnique name for the service. Used as the container name suffix. No duplicates allowed.
imagestringyesDocker image for the service (e.g. postgres:16, redis:7).
portsint[]noPorts to expose from the service container.
envmap[string]stringnoEnvironment variables passed to the service container.
volumesstring[]noDocker volume mounts (e.g. pgdata:/var/lib/postgresql/data).

See Services for more on how service containers are started and networked.

ports

FieldTypeDescription
exposeint[]Ports to expose from the dev container. Translated to EXPOSE instructions in the generated Dockerfile.
strategystringPort forwarding strategy for podspawn dev: auto (default, resolves conflicts), manual (only flag-specified), expose (fail on conflict).

resources

FieldTypeDefaultDescription
cpusfloatServer default (2.0)CPU cores allocated to the container.
memorystringServer default (2g)Memory limit. Accepts g and m suffixes (e.g. 4g, 512m).

Package resolution

Packages in the packages list are resolved in one of three ways:

1. Plain package names are installed via apt-get:

packages:
  - git
  - curl
  - build-essential

2. Known versioned packages use special install sequences. These are packages where apt-get alone cannot install a specific version:

PackageSupported versionsInstall method
nodejs18, 20, 22NodeSource setup script + apt
python3.11, 3.12, 3.13deadsnakes PPA + apt
goAny version (e.g. 1.22, 1.23)Official tarball from go.dev
rustAny version (e.g. stable, nightly, 1.78)rustup

Usage:

packages:
  - nodejs@22
  - python@3.12
  - go@1.23
  - rust@stable

3. Unknown versioned packages fall back to apt version pinning:

packages:
  - nginx@1.24   # becomes: apt-get install -y nginx=1.24*

Versioned package support is limited to the packages listed above. If you need a package installed via a custom method, use extra_commands instead.

How image building works

When podspawn processes a Podfile, it follows this pipeline:

podfile.yaml → Parse → Generate Dockerfile → docker build → Container

Parse and validate

The YAML is decoded, defaults are applied (shell defaults to /bin/bash, repos[].branch defaults to main), and validation runs. Validation rejects:

  • Missing base image
  • Non-absolute shell path (e.g. zsh instead of /bin/zsh)
  • Invalid resources.memory format
  • Repos with empty url or non-absolute path
  • Services with missing name or image
  • Duplicate service names

Generate Dockerfile

Podspawn translates the Podfile into a Dockerfile. The generated Dockerfile follows this structure:

  1. FROM <base>
  2. SHELL ["/bin/bash", "-euo", "pipefail", "-c"] (only if versioned packages need bash)
  3. RUN blocks for versioned package install scripts (nodejs, python, go, rust)
  4. RUN apt-get update && apt-get install -y <packages> && rm -rf /var/lib/apt/lists/*
  5. ENV declarations for static environment variables (sorted alphabetically)
  6. SHELL ["<shell>", "-c"] (if shell is not /bin/bash)
  7. EXPOSE <ports>
  8. RUN blocks for each extra_commands entry

Only fields that affect the image are included in the Dockerfile. Runtime concerns -- dotfiles, repos, services, lifecycle hooks -- are handled after the container is created.

Content-addressed caching

Before building, podspawn computes a content-addressed tag from the raw Podfile bytes:

podspawn/<project>:podfile-<sha256[:12]>

For example, a project called myapp might produce the tag podspawn/myapp:podfile-a1b2c3d4e5f6.

If an image with that tag already exists locally, the build is skipped entirely. This means:

  • Same Podfile bytes = cache hit. No rebuild needed.
  • Any byte change = cache miss. Even a whitespace change produces a new hash and triggers a rebuild.
  • The cache key is the entire Podfile, not individual fields. There is no partial invalidation.

This is a deliberate trade-off: simplicity over granularity. Rebuilds are fast because Docker's own layer cache handles most of the work.

What is not in the image

Several Podfile fields are handled at container creation time, not during image build:

  • dotfiles -- cloned via git clone inside the running container
  • repos -- cloned via git clone --single-branch inside the running container
  • services -- started as separate containers on the same network
  • on_create -- executed via sh -c inside the running container
  • on_start -- executed via sh -c inside the running container
  • resources -- applied as Docker container resource constraints

This split means changing your dotfiles repo or lifecycle hooks does not trigger an image rebuild.

Sudo and non-root user

Two things are true for every Podfile-built container:

1. Sudo is always available. Podspawn automatically adds sudo to the apt package list if you did not include it yourself. You never need to add it manually.

2. Containers run as a non-root user. The container runs as the connecting user (UID 1000), not root. You have a home directory at /home/<username> and can use sudo to elevate when needed.

This means you get a realistic development environment where you are not accidentally running everything as root, but you can still sudo apt-get install a package you forgot to put in the Podfile.

Lifecycle hooks

Two hooks are available:

  • on_create runs once, when the container is first created. Use it for one-time setup: installing dependencies, running migrations, seeding databases.
  • on_start runs every time the container starts, including on reattach. Use it for lightweight checks or notifications.

Both hooks are executed with sh -c as the container user (not root). If a hook exits with a non-zero code, the error is logged as a warning but does not prevent the session from starting. The container remains usable.

Validation rules

Podspawn validates every Podfile before building. The following rules are enforced:

RuleError
base must be setbase image is required
shell must be an absolute pathshell must be absolute path, got "zsh"
resources.memory must use valid formatinvalid resources.memory: ...
Every repos[].url must be setrepo url is required
Every repos[].path must be absolute (if set)repo path must be absolute, got "workspace"
Every services[].name must be setservice name is required
Every services[].image must be setservice "postgres": image is required
Service names must be uniqueduplicate service name "postgres"

Validation happens at parse time, before any Docker operations. If validation fails, nothing is built and the error message tells you exactly what is wrong.

Example Podfiles

Web application (Node.js + PostgreSQL)

base: ubuntu:24.04

packages:
  - git
  - curl
  - nodejs@22

shell: /bin/bash

env:
  DATABASE_URL: postgres://postgres:devpass@postgres:5432/myapp

services:
  - name: postgres
    image: postgres:16
    ports: [5432]
    env:
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: myapp

ports:
  expose: [3000]

on_create: npm install

Data science (Python + Jupyter)

base: ubuntu:24.04

packages:
  - git
  - curl
  - python@3.12
  - build-essential
  - libffi-dev

shell: /bin/bash

env:
  VIRTUAL_ENV: /home/dev/venv
  PATH: /home/dev/venv/bin:$PATH

on_create: |
  python3.12 -m venv /home/dev/venv
  pip install numpy pandas matplotlib jupyter scikit-learn

ports:
  expose: [8888]

resources:
  cpus: 4.0
  memory: 8g

Go project

base: ubuntu:24.04

packages:
  - git
  - curl
  - go@1.23
  - make
  - gcc

shell: /bin/zsh

env:
  GOPATH: /home/dev/go
  GOBIN: /home/dev/go/bin

dotfiles:
  repo: https://github.com/youruser/dotfiles.git
  install: ./install.sh

repos:
  - url: https://github.com/yourorg/myservice.git
    path: /workspace/myservice

on_create: |
  cd /workspace/myservice && go mod download

Monorepo with multiple services

base: ubuntu:24.04

packages:
  - git
  - curl
  - nodejs@22
  - python@3.12
  - docker.io

shell: /bin/zsh

repos:
  - url: https://github.com/yourorg/monorepo.git
    path: /workspace/monorepo

services:
  - name: postgres
    image: postgres:16
    ports: [5432]
    env:
      POSTGRES_PASSWORD: devpass

  - name: redis
    image: redis:7
    ports: [6379]

  - name: rabbitmq
    image: rabbitmq:3-management
    ports: [5672, 15672]

ports:
  expose: [3000, 8000, 8080]

resources:
  cpus: 4.0
  memory: 16g

on_create: |
  cd /workspace/monorepo
  npm install --prefix frontend
  pip install -r backend/requirements.txt

on_start: echo "services ready"

Minimal (just a base image and a shell)

base: ubuntu:24.04
packages:
  - git
  - curl

That is it. Every other field is optional. Podspawn fills in the defaults: bash shell, no services, no hooks, no resource limits beyond the server defaults. This is a valid Podfile for quick throwaway containers.

How is this guide?

On this page