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:
| Priority | Path | Use case |
|---|---|---|
| 1 | .podspawn/podfile.yaml | Project-level config, tucked into a dotdir |
| 2 | podfile.yaml | Project-level config at the root |
| 3 | ~/.podspawn/podfile.yaml | User-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:
- Project Podfile (
.podspawn/podfile.yamlorpodfile.yaml) - devcontainer.json (
.devcontainer/devcontainer.jsonor.devcontainer.json) - 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/myappField reference
Every field in the Podfile schema, with types, defaults, and validation rules.
Top-level fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
base | string | yes | -- | Docker image to use as the base. Any valid image reference works (ubuntu:24.04, node:22-slim, ghcr.io/org/image:tag). |
packages | string[] | no | [] | Packages to install. Plain names use apt-get. Versioned names use name@version syntax. See Package resolution. |
shell | string | no | /bin/bash | Shell for interactive sessions. Must be an absolute path (e.g. /bin/zsh, /bin/fish). |
env | map[string]string | no | {} | Environment variables set via ENV in the generated Dockerfile. Static values only -- variables containing ${PODSPAWN_*} are deferred to runtime. |
dotfiles | object | no | null | Dotfiles repository config. See dotfiles. |
repos | object[] | no | [] | Repositories to clone at container creation. See repos. |
services | object[] | no | [] | Companion service containers. See services. |
ports | object | no | -- | Port exposure config. See ports. |
resources | object | no | -- | CPU and memory limits. See resources. |
on_create | string | no | "" | Shell script run once at container creation. Executed with sh -c. |
on_start | string | no | "" | Shell script run every time the container starts. Executed with sh -c. |
extra_commands | string[] | no | [] | Additional shell commands added as RUN instructions in the generated Dockerfile, after package installation. |
extends | string | no | -- | Inherit from a base Podfile. Accepts local path, registry name, or GitHub URL. See Extending Podfiles. |
name | string | no | dir basename | Override the session name used by podspawn dev. |
mount | string | no | bind | How host directory is available in container: bind (live sync), copy (one-time copy), none. |
mode | string | no | grace-period | Session lifecycle: grace-period, destroy-on-disconnect, persistent. |
workspace | string | no | /workspace/<dir> | Mount target path inside the container. Must be absolute. |
dotfiles
| Field | Type | Required | Description |
|---|---|---|---|
repo | string | yes | Git URL to clone into ~/dotfiles (/home/<username>/dotfiles). |
install | string | no | Command 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
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | Git URL of the repository. |
path | string | no | Absolute path inside the container where the repo will be cloned. If omitted, Git's default naming is used. Must start with /. |
branch | string | no | Branch to check out. Defaults to main. Cloned with --single-branch for speed. |
services
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Unique name for the service. Used as the container name suffix. No duplicates allowed. |
image | string | yes | Docker image for the service (e.g. postgres:16, redis:7). |
ports | int[] | no | Ports to expose from the service container. |
env | map[string]string | no | Environment variables passed to the service container. |
volumes | string[] | no | Docker volume mounts (e.g. pgdata:/var/lib/postgresql/data). |
See Services for more on how service containers are started and networked.
ports
| Field | Type | Description |
|---|---|---|
expose | int[] | Ports to expose from the dev container. Translated to EXPOSE instructions in the generated Dockerfile. |
strategy | string | Port forwarding strategy for podspawn dev: auto (default, resolves conflicts), manual (only flag-specified), expose (fail on conflict). |
resources
| Field | Type | Default | Description |
|---|---|---|---|
cpus | float | Server default (2.0) | CPU cores allocated to the container. |
memory | string | Server 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-essential2. Known versioned packages use special install sequences. These are packages where apt-get alone cannot install a specific version:
| Package | Supported versions | Install method |
|---|---|---|
nodejs | 18, 20, 22 | NodeSource setup script + apt |
python | 3.11, 3.12, 3.13 | deadsnakes PPA + apt |
go | Any version (e.g. 1.22, 1.23) | Official tarball from go.dev |
rust | Any version (e.g. stable, nightly, 1.78) | rustup |
Usage:
packages:
- nodejs@22
- python@3.12
- go@1.23
- rust@stable3. 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 → ContainerParse 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
baseimage - Non-absolute
shellpath (e.g.zshinstead of/bin/zsh) - Invalid
resources.memoryformat - Repos with empty
urlor non-absolutepath - Services with missing
nameorimage - Duplicate service names
Generate Dockerfile
Podspawn translates the Podfile into a Dockerfile. The generated Dockerfile follows this structure:
FROM <base>SHELL ["/bin/bash", "-euo", "pipefail", "-c"](only if versioned packages need bash)RUNblocks for versioned package install scripts (nodejs, python, go, rust)RUN apt-get update && apt-get install -y <packages> && rm -rf /var/lib/apt/lists/*ENVdeclarations for static environment variables (sorted alphabetically)SHELL ["<shell>", "-c"](if shell is not/bin/bash)EXPOSE <ports>RUNblocks for eachextra_commandsentry
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 viagit cloneinside the running containerrepos-- cloned viagit clone --single-branchinside the running containerservices-- started as separate containers on the same networkon_create-- executed viash -cinside the running containeron_start-- executed viash -cinside the running containerresources-- 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_createruns once, when the container is first created. Use it for one-time setup: installing dependencies, running migrations, seeding databases.on_startruns 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:
| Rule | Error |
|---|---|
base must be set | base image is required |
shell must be an absolute path | shell must be absolute path, got "zsh" |
resources.memory must use valid format | invalid resources.memory: ... |
Every repos[].url must be set | repo url is required |
Every repos[].path must be absolute (if set) | repo path must be absolute, got "workspace" |
Every services[].name must be set | service name is required |
Every services[].image must be set | service "postgres": image is required |
| Service names must be unique | duplicate 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 installData 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: 8gGo 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 downloadMonorepo 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
- curlThat 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?