Running Jobs Inside a Container
The container key runs every step inside your image, so paths, permissions, and tooling change. Here is the setup that keeps caches and services working.
The container key on a job starts your image on the runner virtual machine and runs every step of the job inside it, per GitHub's workflow syntax reference. Three things change the moment you add it: the paths your steps see, the user those steps run as, and which tools exist at all, because the container carries its own filesystem and the runner image toolchain stays outside it.
This guide covers the mount table the runner sets up, the failures each change produces, the documented cache configuration for a container job, a workflow with a container, a working cache step, and a service alongside it, and the arithmetic on image pulls. Container jobs are supported on Linux runners only, so the label under runs-on is a Linux x64 or Linux ARM64 one. For the wider set of levers, see the hub on speeding up GitHub Actions.
Diagnosis
Work through the three changes in order. Each one produces a distinct symptom, and two of them fail without an error.
Paths move
The runner mounts a fixed set of host directories into the job container and rewrites HOME. The mapping below is what ContainerOperationProvider.cs in the actions/runner repository sets up on every container job.
| Host path | Container path | What it holds |
|---|---|---|
/home/runner/work | /__w | The workspace, so GITHUB_WORKSPACE reads as /__w/repo/repo |
/home/runner/externals | /__e, read only | The Node.js build that executes JavaScript actions |
/home/runner/work/_temp | /__w/_temp | RUNNER_TEMP, step temp files |
/home/runner/work/_actions | /__w/_actions | Checked-out action code |
| The runner tool cache directory | /__t | RUNNER_TOOL_CACHE, where setup-* actions install toolchains |
/home/runner/work/_temp/_github_home | /github/home | HOME for every step in the job |
/home/runner/work/_temp/_github_workflow | /github/workflow | The event payload and the GITHUB_ENV and GITHUB_OUTPUT files |
Paths relative to the workspace survive unchanged, so ./node_modules, vendor/, and target/ behave as they do on a plain runner. Two things break. A ~ in a workflow file expands to /github/home, which is a directory the runner created for this job and which most official language images ignore in favor of an absolute path baked in at image build time. And any absolute host path a script hardcodes, such as /home/runner/work/repo/repo/dist, points at nothing inside the container.
The cache case is the expensive one because it stays silent. A step caching ~/.npm inside a container reports a restore, the install step downloads every package anyway, and the job time never moves. The answer on caching inside a container job works through that mismatch image by image.
Permissions change
The container runs as the user its image declares, which for most official language images is root. Files that steps write into the mounted workspace therefore land owned by root, while the workspace directory itself was created by the runner user on the host. Two failures come out of that split:
- An image that sets a non-root
USERcannot write into the workspace it was handed, andactions/checkoutfails on permission denied before the first useful step. gitrefuses to operate on a repository owned by a different user and reports detected dubious ownership, which shows up when a working tree checked out by one user is read by a step running as another.
Privileged operations behave the same way they do in any container. Mounting filesystems, changing iptables rules, or talking to a Docker daemon requires options you pass explicitly, because the container gets Docker's default capability set and no access to the host socket.
The image is the tool set
Whatever the image lacks, the step lacks. The runner image on the host carries a large preinstalled toolchain, and none of it is visible from inside the container except the tool cache mounted at /__t. Binaries already sitting in that tool cache were built against the host C library, so a glibc-linked binary from /__t will not execute in a musl-based Alpine image.
| Symptom | Cause | Fix |
|---|---|---|
Checkout produces a working tree with no .git directory | git is absent, so actions/checkout falls back to downloading a tarball through the REST API | Install git in the image |
zstd version: null followed by a 404 warning on restore | zstd is absent, so the cache action falls back to gzip and cannot read an entry saved with zstd | Install zstd |
| The cache step cannot authenticate | WARPBUILD_RUNNER_VERIFICATION_TOKEN was not passed into the container env block | Pass the variable through |
| A reported cache hit over an empty directory | HOME is /github/home, so the cached path is not where the toolchain writes | Resolve the path from the tool |
Connection refused at 127.0.0.1:5432 | Inside a job container, 127.0.0.1 addresses the job container itself | Address the service by its label |
| Permission denied writing to the workspace | The image sets a non-root USER | Set options: --user 0 or chown the workspace |
Fix
Five changes, in this order.
1. Decide whether the job needs a container. A container is the right answer when the job needs a specific base operating system, a system library set you control, or the exact image your service runs in production. When all you need is a language runtime, a setup-* action on the plain runner installs it into the mounted tool cache without paying an image pull on every job. The base image entry covers the distinction between the image a job runs in and the image a job builds.
2. Pin the image. A moving tag such as :latest changes the toolchain under a workflow that nobody edited. Pin an exact tag, or a digest when reproducibility matters more than patch updates.
3. Bake the prerequisites in. The WarpBuild caching documentation lists three conditions for the cache action inside a custom container: wget, because the action downloads entries with it; zstd, because entries are compressed with it; and WARPBUILD_RUNNER_VERIFICATION_TOKEN passed into the container env, because that variable lives on the runner host and does not cross into the container on its own. Installing git, wget, and zstd in a step works and costs a package install on every run. Adding them to the image costs nothing per run.
4. Resolve cache paths from the toolchain. npm config get cache, go env GOMODCACHE, pip cache dir, and cargo config get each print the path that image resolved. Feed that output to the cache step instead of writing ~ into the workflow file.
5. Address services by label. Set the connection string once in the container env block so no step has to know the topology.
Configuration
The whole workflow: a container job, a cache step that restores a real directory, and a Postgres service alongside it.
name: integration
on:
pull_request:
push:
branches: [main]
jobs:
integration:
runs-on: warp-ubuntu-latest-x64-8x
container:
image: node:22-bookworm
env:
WARPBUILD_RUNNER_VERIFICATION_TOKEN: ${{ env.WARPBUILD_RUNNER_VERIFICATION_TOKEN }}
DATABASE_URL: postgres://app:app@postgres:5432/app_test
options: --user 0
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
options: >-
--health-cmd "pg_isready -h 127.0.0.1 -U app -d app_test"
--health-interval 5s
--health-timeout 5s
--health-retries 20
--health-start-period 10s
steps:
- name: Install prerequisites
run: apt-get update && apt-get install -y --no-install-recommends git wget zstd
- uses: actions/checkout@v5
- name: Resolve the npm cache path
id: npm
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- name: Restore the npm cache
id: cache
uses: WarpBuilds/cache@v1
with:
path: ${{ steps.npm.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- run: npm ci
- name: Verify the restore landed
run: du -sh "${{ steps.npm.outputs.dir }}"
- name: Migrate
run: npm run migrate
- name: Test
run: npm testThe prerequisite install runs before checkout because checkout needs git. On Alpine images the same line reads apk add --no-cache git wget zstd. Keep the du -sh step for a run or two after any image change: a hit with a few kilobytes behind it is the empty-directory failure, and a hit with hundreds of megabytes behind it is a working cache.
The container block, key by key
image takes a registry reference. credentials supplies a username and password for a private registry. env sets variables for every step, and it is the only way host variables such as the verification token reach the container. volumes adds mounts on top of the ones the runner already makes. options is passed to docker create, so --user, --privileged, --cpus, and --shm-size all go there.
Services seen from inside a container
| Where the job runs | Service address | Does ports matter |
|---|---|---|
| Directly on the runner machine | 127.0.0.1 on the published port | Yes, each service needs a ports mapping |
| Inside a job container | The service label as a hostname, on the container port | No, both containers share a user-defined bridge network |
The runner creates that bridge network before it creates any container, which is why the label resolves (GitHub service container documentation). Health checks matter the same amount either way, and the guide to service containers for integration tests covers the gate that removes the startup race.
Architecture has to match
An image built for amd64 only does not run on an ARM64 label. Publish a multi-architecture image, or keep the container job on an x64 label and read the shapes from the cloud runners documentation.
Cost or Time Model
Runners are ephemeral, so the image is pulled on every job. Assumptions, stated so you can substitute your own step timings:
- 600 runs per month on
warp-ubuntu-latest-x64-8xat $0.016 per minute (pricing page, checked on 2026-08-13). - A 450 MB compressed image that pulls and unpacks in 0.35 minutes.
- A prerequisite package install of 0.25 minutes when it runs as a step.
- A cache restore of 0.20 minutes.
| Setup | Pull | In-job install | Cache restore | Minutes per run | Monthly minutes | Monthly cost |
|---|---|---|---|---|---|---|
| Stock image, prerequisites installed each run | 0.35 | 0.25 | 0.20 | 0.80 | 480 | $7.68 |
| Same image with prerequisites baked in | 0.35 | 0.00 | 0.20 | 0.55 | 330 | $5.28 |
| Slim custom image with prerequisites | 0.15 | 0.00 | 0.20 | 0.35 | 210 | $3.36 |
Cache fees sit on top and are billed separately from runner minutes. Six GB of resident entries at $0.20 per GB-month is $1.20, and 600 restores plus 60 saves is 660 operations at $0.0001 each, or $0.07, both from the pricing page checked on 2026-08-13. On BYOC runners, cache storage and operations are included.
Runner size sets what every remaining minute costs, and the job container plus each service container share that machine's memory:
| Runner label | vCPU | RAM | Price per minute |
|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 |
| warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 |
| warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 |
| warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 |
The container key applies to the Linux labels. When the image pull and the setup inside it dominate the job rather than the work, a snapshot runner captures the prepared machine instead; snapshot runners sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the product surface, and the observability reports show how much of the job the "Initialize containers" group actually takes.
FAQ
Does every step of the job run inside the container?
Yes. The runner creates the container before the first step and runs every step inside it, including the actions your workflow calls. JavaScript actions execute with the Node.js build the runner mounts read only at /__e, so the image does not need Node installed for actions to start. Everything a run step invokes has to be present in the image, because the tooling installed on the runner host is outside the container.
How do I reach a service container from a job that runs in a container?
By the service label as a hostname, on the port the service listens on inside its own container. The runner puts the job container and every service container on the same user-defined bridge network, so a Postgres service labeled postgres answers at postgres:5432 and the ports keyword is unnecessary. Inside a job container, 127.0.0.1 means the job container itself, which is why a workflow copied from a non-container job reports connection refused.
Why does the same cache step work outside a container and restore an empty directory inside one?
HOME is /github/home inside a container job, so a path written as ~/.npm or ~/.cargo resolves under a directory the runner created for the job rather than the location the toolchain in the image uses. The step reports a hit and the install runs at full cost anyway. Print the path from the tool itself with npm config get cache or go env GOMODCACHE and pass that value to the cache action. The answer on caching inside a container job lists the images where this bites hardest.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.