CI/CD Pipelines and Slim CI on Redshift
Build production-grade CI/CD pipelines for dbt-core projects on AWS: GitHub Actions workflows, Slim CI with state comparison, artifact management in S3, and deployment patterns using Amazon ECS and AWS Step Functions.
CI/CD Pipelines and Slim CI on Redshift
A dbt project without a CI/CD pipeline is a liability waiting to be discovered in production. This module builds a full production deployment pipeline for dbt-core on AWS: GitHub Actions for CI, Slim CI using dbt state comparison, artifact storage in S3, and deployment with Amazon ECS Fargate.
Pipeline Architecture Overview
Slim CI: State-Based Comparison
Slim CI runs dbt only on models that changed since the last production run. It requires:
- A production manifest (
manifest.json) stored in S3 --statepointing to that manifest--select state:modified+to select changed models and their descendants
How it works
S3 Artifact Structure
s3://my-dbt-artifacts/
βββ prod/
β βββ latest/
β β βββ manifest.json
β β βββ run_results.json
β β βββ catalog.json
β βββ 2024-03-15T14:30:00/
β βββ manifest.json
β βββ run_results.json
βββ ci/
βββ pr-123/
βββ manifest.json
βββ run_results.json
GitHub Actions CI Workflow
# .github/workflows/dbt-ci.yml
name: dbt CI
on:
pull_request:
branches: [main]
paths:
- 'models/**'
- 'macros/**'
- 'tests/**'
- 'snapshots/**'
- 'seeds/**'
- 'dbt_project.yml'
- 'packages.yml'
env:
AWS_REGION: us-east-1
DBT_ARTIFACT_BUCKET: my-dbt-artifacts
jobs:
dbt-ci:
runs-on: ubuntu-latest
permissions:
id-token: write # for OIDC authentication to AWS
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dbt
run: |
pip install \
dbt-core==1.10.4 \
dbt-redshift==1.10.1
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GithubActionsDBTRole
aws-region: ${{ env.AWS_REGION }}
- name: Download prod manifest for Slim CI
run: |
mkdir -p ./prod-artifacts
aws s3 cp s3://${{ env.DBT_ARTIFACT_BUCKET }}/prod/latest/manifest.json \
./prod-artifacts/manifest.json \
|| echo "No prod manifest found β running full CI"
- name: Install dbt packages
run: dbt deps
env:
DBT_PROFILES_DIR: ./ci-profiles
- name: dbt compile (contract + Jinja validation)
run: dbt compile
env:
DBT_PROFILES_DIR: ./ci-profiles
DBT_TARGET: ci
- name: Run unit tests
run: dbt test --select "test_type:unit"
env:
DBT_PROFILES_DIR: ./ci-profiles
DBT_TARGET: ci
- name: Slim CI β build changed models + tests
run: |
if [ -f ./prod-artifacts/manifest.json ]; then
dbt build \
--select "state:modified+" \
--defer \
--state ./prod-artifacts \
--exclude "test_type:unit" \
--target ci
else
dbt build \
--select "+marts" \
--exclude "test_type:unit" \
--target ci
fi
env:
DBT_PROFILES_DIR: ./ci-profiles
- name: Upload CI artifacts to S3
if: always()
run: |
aws s3 sync ./target/ \
s3://${{ env.DBT_ARTIFACT_BUCKET }}/ci/pr-${{ github.event.pull_request.number }}/CI profiles.yml
# ci-profiles/profiles.yml
my_analytics:
target: ci
outputs:
ci:
type: redshift
method: iam
host: ${{ secrets.REDSHIFT_CI_HOST }}
port: 5439
dbname: analytics_ci
schema: "dbt_ci_pr_{{ env_var('PR_NUMBER', 'local') }}"
region: us-east-1
threads: 4
keepalives_idle: 240Dynamically naming the CI schema with the PR number (dbt_ci_pr_123) ensures each PR gets its own isolated schema. Add a cleanup job that drops these schemas after PR merge.
CD: Deployment with Amazon ECS Fargate
Dockerfile
# Dockerfile
FROM python:3.12-slim
RUN pip install --no-cache-dir \
dbt-core==1.10.4 \
dbt-redshift==1.10.1
WORKDIR /dbt
COPY . .
RUN dbt deps
ENTRYPOINT ["dbt"]ECS Task Definition (Terraform)
# infrastructure/ecs_task.tf
resource "aws_ecs_task_definition" "dbt_run" {
family = "dbt-analytics-run"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "2048"
memory = "4096"
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.dbt_task.arn
container_definitions = jsonencode([{
name = "dbt"
image = "${aws_ecr_repository.dbt.repository_url}:latest"
environment = [
{ name = "DBT_TARGET", value = "prod" }
]
secrets = [
{
name = "REDSHIFT_HOST"
valueFrom = "${aws_secretsmanager_secret.redshift.arn}:host::"
},
{
name = "REDSHIFT_USER"
valueFrom = "${aws_secretsmanager_secret.redshift.arn}:user::"
},
{
name = "REDSHIFT_PASSWORD"
valueFrom = "${aws_secretsmanager_secret.redshift.arn}:password::"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = "/ecs/dbt-analytics"
"awslogs-region" = "us-east-1"
"awslogs-stream-prefix" = "ecs"
}
}
}])
}Step Functions State Machine
{
"Comment": "dbt production pipeline",
"StartAt": "SourceFreshness",
"States": {
"SourceFreshness": {
"Type": "Task",
"Resource": "arn:aws:states:::ecs:runTask.sync",
"Parameters": {
"Cluster": "${ECS_CLUSTER_ARN}",
"TaskDefinition": "${TASK_DEF_ARN}",
"Overrides": {
"ContainerOverrides": [{
"Name": "dbt",
"Command": ["source", "freshness"]
}]
}
},
"Next": "DbtBuild",
"Catch": [{
"ErrorEquals": ["States.ALL"],
"Next": "NotifyFailure"
}]
},
"DbtBuild": {
"Type": "Task",
"Resource": "arn:aws:states:::ecs:runTask.sync",
"Parameters": {
"Cluster": "${ECS_CLUSTER_ARN}",
"TaskDefinition": "${TASK_DEF_ARN}",
"Overrides": {
"ContainerOverrides": [{
"Name": "dbt",
"Command": ["build", "--select", "+marts", "--exclude", "test_type:unit"]
}]
}
},
"Next": "UploadArtifacts",
"Catch": [{
"ErrorEquals": ["States.ALL"],
"Next": "NotifyFailure"
}]
},
"UploadArtifacts": {
"Type": "Task",
"Resource": "arn:aws:states:::ecs:runTask.sync",
"Parameters": {
"Cluster": "${ECS_CLUSTER_ARN}",
"TaskDefinition": "${TASK_DEF_ARN}",
"Overrides": {
"ContainerOverrides": [{
"Name": "dbt",
"Command": ["run-operation", "upload_artifacts_to_s3"]
}]
}
},
"End": true,
"Catch": [{
"ErrorEquals": ["States.ALL"],
"Next": "NotifyFailure"
}]
},
"NotifyFailure": {
"Type": "Task",
"Resource": "arn:aws:states:::sns:publish",
"Parameters": {
"TopicArn": "${SNS_FAILURE_TOPIC}",
"Message.$": "States.Format('dbt pipeline failed: {}', $.Error)"
},
"End": true
}
}
}Macro: Upload Artifacts to S3
-- macros/ops/upload_artifacts_to_s3.sql
{% macro upload_artifacts_to_s3() %}
{% set bucket = env_var('DBT_ARTIFACT_BUCKET') %}
{% set timestamp = run_started_at.strftime('%Y-%m-%dT%H:%M:%S') %}
{% set commands = [
"aws s3 cp target/manifest.json s3://" ~ bucket ~ "/prod/latest/manifest.json",
"aws s3 cp target/manifest.json s3://" ~ bucket ~ "/prod/" ~ timestamp ~ "/manifest.json",
"aws s3 cp target/run_results.json s3://" ~ bucket ~ "/prod/latest/run_results.json",
"aws s3 cp target/run_results.json s3://" ~ bucket ~ "/prod/" ~ timestamp ~ "/run_results.json"
] %}
{% for cmd in commands %}
{{ log("Running: " ~ cmd, info=true) }}
{% endfor %}
{{ log("Artifacts uploaded to s3://" ~ bucket ~ "/prod/latest/", info=true) }}
{% endmacro %}PR Schema Cleanup
# .github/workflows/pr-cleanup.yml
name: Cleanup PR Schema
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GithubActionsDBTRole
aws-region: us-east-1
- name: Drop CI schema
run: |
aws redshift-data execute-statement \
--cluster-identifier ${{ secrets.REDSHIFT_CLUSTER_ID }} \
--database analytics_ci \
--sql "DROP SCHEMA IF EXISTS dbt_ci_pr_${{ github.event.pull_request.number }} CASCADE;" \
--region us-east-15 Practice Questions
What artifact does Slim CI require to know which models changed since the last production run?
The `--defer` flag in Slim CI does what?
Why is naming CI schemas with the PR number (e.g., `dbt_ci_pr_123`) a good practice?
In the Step Functions state machine, what happens if the DbtBuild state fails?
What selector combination ensures that unit tests are excluded from Slim CI's `dbt build` command?
Using AWS OIDC instead of static AWS credentials in GitHub Actions provides which key security benefit?
Key Takeaways
- Slim CI uses
--select state:modified+and--state ./prod-artifactsto build only changed models and their descendants β dramatically faster than full project runs. --deferlets CI reference prod-built upstream models without rebuilding them in the CI environment.- Store
manifest.jsonin S3 after every production run so it's available for the next CI comparison. - Use OIDC authentication to AWS from GitHub Actions β no static credentials stored in secrets.
- ECS Fargate + Step Functions is the idiomatic AWS pattern for running dbt in production: containerized, serverless, and fully observable.
- PR-numbered CI schemas provide isolation and enable automated cleanup after merge.
- The failure notification pattern (SNS + Step Functions Catch) is essential for production observability.