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 pathContainer pathWhat it holds
/home/runner/work/__wThe workspace, so GITHUB_WORKSPACE reads as /__w/repo/repo
/home/runner/externals/__e, read onlyThe Node.js build that executes JavaScript actions
/home/runner/work/_temp/__w/_tempRUNNER_TEMP, step temp files
/home/runner/work/_actions/__w/_actionsChecked-out action code
The runner tool cache directory/__tRUNNER_TOOL_CACHE, where setup-* actions install toolchains
/home/runner/work/_temp/_github_home/github/homeHOME for every step in the job
/home/runner/work/_temp/_github_workflow/github/workflowThe 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 USER cannot write into the workspace it was handed, and actions/checkout fails on permission denied before the first useful step.
  • git refuses 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.

SymptomCauseFix
Checkout produces a working tree with no .git directorygit is absent, so actions/checkout falls back to downloading a tarball through the REST APIInstall git in the image
zstd version: null followed by a 404 warning on restorezstd is absent, so the cache action falls back to gzip and cannot read an entry saved with zstdInstall zstd
The cache step cannot authenticateWARPBUILD_RUNNER_VERIFICATION_TOKEN was not passed into the container env blockPass the variable through
A reported cache hit over an empty directoryHOME is /github/home, so the cached path is not where the toolchain writesResolve the path from the tool
Connection refused at 127.0.0.1:5432Inside a job container, 127.0.0.1 addresses the job container itselfAddress the service by its label
Permission denied writing to the workspaceThe image sets a non-root USERSet 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 test

The 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 runsService addressDoes ports matter
Directly on the runner machine127.0.0.1 on the published portYes, each service needs a ports mapping
Inside a job containerThe service label as a hostname, on the container portNo, 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-8x at $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.
SetupPullIn-job installCache restoreMinutes per runMonthly minutesMonthly cost
Stock image, prerequisites installed each run0.350.250.200.80480$7.68
Same image with prerequisites baked in0.350.000.200.55330$5.28
Slim custom image with prerequisites0.150.000.200.35210$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 labelvCPURAMPrice per minute
warp-ubuntu-latest-x64-2x28 GB$0.004
warp-ubuntu-latest-x64-4x416 GB$0.008
warp-ubuntu-latest-x64-8x832 GB$0.016
warp-ubuntu-latest-x64-16x1664 GB$0.032
warp-ubuntu-latest-x64-32x32128 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.