
I tried CI/CD with dbt Projects on Snowflake using GitHub Actions + OIDC authentication
This page has been translated by machine translation. View original
This is Kawabata.
With dbt Projects on Snowflake, you can now run dbt projects directly on Snowflake, and there are many cases where you'd want to incorporate CI/CD into your development flow. One thing that comes to mind is how to authenticate from GitHub Actions to Snowflake. The approach of storing passwords or PATs (Programmatic Access Tokens) in GitHub Secrets comes with the operational burden of managing and rotating long-lived secrets.
In this article, we'll use GitHub Actions OIDC tokens and Snowflake's Workload Identity Federation to build a CI that runs dbt build in a dev environment on PRs and a CD that deploys to production with snow dbt deploy on merges to main — all without long-lived secrets.
Architecture Overview
CI/CD for dbt Projects on Snowflake
With dbt Projects on Snowflake, dbt projects are deployed as schema-level DBT PROJECT objects and executed on Snowflake. The official documentation describes the following CI/CD setup:
- CI: For each Pull Request, deploy a DBT PROJECT object for testing and run
dbt buildagainst the dev environment - CD: After merging to the main branch, update the production DBT PROJECT object with
snow dbt deploy
snow dbt deploy is a versioned deployment, where each deployment stacks a new version. Execution history is also retained, allowing you to track when and which version was executed.
Authentication via OIDC (Workload Identity Federation)
Using Snowflake's Workload Identity Federation, GitHub Actions can authenticate to Snowflake using short-lived OIDC tokens. Only the account identifier needs to be stored in GitHub Secrets — no passwords, PATs, or key pairs are required.
The mechanism works by registering the ISSUER (token issuer) and SUBJECT (the token's sub claim) as WORKLOAD_IDENTITY for a user of TYPE = SERVICE, and Snowflake validates the GitHub OIDC token on its side.
sub claim and Service User Design
The sub claim of GitHub Actions OIDC tokens changes format depending on the triggering event.
| Event | sub claim format |
|---|---|
| pull_request | repo:<org>/<repo>:pull_request |
| push (branch) | repo:<org>/<repo>:ref:refs/heads/<branch> |
| Jobs with environment specified | repo:<org>/<repo>:environment:<name> |
Snowflake service users require an exact match between the SUBJECT and the sub claim. The official tutorial specifies environment: prod for both CI/CD jobs to unify the sub claim and configure everything with a single service user.
Since we're going with a simple setup that doesn't use GitHub Environments, we'll create two service users: one for CI (pull_request) and one for CD (push to main). Separating the authentication principals makes it easy to evolve into a permission separation where the CI user only has access to dev and the CD user only to prod.
Note that for simplicity in this article's verification, both users are granted a common role (dbt_cicd_role), meaning the CI user can also access prod in this configuration as-is. If you want to separate permissions, split the roles into dbt_ci_role (dev and raw only) / dbt_cd_role (prod and raw only), grant each to the respective service user, and set the role in each target of profiles.yml accordingly.
Prerequisites
- Snowflake account (ACCOUNTADMIN-equivalent permissions required for creating service users)
- A repository with GitHub Actions enabled
- Snowflake CLI 3.11.0 or later (required for OIDC authentication; introduced via
snowflakedb/snowflake-actions@v3) - dbt project: This article uses jaffle-shop (a sample project from dbt Labs)
- dbt version: dbt Core 1.11.11 specified
- This article assumes the default branch is
main. If you're using a different branch name likemaster, update thebranchesin the workflow and the SUBJECT of the CD service user to match your actual branch name
Setup
Creating the Snowflake Environment
Create the database, dev/prod schemas, warehouse, and a role for CI/CD. The official tutorial uses ACCOUNTADMIN directly, but here we grant minimal permissions to a dedicated role.
Creating the Snowflake Environment
USE ROLE ACCOUNTADMIN;
CREATE DATABASE IF NOT EXISTS jaffle_shop_db;
CREATE SCHEMA IF NOT EXISTS jaffle_shop_db.dev;
CREATE SCHEMA IF NOT EXISTS jaffle_shop_db.prod;
CREATE SCHEMA IF NOT EXISTS jaffle_shop_db.raw; -- For source data (seeds)
CREATE WAREHOUSE IF NOT EXISTS jaffle_shop_wh
WAREHOUSE_SIZE = XSMALL
AUTO_SUSPEND = 60
AUTO_RESUME = TRUE
INITIALLY_SUSPENDED = TRUE;
CREATE ROLE IF NOT EXISTS dbt_cicd_role;
GRANT USAGE ON DATABASE jaffle_shop_db TO ROLE dbt_cicd_role;
GRANT USAGE ON WAREHOUSE jaffle_shop_wh TO ROLE dbt_cicd_role;
GRANT ALL ON SCHEMA jaffle_shop_db.dev TO ROLE dbt_cicd_role;
GRANT ALL ON SCHEMA jaffle_shop_db.prod TO ROLE dbt_cicd_role;
GRANT ALL ON SCHEMA jaffle_shop_db.raw TO ROLE dbt_cicd_role;
GRANT CREATE DBT PROJECT ON SCHEMA jaffle_shop_db.dev TO ROLE dbt_cicd_role;
GRANT CREATE DBT PROJECT ON SCHEMA jaffle_shop_db.prod TO ROLE dbt_cicd_role;
GRANT CREATE SCHEMA ON DATABASE jaffle_shop_db TO ROLE dbt_cicd_role;
CREATE DBT PROJECT is the permission required for snow dbt deploy. Also, since dbt checks for the existence of the target schema and creates it (CREATE IF NOT EXISTS) at runtime, even if the schema was created in advance, dbt build will fail with a permission error if CREATE SCHEMA permission on the database is missing (this is an error I actually encountered during testing). Details on access control are covered in the documentation below.
Adjusting the dbt Project (jaffle-shop)
For dbt Projects on Snowflake, profiles.yml is required at the project root in addition to dbt_project.yml. Define two targets: dev and prod.
profiles.yml
jaffle_shop:
target: dev
outputs:
dev:
type: snowflake
account: '_' # Placeholder. Not used since execution happens inside Snowflake
user: '_' # Same as above
role: DBT_CICD_ROLE
database: JAFFLE_SHOP_DB
schema: DEV
warehouse: JAFFLE_SHOP_WH
threads: 8
prod:
type: snowflake
account: '_'
user: '_'
role: DBT_CICD_ROLE
database: JAFFLE_SHOP_DB
schema: PROD
warehouse: JAFFLE_SHOP_WH
threads: 8
Note: Since execution happens inside Snowflake, placeholder strings are fine for
accountanduser. No password is needed either. However,type/database/schema/role/warehouseare required. Furthermore,snow dbt deployvalidates the contents of profiles.yml against the actual environment before deploying — ifroleorwarehousedoesn't exist or isn't accessible from the connecting user, it will be rejected with an error like "Role 'XXX' does not exist or is not accessible." (all targets are validated). Make sure the names match exactly with your actual environment objects.
Creating OIDC Service Users
Create two users: one for CI and one for CD. Set the SUBJECT to exactly match the sub claim of each event.
Creating OIDC Service Users
USE ROLE ACCOUNTADMIN;
-- For CI (pull_request event)
CREATE USER IF NOT EXISTS svc_gha_dbt_ci
TYPE = SERVICE
WORKLOAD_IDENTITY = (
TYPE = OIDC
ISSUER = 'https://token.actions.githubusercontent.com'
SUBJECT = 'repo:<your-org>/<your-dbt-repo>:pull_request'
)
DEFAULT_ROLE = dbt_cicd_role
DEFAULT_WAREHOUSE = jaffle_shop_wh
COMMENT = 'GitHub Actions CI (pull_request) service user';
-- For CD (push to main event)
CREATE USER IF NOT EXISTS svc_gha_dbt_cd
TYPE = SERVICE
WORKLOAD_IDENTITY = (
TYPE = OIDC
ISSUER = 'https://token.actions.githubusercontent.com'
SUBJECT = 'repo:<your-org>/<your-dbt-repo>:ref:refs/heads/main'
)
DEFAULT_ROLE = dbt_cicd_role
DEFAULT_WAREHOUSE = jaffle_shop_wh
COMMENT = 'GitHub Actions CD (push to main) service user';
GRANT ROLE dbt_cicd_role TO USER svc_gha_dbt_ci;
GRANT ROLE dbt_cicd_role TO USER svc_gha_dbt_cd;
Note: The SUBJECT must exactly match the sub claim. Replace
<your-org>/<your-dbt-repo>with your repository's actual path. Case sensitivity also matters.
I confirmed that WORKLOAD_IDENTITY (TYPE=OIDC) is registered as intended with the ISSUER/SUBJECT.

GitHub Repository Settings
Open Settings, select Secrets and variables → Actions. Click New repository secret and register just one secret.
SNOWFLAKE_ACCOUNT: Account identifier (in<orgname>-<account_name>format)
That's the only secret you need to register. The fact that no passwords or PATs are needed is a major advantage of OIDC authentication.

Creating the CI Workflow
Create .github/workflows/ci.yml. Triggered by PRs, this deploys a test DBT PROJECT object to the dev schema and runs dbt build against the dev target.
name: CI - dbt build on PR
run-name: PR by ${{ github.actor }}
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: [main]
concurrency:
group: ci-dev
cancel-in-progress: false
permissions:
contents: read
id-token: write # Required for OIDC token issuance
jobs:
dbt-ci:
runs-on: ubuntu-latest
env:
SNOWFLAKE_CLI_FEATURES_ENABLE_DBT: true
SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
SNOWFLAKE_ROLE: DBT_CICD_ROLE
SNOWFLAKE_WAREHOUSE: JAFFLE_SHOP_WH
SNOWFLAKE_DATABASE: JAFFLE_SHOP_DB
SNOWFLAKE_SCHEMA: DEV
steps:
- uses: actions/checkout@v4
- name: Install dbt and resolve dependencies
run: |
pip install 'dbt-core==1.11.*'
dbt deps
- name: Install Snowflake CLI
uses: snowflakedb/snowflake-actions@v3
with:
use-oidc: true
- name: Test connection
run: snow connection test -x
- name: Deploy tester dbt project object
run: snow dbt deploy ci_jaffle_shop --source . --dbt-version 1.11.11 -x
- name: Build and test on dev
run: snow dbt execute -x ci_jaffle_shop build --target dev
Key points:
id-token: writeinpermissionsis required for OIDC token issuance- Passing
use-oidc: truetosnowflakedb/snowflake-actions@v3automatically sets authentication environment variables (such asSNOWFLAKE_AUTHENTICATOR=WORKLOAD_IDENTITY) - The environment variable
SNOWFLAKE_CLI_FEATURES_ENABLE_DBT: trueis required to usesnow dbtcommands -xis the temporary connection option, which connects based on environment variables without a config file- Explicitly specifying
SNOWFLAKE_ROLE/SNOWFLAKE_WAREHOUSEensures the connection context is set without relying on the service user's default role settings (leaving it unspecified when the default role isn't effective results in a "Could not use database" error) concurrencyserializes CI runs. Since all PRs share the sameci_jaffle_shopobject, concurrentEXECUTE DBT PROJECTcalls on the same DBT PROJECT object are not supported
Creating the CD Workflow
Create .github/workflows/deploy.yml. Triggered by pushes to main, this deploys the production DBT PROJECT object to the prod schema, then runs build against the prod target.
name: CD - deploy dbt project on merge
run-name: Deploy by ${{ github.actor }}
on:
push:
branches: [main]
concurrency:
group: cd-prod
cancel-in-progress: false
permissions:
contents: read
id-token: write
jobs:
dbt-deploy:
runs-on: ubuntu-latest
env:
SNOWFLAKE_CLI_FEATURES_ENABLE_DBT: true
SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
SNOWFLAKE_ROLE: DBT_CICD_ROLE
SNOWFLAKE_WAREHOUSE: JAFFLE_SHOP_WH
SNOWFLAKE_DATABASE: JAFFLE_SHOP_DB
SNOWFLAKE_SCHEMA: PROD
steps:
- uses: actions/checkout@v4
- name: Install dbt and resolve dependencies
run: |
pip install 'dbt-core==1.11.*'
dbt deps
- name: Install Snowflake CLI
uses: snowflakedb/snowflake-actions@v3
with:
use-oidc: true
- name: Test connection
run: snow connection test -x
- name: Deploy production dbt project object
run: snow dbt deploy jaffle_shop --source . --default-target prod --dbt-version 1.11.11 -x
- name: Build models on prod
run: snow dbt execute -x jaffle_shop build --target prod
- name: List dbt project objects
run: snow dbt list -x
For CD, --default-target prod is specified to set the default target of the DBT PROJECT object to prod. Initially I tested with a deploy-only setup, but deploying alone only registers a new version of the DBT PROJECT object — the tables in the prod schema aren't updated — so I added a build step to ensure data is reflected on merge. For production environments with many models, separating build into a scheduled Snowflake Task and having CD handle only deployment is another option.
concurrency here is for serializing CD runs. Since concurrent EXECUTE DBT PROJECT on the same DBT PROJECT object is not supported, cancel-in-progress: false queues subsequent runs to prevent build conflicts when merges happen in quick succession.
Results
Confirming OIDC Authentication
When the workflow is pushed to main, CD starts. First, confirm that OIDC authentication succeeds via the snow connection test -x output.

CI: Tests Run on PR Creation
When a minor change is made to a model on a feature branch and a PR is created, the CI workflow starts.
In this case, I renamed some columns in customers.sql.

Create the PR.

In CI, the test object ci_jaffle_shop is deployed to the dev schema and dbt build is run against the dev target.

At this point, you can confirm that the changes are reflected in the dev schema while the prod schema remains unchanged.

CD: Production Object Updated on Merge
When the PR is merged, the CD workflow starts, the production object is deployed as a new version, and the tables and views in the prod schema are also updated by the subsequent build step.


The changes were confirmed to be reflected in the prod schema as well.

Confirming CI Blocks on Test Failure
When a PR is created with a change that intentionally causes test failures (such as a not_null violation), CI fails. Combined with branch protection rules, this allows you to block merging changes that don't pass tests.
For this test, I appended the following to customers.sql:
-- test: intentionally duplicate customer_id to fail the unique test
select * from joined
union all
(select * from joined limit 1)

Limitations and Notes
snow dbt deploy --forcebehaves asCREATE OR REPLACE DBT PROJECTand will delete existing versions and execution history. It is not recommended for use in normal CD pipelines- DBT PROJECT objects cannot be executed with Serverless Tasks; a user-managed warehouse is required
- Concurrent
EXECUTE DBT PROJECTcalls on the same DBT PROJECT object are not supported - dbt Cloud projects are not supported (dbt Core / dbt Fusion only)
- If you're running network policies on your account, you'll need to add the Snowflake-managed network rule
SNOWFLAKE.NETWORK_SECURITY.GITHUBACTIONS_GLOBALto the allowlist for connections from GitHub-hosted runners - While this article uses two service users for simplicity, for production use it is recommended to configure GitHub Environments (
environment: prod) with required reviewers and set the SUBJECT torepo:<org>/<repo>:environment:prod. The deploy job won't run until passing the approval gate, resulting in a safer CD - The sub claim specification for GitHub OIDC tokens may change or be extended, so also check the official GitHub documentation when implementing
Closing
Using GitHub Actions OIDC tokens and Workload Identity Federation, we were able to build CI/CD for dbt Projects on Snowflake with only the account identifier stored in GitHub Secrets.
I hope this article is helpful to someone!