Running dbt on GitHub Actions
Self-hosted dbt job runner using GitHub Actions, replacing dbt Cloud / Fivetran Transformations for automated builds, scheduled runs, and API-triggered orchestration.
A complete how-to for running your dbt project on GitHub Actions instead of dbt Cloud (now Fivetran Transformations). Covers scheduled runs, push triggers, Slack notifications, and API-triggered orchestration.
For the why, see the blog post: Replace dbt Cloud with a Free GitHub Actions Runner.
What you get
- Push triggers — every merge to
masterrunsdbt build. - Scheduled runs — nightly (or hourly, every 15 min, whatever you want) via cron.
- Manual triggers — kick off a run from the GitHub UI with a target / command picker.
- API triggers —
POSTto GitHub's workflow_dispatch endpoint to trigger runs from your app, Convex, Lambda, or any other system that can speak HTTP. - Slack notifications — success and failure messages with a link to the run.
- Run artifacts —
manifest.json,run_results.json, and the full dbt log saved on every run for debugging.
Architecture
your-dbt-repo/
├── .github/workflows/
│ └── dbt.yml # The workflow
├── profiles/
│ └── profiles.yml # CI profile (uses env vars for credentials)
├── models/
├── dbt_project.yml
└── packages.yml
One workflow file, one profiles file. That's it. Everything else is your existing dbt project.
Prerequisites
- A GitHub repo containing your dbt project.
- A service account / user for your warehouse with the right permissions (BigQuery service account JSON, or Snowflake user with key-pair auth).
- A Slack incoming webhook URL (optional but recommended).
1. Create the workflow file
Create .github/workflows/dbt.yml:
name: dbt
on:
push:
branches:
- main
- master
schedule:
- cron: '0 6 * * *' # 6 AM UTC daily
workflow_dispatch:
inputs:
target:
description: 'dbt target'
required: false
default: 'prod'
type: choice
options:
- dev
- prod
dbt_command:
description: 'dbt command'
required: false
default: 'build'
type: choice
options:
- build
- run
- test
- seed
selector:
description: 'dbt selector (e.g., --select tag:nightly)'
required: false
default: ''
type: string
env:
DBT_PROFILES_DIR: ${{ github.workspace }}/profiles
jobs:
dbt-run:
name: dbt ${{ inputs.dbt_command || 'build' }} (${{ inputs.target || 'prod' }})
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dbt
run: |
pip install --upgrade pip
pip install dbt-bigquery # or dbt-snowflake, dbt-postgres, etc.
- name: Set up BigQuery credentials
run: |
mkdir -p ~/.dbt
echo "${{ secrets.BIGQUERY_KEYFILE }}" | base64 -d > ~/.dbt/keyfile.json
echo "BIGQUERY_KEYFILE=$HOME/.dbt/keyfile.json" >> $GITHUB_ENV
- name: Install dbt packages
run: dbt deps
- name: Run dbt
run: |
set -o pipefail
dbt ${{ inputs.dbt_command || 'build' }} \
--target ${{ inputs.target || 'prod' }} \
${{ inputs.selector }} \
2>&1 | tee dbt_output.log
- name: Upload dbt artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: dbt-artifacts-${{ github.run_id }}
path: |
target/manifest.json
target/run_results.json
dbt_output.log
retention-days: 7
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "dbt Job Failed",
"emoji": true
}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": "*Target:*\n${{ inputs.target || 'prod' }}"},
{"type": "mrkdwn", "text": "*Command:*\ndbt ${{ inputs.dbt_command || 'build' }}"},
{"type": "mrkdwn", "text": "*Trigger:*\n${{ github.event_name }}"}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: Notify Slack on success
if: success()
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "dbt Job Succeeded",
"emoji": true
}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": "*Target:*\n${{ inputs.target || 'prod' }}"},
{"type": "mrkdwn", "text": "*Command:*\ndbt ${{ inputs.dbt_command || 'build' }}"}
]
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
2. Create CI profiles
Create profiles/profiles.yml in the repo root. This is a CI-only profile —
your local dev profile in ~/.dbt/profiles.yml is unaffected.
BigQuery
my_project:
target: prod
outputs:
dev:
type: bigquery
method: service-account
keyfile: "{{ env_var('BIGQUERY_KEYFILE') }}"
project: my-gcp-project
dataset: development
location: US
threads: 4
job_execution_timeout_seconds: 300
prod:
type: bigquery
method: service-account
keyfile: "{{ env_var('BIGQUERY_KEYFILE') }}"
project: my-gcp-project
dataset: production
location: US
threads: 4
job_execution_timeout_seconds: 300
The profile name (my_project here) must match the profile: field in your
dbt_project.yml.
Snowflake
For Snowflake, swap the Install dbt step to pip install dbt-snowflake and
the credentials step:
- name: Set up Snowflake credentials
run: |
mkdir -p ~/.dbt
echo "${{ secrets.SNOWFLAKE_PRIVATE_KEY }}" | base64 -d > ~/.dbt/snowflake_key.p8
echo "SNOWFLAKE_PRIVATE_KEY_PATH=$HOME/.dbt/snowflake_key.p8" >> $GITHUB_ENV
echo "SNOWFLAKE_ACCOUNT=${{ secrets.SNOWFLAKE_ACCOUNT }}" >> $GITHUB_ENV
echo "SNOWFLAKE_USER=${{ secrets.SNOWFLAKE_USER }}" >> $GITHUB_ENV
Profiles:
my_project:
target: prod
outputs:
prod:
type: snowflake
account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
user: "{{ env_var('SNOWFLAKE_USER') }}"
private_key_path: "{{ env_var('SNOWFLAKE_PRIVATE_KEY_PATH') }}"
role: TRANSFORMER
database: ANALYTICS
warehouse: TRANSFORMING
schema: production
threads: 4
3. Add GitHub secrets
Go to your repo → Settings → Secrets and variables → Actions.
BigQuery
| Secret | Description |
|---|---|
BIGQUERY_KEYFILE |
Base64-encoded service account JSON |
SLACK_WEBHOOK_URL |
Slack incoming webhook URL (optional) |
To encode the keyfile:
base64 -i /path/to/keyfile.json | pbcopy
Then paste in the secret value field.
Snowflake
| Secret | Description |
|---|---|
SNOWFLAKE_ACCOUNT |
Account identifier (e.g. xy12345.us-east-1) |
SNOWFLAKE_USER |
Service user name |
SNOWFLAKE_PRIVATE_KEY |
Base64-encoded PKCS8 private key |
SLACK_WEBHOOK_URL |
Slack incoming webhook URL (optional) |
4. Commit, push, and merge
The workflow only appears in the GitHub Actions UI once the workflow file is on the default branch. Open a PR, get it green, and merge.
Triggering runs
From the GitHub UI
- Go to the Actions tab in your repo.
- Select the dbt workflow.
- Click Run workflow.
- Pick
target,dbt_command, and an optional selector. Click Run.
From the CLI
# Trigger a default build on prod
gh workflow run dbt.yml
# Trigger with options
gh workflow run dbt.yml \
-f target=dev \
-f dbt_command=run \
-f selector='--select tag:nightly'
# List recent runs
gh run list --workflow=dbt.yml
# View logs for a specific run
gh run view <run-id> --log
From an API call
This is what makes the runner useful as an orchestration target. Any system
that can POST to GitHub can trigger a dbt run:
await fetch(
'https://api.github.com/repos/<owner>/<repo>/actions/workflows/dbt.yml/dispatches',
{
method: 'POST',
headers: {
Authorization: `Bearer ${githubToken}`,
Accept: 'application/vnd.github+json',
},
body: JSON.stringify({
ref: 'master',
inputs: { target: 'prod', dbt_command: 'build' },
}),
},
)
The githubToken needs actions:write permission on the repo. A
fine-grained personal access token
or a GitHub App installation token both work.
Triggers reference
| Trigger | When | Default target |
|---|---|---|
push |
Any commit to main / master |
prod |
schedule |
6 AM UTC daily (configurable cron) | prod |
workflow_dispatch |
Manual or API trigger | configurable |
To run more often than nightly, change the cron. GitHub Actions cron supports
down to 5-minute intervals — */15 * * * * runs every 15 minutes.
To skip runs when only docs change, add a paths-ignore filter to the push
trigger:
on:
push:
branches: [main, master]
paths-ignore:
- '**.md'
- 'docs/**'
Notifications
Slack messages are posted on both success and failure with the target, command, trigger source, and a link to the run.
To send only failures, delete the Notify Slack on success step.
To send to multiple channels, add additional Slack steps with different webhook secrets, or use Slack's routing in the webhook itself.
Troubleshooting
Authentication errors
Check that BIGQUERY_KEYFILE is correctly base64-encoded and that the service
account has BigQuery Job User and BigQuery Data Editor (or equivalent) on
the target datasets.
# Sanity check the encoding round-trips
base64 -i keyfile.json | base64 -d | jq .
dbt deps failure
Make sure every package in packages.yml is publicly accessible, or that the
runner has SSH credentials configured if you depend on a private GitHub repo.
For a private dbt package on GitHub, add a deploy key with read access on the
package repo and add an SSH agent step before dbt deps:
- name: Set up SSH for private packages
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DBT_PACKAGE_DEPLOY_KEY }}
Workflow not visible in the Actions tab
The workflow file has to be on the default branch (usually main or master).
Open a PR and merge it. After that, all subsequent triggers — including manual
runs from any branch — work normally.
A test fails and blocks the build
You can disable a single dbt test temporarily by adding
{{ config(enabled=false) }} to the top of its .sql file (for singular
tests), or by setting the test to severity: warn in YAML.
Cost
GitHub Actions on public repos is unlimited. On private repos, the free tier includes 2,000 minutes/month for the Free plan, 3,000 for Pro, and tiered allowances for Team and Enterprise.
A typical dbt build on a small-to-medium project takes 3–8 minutes. Even at 8 minutes per run, hourly all day every day is ~5,800 minutes/month — fits in Team plan ($4/user/month) or you bump to a paid runner overage at $0.008/minute.
What this replaces
This setup replaces dbt Cloud / Fivetran Transformations as the orchestration and scheduling layer. You keep your dbt project exactly as-is. What changes:
- Schedules move from the dbt Cloud UI to
cronindbt.yml. - API triggers move from dbt Cloud's job API to GitHub's workflow_dispatch.
- Webhooks/notifications move from dbt Cloud to Slack via this workflow.
- The "IDE" and lineage browser go away. Use dbt docs
with
dbt docs generate && dbt docs servelocally, or host the generated static site on GitHub Pages / Cloudflare Pages.
For more on the why, see Replace dbt Cloud with a Free GitHub Actions Runner.