Ken Muse

Calling Docker From Inside a GitHub Job Container


I’ve had an interesting question that has come up a few times recently: how can you call Docker from inside a GitHub Actions job container? This can happen when a job container needs to be able to run Docker commands, access details about the runner Docker processes, or needs to dynamically create containers. To understand the solution, it might be helpful to understand how GitHub Actions uses Docker to create job containers and service containers.

Creating job and service containers

To create a job container or service container, a GitHub Actions workflow just needs to declare those in the Actions workflow on a Linux-based runner. For example:

 1on: push
 2
 3jobs:
 4  my-container-job:
 5    runs-on: ubuntu-latest
 6    container: node:22
 7
 8    services:
 9      redis:
10        image: redis

This creates a job container based on the node:22 image. This will then be used to execute all of the steps in the job. The runner will also create a service container based on the redis image. This service container will be available to the job container using the DNS name redis. Under the hood, GitHub Actions relies on Docker to create these containers.

Creating containers

When the runner starts, you will see the “Initializing containers” step execute. This step is responsible for creating the containers that have been requested. It begins by checking the Docker version to ensure that the client and server components are reporting at least version 1.35 (Docker CE 17.12).

1▼ Checking docker version
2/usr/bin/docker version --format '{{.Client.APIVersion}}'
3/usr/bin/docker version --format '{{.Server.APIVersion}}'

After that, the runner lists all containers that Docker is managing for the runner so that it can terminate those processes. It does this by creating a label based on a hash of the .runner JSON configuration file. This label is attached to any containers that the runner creates. It then does the same thing with the Docker network that the runner will create. In this example, the label is 0fbc22:

1▼ Clean up resources from previous jobs
2/usr/bin/docker ps --all --quiet --no-trunc --filter "label=0fbc22"
3/usr/bin/docker network prune --force --filter "label=0fbc22"

Next, the runner creates a private container network. This helps to avoid possible port conflicts on the runner. The network is labeled so that it can be cleaned up later, and all containers that are created will join it. The network is created with a name that includes the label and a random, GUID-based suffix to ensure uniqueness:

1▼ Create local container network
2/usr/bin/docker network create --label 0fbc22 github_network_904d33721d9b4f43bc6b407881e058a7

With the network created, the runner can now create the job container. It pulls the image and creates the container, attaching the label and network to it. The container is provided a handful of environment variables, and multiple volume mounts. These mounts are primarily intended to provide parity between the job container and the host runner. In other words, the runner is creating mounts for all of the important folders that the runner uses for creating and managing steps to ensure that it can run Actions inside of the job container as if it were running locally on the host.

The runner creates the container with an entrypoint that ensures it remains running without requiring many CPU cycles: tail -f /dev/null. This will allow it to later execute tasks inside the running container with docker exec.

1▼ Starting job container
2/usr/bin/docker pull node:22
3/usr/bin/docker create --name 17ec58c7d9b24d8ca705d6b171aa1178_node22_01054a --label 0fbc22 --workdir /__w/test-workflows/test-workflows --network github_network_904d33721d9b4f43bc6b407881e058a7  -e "HOME=/github/home" -e GITHUB_ACTIONS=true -e CI=true -v "/var/run/docker.sock":"/var/run/docker.sock" -v "/home/runner/work":"/__w" -v "/home/runner/actions-runner/cached/externals":"/__e":ro -v "/home/runner/work/_temp":"/__w/_temp" -v "/home/runner/work/_actions":"/__w/_actions" -v "/opt/hostedtoolcache":"/__t" -v "/home/runner/work/_temp/_github_home":"/github/home" -v "/home/runner/work/_temp/_github_workflow":"/github/workflow" --entrypoint "tail" node:22 "-f" "/dev/null"
4  1f9798befd62a1ec4fabc9189965170a591cf3459387124042741b18eac34250

This step returns the ID of the container, which is used to start the container. The same ID is then used to check the status of the container to ensure it is running and to inspect the environment variables in the container:

 1/usr/bin/docker start 1f9798befd62a1ec4fabc9189965170a591cf3459387124042741b18eac34250
 2  1f9798befd62a1ec4fabc9189965170a591cf3459387124042741b18eac34250
 3
 4/usr/bin/docker ps --all --filter id=1f9798befd62a1ec4fabc9189965170a591cf3459387124042741b18eac34250 --filter status=running --no-trunc --format "{{.ID}} {{.Status}}"
 5  1f9798befd62a1ec4fabc9189965170a591cf3459387124042741b18eac34250 Up Less than a second
 6
 7/usr/bin/docker inspect --format "{{range .Config.Env}}{{println .}}{{end}}" 1f9798befd62a1ec4fabc9189965170a591cf3459387124042741b18eac34250
 8  HOME=/github/home
 9  GITHUB_ACTIONS=true
10  CI=true

The steps for the service container are similar, but don’t require the volume mounts. In addition, the runner relies on the entrypoint defined as part of the service container’s image. The service container is created with a name that includes the label and a random, GUID-based suffix to ensure uniqueness.

 1▼ Starting redis service container
 2/usr/bin/docker pull redis
 3/usr/bin/docker create --name 68f41e2d7f9a449fbc09c5d490584669_redis_3d0859 --label 0fbc22 --network github_network_904d33721d9b4f43bc6b407881e058a7 --network-alias redis  -e GITHUB_ACTIONS=true -e CI=true redis
 4  998bb8f5b844d10a8cd04357be0fe639d71bbfe027abddf16b229fbb619bb59f
 5
 6/usr/bin/docker start 998bb8f5b844d10a8cd04357be0fe639d71bbfe027abddf16b229fbb619bb59f
 7  998bb8f5b844d10a8cd04357be0fe639d71bbfe027abddf16b229fbb619bb59f
 8
 9/usr/bin/docker ps --all --filter id=998bb8f5b844d10a8cd04357be0fe639d71bbfe027abddf16b229fbb619bb59f --filter status=running --no-trunc --format "{{.ID}} {{.Status}}"
10  998bb8f5b844d10a8cd04357be0fe639d71bbfe027abddf16b229fbb619bb59f Up Less than a second
11
12/usr/bin/docker port 998bb8f5b844d10a8cd04357be0fe639d71bbfe027abddf16b229fbb619bb59f
13
14▼ Waiting for all services to be ready
15/usr/bin/docker inspect --format="{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}" 998bb8f5b844d10a8cd04357be0fe639d71bbfe027abddf16b229fbb619bb59f

There are differences in this process compared to the earlier one. First, docker ps is used to identify the ports mapped to the container. Second, the runner uses the Health Check feature to ensure that the service container is ready before starting to execute the job steps. Once all of the service containers are ready, the runner can proceed to execute the job steps.

Cleaning up

Like all good citizens, the runner cleans up after itself. It does this by stopping and removing the job container, service containers, and the network that it created. The runner also prints the logs for each service container before removing them. This is useful for debugging purposes.

 1▼ Stop Containers
 2Stop and remove container: 17ec58c7d9b24d8ca705d6b171aa1178_node22_01054a
 3/usr/bin/docker rm --force 1f9798befd62a1ec4fabc9189965170a591cf3459387124042741b18eac34250
 4  1f9798befd62a1ec4fabc9189965170a591cf3459387124042741b18eac34250
 5
 6Print service container logs: 68f41e2d7f9a449fbc09c5d490584669_redis_3d0859
 7/usr/bin/docker logs --details 998bb8f5b844d10a8cd04357be0fe639d71bbfe027abddf16b229fbb619bb59f
 8  ... (logs for the redis service container) ...
 9
10Stop and remove container: 68f41e2d7f9a449fbc09c5d490584669_redis_3d0859
11/usr/bin/docker rm --force 998bb8f5b844d10a8cd04357be0fe639d71bbfe027abddf16b229fbb619bb59f
12  998bb8f5b844d10a8cd04357be0fe639d71bbfe027abddf16b229fbb619bb59f
13
14Remove container network: github_network_904d33721d9b4f43bc6b407881e058a7
15/usr/bin/docker network rm github_network_904d33721d9b4f43bc6b407881e058a7
16github_network_904d33721d9b4f43bc6b407881e058a7

As we saw earlier, the runner assumes this might not succeed and also tries to clean up at the start of the job. This is designed to support non-ephemeral self-hosted runners, where an error might have caused the runner to complete before cleaning up the containers.

Calling Docker from inside a job container

Now that you understand how the runner creates job containers and service containers, we can continue with the original question: how can you call Docker from inside a job container? The secret to doing this was somewhat hidden in the way Docker starts up the job container. It creates a volume mount for the Docker socket to share the connection to the Docker daemon with the job container: -v "/var/run/docker.sock":"/var/run/docker.sock".

By default, the Docker CLI expects to find a Unix domain socket created at /var/run/docker.sock. This socket is used to communicate with the Docker daemon. This means that the job container can run Docker commands as if it were the host. Actions uses this to enable Docker container Actions to run. Since those share some of the same volume mounts, the executed Action will have access to the same files as the job container and host.

That means that all we really need to do is ensure the job container includes the Docker CLI. The easiest way to do this is to use a Docker image that already has the Docker CLI installed. Docker provides instructions on this process. For Ubuntu, it involves setting up the Docker repository and then using sudo apt-get install docker-ce-cli to install the CLI component. If you want to be extra safe, you can also configure the DOCKER_HOST environment variable to point to the socket. This is not strictly necessary since the CLI will default to that location. That said, setting the value explicitly avoids confusion and ensures the value is not overridden by any other configurations. For an Actions step, it would look like this:

1- run: docker ps
2  env:
3    DOCKER_HOST: unix:///var/run/docker.sock

Knowing these details make the experience of running Docker commands inside a job container very easy. And since it’s just simple Docker CLI commands, you can take advantage of your knowledge of those commands to interact with the environment any way you want. For example, if I need to get details about the current job container, I can take advantage of the fact that $HOSTNAME will contain a shortened version of the container ID:

 1docker ps --no-trunc --filter id=$HOSTNAME --filter status=running --format json
 2
 3{
 4    "Command": "\"tail -f /dev/null\"",
 5    "CreatedAt": "2025-05-25 04:08:28 +0000 UTC",
 6    "ID": "1f9798befd62a1ec4fabc9189965170a591cf3459387124042741b18eac34250",
 7    "Image": "node:22",
 8    "Labels": "0fbc22",
 9    "LocalVolumes": "0",
10    "Mounts": "/home/runner/work/_temp/_github_home,/home/runner/work/_temp/_github_workflow,/var/run/docker.sock,/home/runner/actions-runner/cached/externals,/opt/hostedtoolcache,/home/runner/work,/home/runner/work/_actions,/home/runner/work/_temp",
11    "Names": "17ec58c7d9b24d8ca705d6b171aa1178_node22_01054a",
12    "Networks": "github_network_904d33721d9b4f43bc6b407881e058a7",
13    "Ports": "",
14    "RunningFor": "4 seconds ago",
15    "Size": "0B",
16    "State": "running",
17    "Status": "Up 3 seconds"
18}

As you can see, there was a lot of thought that went into how the runner code creates job and service containers. I hope this has demystified how Docker works to create job and service containers, and how you can take advantage of that to run Docker commands inside a job container.