Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

392 lines · 19.0 KBRaw History
1---
2title: Docker in workflows
3description: Run jobs in containers, start service containers, use Docker container actions, and build and push images from Diffinery workflows.
4---
5
6Diffinery workflows support Docker the same way GitHub Actions does: a job can run inside a container image, start service containers such as databases next to it, use actions packaged as Docker images, and run `docker build`, `docker run` and `docker compose` in steps. This page covers each feature, the differences from GitHub, and complete examples.
7
8## Where Docker is available
9
10| Runner | Docker |
11|---|---|
12| Hosted (`runs-on: ubuntu-latest` and other hosted labels) | Always available. The Docker daemon starts the first time the job uses it. |
13| Self-hosted (`runs-on: [self-hosted, ...]`) | Available when the machine has Docker installed and running, with the `docker` CLI on the runner's `PATH`. |
14
15On hosted runners:
16
17- The `docker` command starts the daemon on first use and prints `Starting the Docker daemon`. Jobs that never use Docker don't start it. A job that will use Docker (a job `container:`, `services:`, a `docker://` action, or a step whose `uses:` or `run:` names `docker`) gets the daemon started in the background as the job begins, so it is usually ready by the time checkout and setup are done.
18- Docker Hub images are pulled through the registry mirror `https://mirror.gcr.io`, which avoids Docker Hub's anonymous pull limits for official images. Images the mirror doesn't have come from Docker Hub directly.
19- Containers use the DNS servers `1.1.1.1` and `8.8.8.8`.
20- Bridge networks, service names, port publishing and image builds all work, as on GitHub's hosted runners.
21
22See [Runners](/docs/actions/runners) for the hosted image and for setting up Docker on a self-hosted runner.
23
24## Job containers
25
26`jobs.<id>.container` runs the job's steps inside a container instead of directly on the runner.
27
28```yaml
29jobs:
30 test:
31 runs-on: ubuntu-latest
32 container:
33 image: node:22-bookworm
34 env:
35 NODE_ENV: test
36 options: --cpus 1
37 steps:
38 - uses: actions/checkout@v4
39 - run: node --version && npm ci && npm test
40```
41
42`container` is either an image name (`container: node:22`) or a mapping:
43
44| Key | Description |
45|---|---|
46| `image` | Image to run. Expressions are allowed. If it evaluates to an empty string, the job runs without a container. |
47| `credentials.username`, `credentials.password` | Registry login for a private image. Both are required when either is set ("Registry credentials for IMAGE need both a username and a password."). The password is masked in logs. |
48| `env` | Environment variables for the container. |
49| `ports` | Ports to publish, as for `docker run -p`. |
50| `volumes` | Volumes to mount, as for `docker run -v`. |
51| `options` | Extra `docker run` options, as one string. `--network` is not allowed. |
52
53How it works:
54
551. In the "Initialize containers" section of the job log, Diffinery creates a bridge network for the job, starts any service containers, pulls the image and starts the job container on that network.
562. The container's entrypoint is replaced with `tail -f /dev/null` to keep it running, so **the image needs a `tail` command** (almost every Linux image has one). If it exits right away, the job fails with "The job container (IMAGE) exited right after starting. The image needs a 'tail' command to stay up."
573. The runner's work directory is mounted into the container at the same path, so `GITHUB_WORKSPACE`, `$GITHUB_OUTPUT`, `$GITHUB_ENV` and the other file commands work unchanged. `HOME` is `/github/home` inside the container.
584. Each `run:` step is executed with `docker exec` in the container, with the step's environment. The default shell is `bash` if the image has it, otherwise `sh`. `PATH` comes from the image unless the workflow sets `PATH`.
595. JavaScript actions run inside the container with the runner's own Node.js binary, mounted at `/__e/node`. That binary needs glibc, so JavaScript actions don't work in musl-based images such as Alpine (the same limitation GitHub has).
606. The built-in actions (`actions/checkout`, `actions/cache`, `actions/upload-artifact`, `actions/download-artifact`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go`) run on the runner itself and work on the shared workspace. When the runner uses the real action instead (for a version or input the built-in doesn't cover, see [Compatibility with GitHub Actions](/docs/actions/compatibility#built-in-actions)), it runs like any other JavaScript action, inside the container.
617. The Docker socket is mounted into the job container when the runner has one at `/var/run/docker.sock`, so a step in the container can use Docker if the image includes the `docker` CLI.
628. When the job ends, every container the job started and its network are removed.
63
64The `job.container` context has `id` and `network`.
65
66## Service containers
67
68`jobs.<id>.services` starts containers next to the job, for databases, caches and other dependencies your tests need. Each key is the service's name.
69
70```yaml
71services:
72 postgres:
73 image: postgres:16
74 env:
75 POSTGRES_PASSWORD: postgres
76 ports: ["5432:5432"]
77 options: --health-cmd "pg_isready -U postgres" --health-interval 2s --health-retries 30
78```
79
80Each service takes the same keys as a job container (`image`, `credentials`, `env`, `ports`, `volumes`, `options`). A service whose `image` evaluates to an empty string is skipped, as on GitHub, which lets you turn a service on or off with an expression.
81
82### Reaching a service
83
84How a step reaches a service depends on where the step runs:
85
86| The job... | Reach the service at | Notes |
87|---|---|---|
88| Has a `container:` | `<service name>:<container port>`, for example `postgres:5432` | Every container of the job joins one network, and each service is registered under its key as a network alias. No `ports` needed. |
89| Has no `container:` (steps run on the runner) | `localhost:<published port>` | Publish the port with `ports`. `"5432:5432"` publishes on the same port; `"5432"` picks a free host port, which you read from `job.services.<name>.ports['5432']`. |
90
91```yaml
92- run: redis-cli -p ${{ job.services.redis.ports['6379'] }} ping
93```
94
95The `job.services.<name>` context has `id`, `network` and `ports` (a map from container port to host port). The job log lists the mappings, for example `Service redis is running (6379 -> localhost:32768)`.
96
97### Health checks
98
99If a service image (or its `options`, through `--health-cmd`) defines a Docker health check, Diffinery waits for the service to become healthy before running the first step, checking with increasing intervals for up to 15 minutes. Services without a health check are considered ready as soon as they start. The job fails when one of these happens (for the first two, the log shows the last 50 lines of the service's output):
100
101| Message | Meaning |
102|---|---|
103| `Service container NAME failed its health check.` | Docker marked the container unhealthy. |
104| `Service container NAME exited before it became healthy.` | The container stopped, for example because of a bad environment variable. |
105| `Service container NAME did not become healthy in time (status: starting).` | It was still starting after 15 minutes. |
106
107Add a health check to every service your tests connect to straight away; without one, the first step can start before the database accepts connections.
108
109### The --network option
110
111Every container of a job shares one network, so `--network` (or `--net`) in `options` is rejected:
112
113```text
114services.cache: the --network option is not supported; every container of the job shares one network.
115```
116
117## Docker container actions
118
119Two kinds of steps run an action in its own container:
120
121- `uses: docker://IMAGE` runs a public or mirrored image directly.
122- An action whose `action.yml` has `runs.using: docker`, with `runs.image` set to a `Dockerfile` in the action (built for the job) or to `docker://IMAGE`. An action directory with a `Dockerfile` and no `action.yml` is treated the same way.
123
124```yaml
125steps:
126 - uses: actions/checkout@v4
127 # An image straight from a registry; `args` and `entrypoint` drive the container.
128 - uses: docker://alpine:3.20
129 with:
130 entrypoint: /bin/sh
131 args: -c "ls -la /github/workspace"
132 # A Docker action in this repository (./.github/actions/lint/action.yml + Dockerfile).
133 - uses: ./.github/actions/lint
134 with:
135 strict: true
136```
137
138```yaml
139# .github/actions/lint/action.yml
140name: Lint
141inputs:
142 strict:
143 default: "false"
144runs:
145 using: docker
146 image: Dockerfile
147 args:
148 - --strict=${{ inputs.strict }}
149```
150
151Behaviour, matching GitHub's:
152
153- For `docker://` steps, `with.args` is split like a shell command line into the container's arguments and `with.entrypoint` replaces its entrypoint. Any other `with` keys become `INPUT_<NAME>` environment variables.
154- For `runs.using: docker` actions, inputs become `INPUT_<NAME>` variables, and `runs.args`, `runs.env`, `runs.entrypoint`, `runs.pre-entrypoint`, `runs.post-entrypoint`, `runs.pre-if` and `runs.post-if` are honoured.
155- A `Dockerfile` action is built with `docker build` before it runs. A build failure fails the step with "Building the action's Dockerfile failed (exit code N)."
156- The action container runs on the job's network with the workspace mounted at `/github/workspace` (the working directory and `GITHUB_WORKSPACE`), `HOME` at `/github/home`, and the runner's temp directory mounted so `$GITHUB_OUTPUT` and the other file commands work. The Docker socket is mounted when the runner has one.
157- A non-zero exit fails the step with "Docker run failed with exit code N".
158
159Remote Docker actions (`uses: owner/repo@ref`) are downloaded from github.com, like other remote actions.
160
161## docker build, run and compose in steps
162
163On hosted runners the full Docker CLI is installed with the Buildx and Compose plugins, so ordinary commands work in `run:` steps:
164
165```yaml
166jobs:
167 integration:
168 runs-on: ubuntu-latest
169 steps:
170 - uses: actions/checkout@v4
171 - run: docker build -t website:test .
172 - run: docker run --rm website:test npm test
173 - run: docker compose -f compose.test.yml up -d --wait
174 - run: ./scripts/integration-tests.sh
175 - if: always()
176 run: docker compose -f compose.test.yml logs && docker compose -f compose.test.yml down -v
177```
178
179`docker/setup-buildx-action`, `docker/login-action`, `docker/metadata-action` and `docker/build-push-action` also work. On hosted runners, `docker buildx create` with the `docker-container` driver (what `docker/setup-buildx-action` runs) starts BuildKit on the host network and registers it as a remote builder, because the regular driver can't run inside a Cloudflare Container. You don't need to change your workflow for this.
180
181BuildKit's `type=gha` cache works with `docker/build-push-action`. The action runs with the job's cache service credentials and hands them to BuildKit, and the layers are stored in the repository's Actions cache, with the same scope, size limit and eviction as `actions/cache`. See [Caching and artifacts](/docs/actions/caching-and-artifacts).
182
183```yaml
184- uses: docker/setup-buildx-action@v3
185- uses: docker/build-push-action@v6
186 with:
187 context: .
188 push: false
189 cache-from: type=gha
190 cache-to: type=gha,mode=max
191```
192
193> **Note:** The cache service credentials (`ACTIONS_RUNTIME_TOKEN` and `ACTIONS_RESULTS_URL`) are given only to JavaScript actions, Docker container actions and the built-in actions, never to `run:` steps. `docker buildx build --cache-to type=gha` in a `run:` step has no credentials, so use `docker/build-push-action` for the gha cache.
194
195## Push images to the Diffinery registry
196
197Diffinery hosts a container registry at `diffinery.com`, in place of ghcr.io. A workflow signs in with its own `GITHUB_TOKEN`; the job's `packages` permission decides what the token can do:
198
199| `permissions` in the workflow or job | `GITHUB_TOKEN` can |
200|---|---|
201| No `permissions` block | What the repository default allows: pull for repositories with the `read` default (the default for new repositories), push and pull for those with `write` |
202| `write-all`, or `packages: write` | Push and pull |
203| `packages: read`, or `read-all` | Pull |
204| A mapping without `packages` | Nothing |
205
206A job's own `permissions:` replaces the workflow's. To push from a repository with the `read` default, add `packages: write` to the job or the workflow. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions).
207
208With write, the token pushes only to packages linked to the workflow's own repository, and creates new packages under the repository's owner (linked to the repository). Other packages of the same owner, including ones that aren't linked to any repository, are pull-only for it.
209
210Image names are `diffinery.com/<owner>/<name>` and must be lowercase. `github.repository` keeps the owner's and repository's capitals, so lowercase it in a shell step with `${GITHUB_REPOSITORY,,}`.
211
212On hosted runners, image layers of any size push, because the Docker daemon and BuildKit builders reach the registry through a relay that splits large uploads. Self-hosted runners don't have the relay, so from them (as from a laptop or a server) a layer larger than 100 MB fails to push.
213
214The registry, its permissions and its API are documented in [Container registry](/docs/packages/container-registry).
215
216## Examples
217
218### Postgres service for tests
219
220Steps run on the runner and reach Postgres on `localhost`:
221
222```yaml
223name: Test
224on: [push, pull_request]
225
226jobs:
227 test:
228 runs-on: ubuntu-latest
229 services:
230 postgres:
231 image: postgres:16
232 env:
233 POSTGRES_USER: postgres
234 POSTGRES_PASSWORD: postgres
235 POSTGRES_DB: website_test
236 ports: ["5432:5432"]
237 options: >-
238 --health-cmd "pg_isready -U postgres"
239 --health-interval 2s
240 --health-timeout 5s
241 --health-retries 30
242 env:
243 DATABASE_URL: postgres://postgres:postgres@localhost:5432/website_test
244 steps:
245 - uses: actions/checkout@v4
246 - uses: actions/setup-node@v4
247 with:
248 node-version: 22
249 cache: npm
250 - run: npm ci
251 - run: npm run db:migrate
252 - run: npm test
253```
254
255The same job in a container reaches the service by name instead:
256
257```yaml
258jobs:
259 test:
260 runs-on: ubuntu-latest
261 container: node:22-bookworm
262 services:
263 postgres:
264 image: postgres:16
265 env:
266 POSTGRES_PASSWORD: postgres
267 options: --health-cmd "pg_isready -U postgres" --health-interval 2s --health-retries 30
268 env:
269 DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres
270 steps:
271 - uses: actions/checkout@v4
272 - run: npm ci && npm test
273```
274
275### Redis with a random host port
276
277```yaml
278jobs:
279 cache-tests:
280 runs-on: ubuntu-latest
281 services:
282 redis:
283 image: redis:7-alpine
284 ports: ["6379"]
285 options: --health-cmd "redis-cli ping" --health-interval 1s --health-retries 30
286 steps:
287 - uses: actions/checkout@v4
288 - run: npm ci && npm test
289 env:
290 REDIS_URL: redis://localhost:${{ job.services.redis.ports['6379'] }}
291```
292
293### Build and push an image on every push to main
294
295With plain Docker commands:
296
297```yaml
298name: Image
299on:
300 push:
301 branches: [main]
302
303jobs:
304 image:
305 runs-on: ubuntu-latest
306 permissions:
307 contents: read
308 packages: write
309 steps:
310 - uses: actions/checkout@v4
311 - name: Sign in to the Diffinery registry
312 run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ github.actor }}" --password-stdin
313 - name: Build and push
314 run: |
315 image="diffinery.com/${GITHUB_REPOSITORY,,}"
316 docker build -t "$image:${{ github.sha }}" -t "$image:latest" .
317 docker push "$image:${{ github.sha }}"
318 docker push "$image:latest"
319```
320
321With the `docker/*` actions most ghcr.io workflows use:
322
323```yaml
324jobs:
325 image:
326 runs-on: ubuntu-latest
327 permissions:
328 contents: read
329 packages: write
330 steps:
331 - uses: actions/checkout@v4
332 - run: echo "IMAGE=diffinery.com/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
333 - uses: docker/setup-buildx-action@v3
334 - uses: docker/login-action@v3
335 with:
336 registry: diffinery.com
337 username: ${{ github.actor }}
338 password: ${{ secrets.GITHUB_TOKEN }}
339 - uses: docker/build-push-action@v6
340 with:
341 context: .
342 push: true
343 tags: ${{ env.IMAGE }}:${{ github.sha }}
344```
345
346A later job (or another workflow of the same owner) can use the image as its job container:
347
348```yaml
349 smoke:
350 needs: image
351 runs-on: ubuntu-latest
352 permissions:
353 packages: read
354 container:
355 image: diffinery.com/acme/website:${{ github.sha }}
356 credentials:
357 username: ${{ github.actor }}
358 password: ${{ secrets.GITHUB_TOKEN }}
359 steps:
360 - run: /app/healthcheck
361```
362
363A deploy job can then hand the image to your platform, using credentials stored as [environment](/docs/actions/environments) secrets so that only allowed branches reach them. The job records a deployment of the commit to `production`:
364
365```yaml
366 deploy:
367 needs: smoke
368 runs-on: ubuntu-latest
369 environment:
370 name: production
371 url: https://www.example.com
372 steps:
373 - name: Tell the platform to roll out the new image
374 env:
375 DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }}
376 run: curl -fsS -X POST "$DEPLOY_HOOK_URL" -d "image=diffinery.com/acme/website:${{ github.sha }}"
377```
378
379## Troubleshooting
380
381| Message | Fix |
382|---|---|
383| `... need Docker, and this runner has no docker CLI.` | The self-hosted runner has no `docker` on its `PATH`. Install Docker, or run the job on a hosted runner. |
384| `... need Docker, and the Docker daemon on this runner is not reachable: ...` | Start the Docker daemon on the self-hosted machine, and make sure the runner's user may use it. |
385| `The Docker daemon did not start:` | A hosted runner couldn't start Docker. Re-run the job; if it keeps failing, report the log lines that follow. |
386| `Could not pull IMAGE (docker pull exited with N).` | The image name or tag is wrong, or it's private and needs `credentials`. |
387| `The job container (IMAGE) exited right after starting.` | The image has no `tail` command, or its startup fails. Use an image with a shell and coreutils. |
388| `Service container NAME failed its health check.` | Check the service log printed above the error, usually a missing environment variable such as `POSTGRES_PASSWORD`. |
389| `the --network option is not supported` | Remove `--network` from `options`. Services are already on the job's network. |
390| A JavaScript action fails inside an Alpine job container | The runner's Node.js needs glibc. Use a Debian or Ubuntu based image, or run the action outside the container. |
391| A registry error ending in `the workflow's GITHUB_TOKEN has no packages permission; add permissions: packages: write` | The job's `permissions` mapping leaves out `packages`. Add `packages: write` to push, or `packages: read` to pull. |
392| A push fails with a registry error ending in `the workflow's GITHUB_TOKEN has packages: read; add permissions: packages: write to push` | The job has only read access to packages, often because it has no `permissions:` and the repository default is `read`. Add `packages: write`. |