How do I run GitHub Actions locally?
To run a GitHub Actions workflow before you push, move the workflow’s steps to a place that your laptop can also run. The workflow file then only calls those steps. A portable pipeline meets four requirements: the steps have one definition, the environment is the same everywhere, the services are healthy before the tests start, and the CI file stays thin. Dagger meets all four in one place. You run dagger check on your laptop, and the workflow runs the same command in GitHub Actions or in another hosted CI.
The requirements for a portable pipeline
A workflow that runs the same way before and after you push meets four requirements:
- One definition of the steps. The commands that test the project live in one place, and your laptop and the workflow both call it. If the commands live only in YAML, you can test them only when you push.
- The same environment. The tests use the same Python version and the same Postgres and Redis images in both places. In GitHub Actions, service containers are Docker containers that GitHub starts for one job and removes when the job completes.
- Healthy services before the tests. The tests start only after Postgres and Redis accept connections. GitHub’s PostgreSQL example does this with
--health-cmd pg_isreadyin the serviceoptions. - A thin CI file. The workflow installs a tool and runs one command. A thin file does not change when the tests change, or when you move to another CI system.
A typical Python job does not meet the first and fourth requirements. The images, the health checks, and the connection URLs exist only in the workflow file:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
REDIS_URL: redis://localhost:6379/0
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10
- run: uv run pytest
What act and depot ci run give you
Two tools run a workflow before you push. Both run the workflow YAML itself, so the steps still have only one runnable form: the YAML.
- act reads
.github/workflows/and runs the jobs in local Docker containers. Its default runner images are “intentionally incomplete”, so a job can behave differently than on a GitHub-hosted runner. act also ignores some keys, such astimeout-minutesandcontinue-on-error. depot ci runsubmits a workflow from.depot/workflows/to Depot CI. It uploads your uncommitted changes as a patch and applies the patch afteractions/checkout. The job runs in a Depot sandbox, not on your laptop. Untracked files are not in the patch, so rungit addon new files first. A job that calls a reusable workflow runs without your changes.
Both tools give you a result without a commit. The steps still exist only in workflow YAML, which only GitHub Actions, act, or Depot CI can run.
Make the workflow portable by hand
Move the services into a Compose file, and the steps into commands that any shell can run. The workflow then calls the same commands. This Compose file keeps the images and health checks from the workflow:
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 5s
retries: 10
redis:
image: redis:7
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 10
Add pytest to the project’s dev dependencies. Then run the same commands on your laptop and in the workflow’s run steps:
docker compose up --wait
export DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
export REDIS_URL=redis://localhost:6379/0
uv run pytest
docker compose up --wait waits for the services to be running or healthy, so it meets the third requirement. The image tags are now in one file, so the services meet the first requirement.
Laptop and workflow test environments differ
The hand-built version meets the requirements for the services, but not for the test environment. The gaps grow with the project:
- The Python environment differs. On a laptop,
uv run pytestuses the local Python and the local system libraries. In the workflow, it uses whatsetup-uvand the runner image provide. A test can pass in one place and fail in the other. - The connection URLs live in two places. The
exportlines and the workflowenvblock must change together. - The glue is your code. Each new service, test suite, or lint step adds lines to a script and to the workflow. You can test the workflow form of each change only when you push.
Meet the requirements with Dagger
Dagger runs each step in a container that the project defines, so the steps and their environment are the same on a laptop and in CI. A Dagger module is a package of functions that build and test a project. A check is a function that dagger check runs, and that passes or fails. The official Pytest module finds the Python projects and test files, and runs pytest as a check.
Initialize the workspace. Then install the Pytest module and the Dang SDK:
dagger init
dagger module install dagger.io/python/pytest
dagger module install dagger.io/sdk/dang
dagger init creates dagger.toml, the workspace configuration for everyone who uses the repository. Dagger also writes dagger.lock, which pins each module and image to an exact version. Commit both files.
The tests need Postgres and Redis. The Pytest module’s base setting takes the container that runs the tests, with Python and uv. A small module supplies that container, with the services bound to it. Dang is Dagger’s native language for modules. Create the project’s dev module:
dagger module init dang -y
The command creates .dagger/modules/shop-dev, named after the project directory, and installs it as the workspace main module. In the snippets, shop-dev and ShopDev stand in for the names that dagger module init generated. Replace .dagger/modules/shop-dev/main.dang with this code:
"""
Test services for the shop project: Postgres and Redis, the same as in CI.
"""
type ShopDev {
"""
Postgres, with the image and password that CI uses.
"""
postgres: Service! {
container
.from("postgres:16")
.withEnvVariable("POSTGRES_PASSWORD", "postgres")
.withExposedPort(5432)
.asService
}
"""
Redis, with the image that CI uses.
"""
redis: Service! {
container
.from("redis:7")
.withExposedPort(6379)
.asService
}
"""
The pytest container: Python and uv, with Postgres and Redis bound to it.
"""
testBase: Container! {
container
.from("ghcr.io/astral-sh/uv:python3.12-trixie-slim")
.withServiceBinding("postgres", postgres)
.withServiceBinding("redis", redis)
.withEnvVariable("DATABASE_URL", "postgresql://postgres:postgres@postgres:5432/postgres")
.withEnvVariable("REDIS_URL", "redis://redis:6379/0")
}
}
Give the Pytest module this container as its base:
dagger module settings pytest base test-base
The command writes a module reference to dagger.toml:
[modules.pytest.settings]
base = "dag://shop-dev/test-base"
One definition of the steps
dagger.toml and the dev module hold the whole test pipeline. List the checks:
dagger check -l
MODULE PYTEST-PROJECT PYTEST-TEST-FILE CHECK DESCRIPTION
dang-sdk stale staleness check: re-generate modules and clients managed by the Dang SDK
pytest * * test Run this test file.
# Use --all to expand collections and list each item.
Run every check on your laptop before you push:
dagger check
CHECKS ✔ 2 passed
✔ dag://?check=dang-sdk/generate/stale 0.7s OK
✔ dag://?check=test&pytest-project=. 0.6s OK
A failure names the test, and prints the command that runs the same selection again:
✘ tests/test_services.py › test_redis FAIL
... 9 earlier log lines ...
E - b'x'
E ? ^
E + b'v'
E ? ^
tests/test_services.py:12: AssertionError
✘ 1 failed
✔ 1 passed
✔ dag://?check=dang-sdk/generate/stale 3.6s OK
RUN LOCALLY
dagger check "dag://?check=test&pytest-project=."
To run one test file, select it with --pytest-test-file=tests/test_services.py. Dagger reads the files in the working tree, so a new test file runs without git add.
The same environment
The tests run in ghcr.io/astral-sh/uv:python3.12-trixie-slim on every machine, not in the host’s Python. The services use postgres:16 and redis:7, the same images as the original workflow.
Healthy services before the tests
withServiceBinding makes each service reachable from the test container by its name, such as postgres. Dagger health-checks a service before any client can connect, so the module needs no health-check flags. To use the same services with an app on your laptop, list them, and start one with dagger start postgres. Dagger tunnels the service’s ports to your machine.
dagger start -l
MODULE SERVICE DESCRIPTION
shop-dev postgres Postgres, with the image and password that CI uses.
shop-dev redis Redis, with the image that CI uses.
A thin CI file
The workflow installs Dagger and runs dagger check. It holds no images, services, or test commands. Replace the job in .github/workflows/ci.yml:
name: ci
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dagger/dagger-for-github@v8.4.1
with:
version: "1.0.0-beta.21"
verb: check
The dagger-for-github action installs the Dagger version that you pin. Use the version that dagger version prints on your laptop.
If you move to Depot CI, depot ci migrate copies this workflow to .depot/workflows/ and remaps runs-on to a Depot label. The test steps stay in the Dagger module, so the migration does not touch them.
Run the checks with no workflow file
Cloud Checks runs dagger check on Dagger’s Cloud Engines for every push and pull request to a GitHub repository, with no CI workflow file. The results show as GitHub check statuses. From the repository root, connect the repository to Dagger Cloud:
dagger cloud checks on
The checks that run after each push are the same checks that dagger check runs on your laptop.
Bottom line: A workflow whose steps exist only in YAML cannot run on your laptop the same way it runs in CI. Tools that imitate the runner, or run the workflow remotely, still depend on that YAML. Put the steps, the test container, and the Postgres and Redis services in Dagger, and run dagger check before you push. GitHub Actions, Depot CI, or Cloud Checks then run the same command.
Related guides
- How do I migrate from GitHub Actions to Dagger?: move the other jobs, secrets, and deployments, and switch the required checks.
- How do I run GitLab CI jobs locally?: the same approach for GitLab CI jobs.
- How do I run a Buildkite pipeline locally?: the same approach for a Buildkite pipeline.