Back to Blog
dbt-coreproductionarchitecture

How to Secure dbt-core Credentials in Production

The profiles.yml problem and every solution — environment variables, secret managers, vault integration, and encrypted credential storage for dbt-core.

ModelDock TeamFebruary 17, 202612 min read

There's a file sitting on every dbt developer's machine that contains plaintext database passwords. It's called profiles.yml, and it's the default way dbt-core connects to your data warehouse.

On your laptop, this is fine. In production, it's a serious problem.

This article covers every major approach to securing dbt credentials in production — from simple environment variables to full secret manager integrations — with real code, honest trade-offs, and a clear picture of what each approach actually costs you in setup and maintenance.

The profiles.yml Problem

When you first set up dbt, the CLI creates a profiles.yml file in ~/.dbt/. It looks something like this:

# ~/.dbt/profiles.yml
my_project:
target: prod
outputs:
prod:
type: snowflake
account: xy12345.us-east-1
user: DBT_PROD_USER
password: s3cretP@ssw0rd!
role: TRANSFORMER
database: ANALYTICS
warehouse: TRANSFORMING
schema: PROD
threads: 4

That password is sitting right there in plaintext. And in practice, most teams have more than one credential — maybe a Snowflake account, a PostgreSQL metadata database, and an API key for a package registry.

The problems compound quickly:

  • Plaintext on disk: Anyone with file access can read the credentials. If the server gets compromised, the attacker has your warehouse password.
  • Version control risk: Developers accidentally commit profiles.yml to Git. Even if you delete it later, it's in the Git history forever.
  • Rotation is manual: When you rotate a password, you need to update the file on every machine and server that runs dbt.
  • No audit trail: There's no log of who accessed the credentials or when they changed.
  • Multi-environment headaches: Dev, staging, and prod credentials all need different files or different sections, and keeping them in sync is error-prone.

The .gitignore file is your first line of defense, but it's not a security strategy. Let's look at the real solutions.

Approach 1: Environment Variables

dbt-core supports Jinja templating in profiles.yml, which means you can reference environment variables directly:

# profiles.yml with env vars
my_project:
target: prod
outputs:
prod:
type: snowflake
account: "{{ env_var('DBT_SNOWFLAKE_ACCOUNT') }}"
user: "{{ env_var('DBT_SNOWFLAKE_USER') }}"
password: "{{ env_var('DBT_SNOWFLAKE_PASSWORD') }}"
role: "{{ env_var('DBT_SNOWFLAKE_ROLE') }}"
database: "{{ env_var('DBT_SNOWFLAKE_DATABASE') }}"
warehouse: "{{ env_var('DBT_SNOWFLAKE_WAREHOUSE') }}"
schema: "{{ env_var('DBT_SNOWFLAKE_SCHEMA') }}"
threads: 4

Then set the variables on the host:

export DBT_SNOWFLAKE_ACCOUNT=xy12345.us-east-1
export DBT_SNOWFLAKE_USER=DBT_PROD_USER
export DBT_SNOWFLAKE_PASSWORD='s3cretP@ssw0rd!'
export DBT_SNOWFLAKE_ROLE=TRANSFORMER

What's good:

  • Simple to implement. No extra dependencies.
  • Credentials are no longer in the file itself.
  • profiles.yml can be safely committed to version control.
  • Works everywhere — Docker, Kubernetes, CI/CD, bare metal.

What's not:

  • Environment variables are still plaintext in memory. Anyone who can run env or printenv on the host can see them.
  • On many Linux systems, /proc/<pid>/environ exposes the environment of running processes.
  • There's no encryption at rest — the values are stored in whatever mechanism sets the env vars (.env files, systemd unit files, Docker Compose files).
  • No built-in rotation, versioning, or audit trail.

Environment variables are a solid step up from hardcoded passwords, and they're good enough for many small teams. But they push the security problem somewhere else rather than solving it.

Approach 2: Secret Managers

Cloud secret managers are purpose-built for this problem. They store secrets encrypted at rest, provide access control, handle rotation, and maintain audit logs.

AWS Secrets Manager

Store your dbt credentials as a JSON secret in AWS Secrets Manager, then fetch them at runtime:

# fetch_secrets.py
import boto3
import json
import os
def load_dbt_secrets():
client = boto3.client('secretsmanager', region_name='us-east-1')
response = client.get_secret_value(SecretId='dbt/prod/snowflake')
secrets = json.loads(response['SecretString'])
os.environ['DBT_SNOWFLAKE_ACCOUNT'] = secrets['account']
os.environ['DBT_SNOWFLAKE_USER'] = secrets['user']
os.environ['DBT_SNOWFLAKE_PASSWORD'] = secrets['password']
os.environ['DBT_SNOWFLAKE_ROLE'] = secrets['role']
if __name__ == '__main__':
load_dbt_secrets()

Wrap your dbt command to call this first:

python fetch_secrets.py && dbt build --target prod

GCP Secret Manager

from google.cloud import secretmanager
import json, os
def load_dbt_secrets():
client = secretmanager.SecretManagerServiceClient()
name = "projects/my-project/secrets/dbt-prod-snowflake/versions/latest"
response = client.access_secret_version(request={"name": name})
secrets = json.loads(response.payload.data.decode("UTF-8"))
os.environ['DBT_SNOWFLAKE_PASSWORD'] = secrets['password']
# ... set other env vars

Azure Key Vault

from azure.keyvault.secrets import SecretClient
from azure.identity import DefaultAzureCredential
import os
def load_dbt_secrets():
credential = DefaultAzureCredential()
client = SecretClient(
vault_url="https://my-dbt-vault.vault.azure.net",
credential=credential
)
password = client.get_secret("dbt-snowflake-password")
os.environ['DBT_SNOWFLAKE_PASSWORD'] = password.value

What's good:

  • Encryption at rest and in transit.
  • Fine-grained access control (IAM policies).
  • Automatic rotation support.
  • Full audit trail of who accessed what and when.
  • Versioning — you can roll back to a previous secret value.

What's not:

  • You're locked into your cloud provider. Moving from AWS to GCP means rewriting your secret-fetching code.
  • There's a cost per secret and per API call (small, but it adds up at scale).
  • You need a wrapper script or custom entrypoint to fetch secrets before dbt runs.
  • The secret still ends up as a plaintext environment variable in the process — the manager just controls how it gets there.
  • Setup and IAM configuration can be surprisingly complex, especially in organizations with strict cloud policies.

Approach 3: HashiCorp Vault

Vault is the cloud-agnostic alternative to provider-specific secret managers. It runs on your own infrastructure (or as HashiCorp Cloud Platform managed service) and supports dynamic secrets — credentials that are generated on-demand and automatically expire.

# vault_fetch.py
import hvac
import os
def load_dbt_secrets():
client = hvac.Client(
url='https://vault.internal.company.com:8200',
token=os.environ.get('VAULT_TOKEN')
)
# Static secrets
secret = client.secrets.kv.v2.read_secret_version(
path='dbt/prod/snowflake'
)
creds = secret['data']['data']
os.environ['DBT_SNOWFLAKE_PASSWORD'] = creds['password']
os.environ['DBT_SNOWFLAKE_USER'] = creds['user']
load_dbt_secrets()

The real power of Vault is dynamic database credentials. Instead of storing a static password, Vault generates a temporary username and password for each dbt run:

# Dynamic credentials — Vault creates a temporary DB user
def get_dynamic_credentials():
client = hvac.Client(url='https://vault.internal.company.com:8200')
creds = client.secrets.database.generate_credentials(name='dbt-prod-role')
os.environ['DBT_SNOWFLAKE_USER'] = creds['data']['username']
os.environ['DBT_SNOWFLAKE_PASSWORD'] = creds['data']['password']
# These credentials auto-expire after the configured TTL

What's good:

  • Cloud-agnostic. Works on AWS, GCP, Azure, or on-prem.
  • Dynamic secrets mean credentials that expire automatically — no rotation needed.
  • Extremely fine-grained access policies.
  • Comprehensive audit logging.
  • Supports lease renewal, revocation, and break-glass procedures.

What's not:

  • Vault is itself a complex piece of infrastructure. Someone needs to deploy, configure, unseal, and maintain it.
  • The learning curve is steep. Writing Vault policies correctly takes practice.
  • If Vault goes down, dbt can't get its credentials — you've added a critical dependency.
  • The VAULT_TOKEN used to authenticate to Vault is itself a secret that needs to be secured somewhere.
  • Overkill for small teams. If you have three people and one dbt project, you don't need Vault.

Approach 4: CI/CD Secrets

If you run dbt from a CI/CD pipeline, most platforms have built-in secret management.

GitHub Actions

# .github/workflows/dbt.yml
name: dbt Production Run
on:
schedule:
- cron: '0 6 * * *'
jobs:
dbt-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dbt
run: pip install dbt-snowflake
- name: Run dbt
env:
DBT_SNOWFLAKE_ACCOUNT: ${{ secrets.DBT_SNOWFLAKE_ACCOUNT }}
DBT_SNOWFLAKE_USER: ${{ secrets.DBT_SNOWFLAKE_USER }}
DBT_SNOWFLAKE_PASSWORD: ${{ secrets.DBT_SNOWFLAKE_PASSWORD }}
DBT_SNOWFLAKE_ROLE: ${{ secrets.DBT_SNOWFLAKE_ROLE }}
run: dbt build --target prod --profiles-dir .

GitLab CI

# .gitlab-ci.yml
dbt-build:
stage: transform
image: python:3.11
variables:
DBT_SNOWFLAKE_PASSWORD: $DBT_SNOWFLAKE_PASSWORD # Set in GitLab CI/CD settings
script:
- pip install dbt-snowflake
- dbt build --target prod --profiles-dir .
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"

What's good:

  • Zero additional infrastructure. Secrets are built into the platform.
  • Secrets are masked in logs automatically.
  • Access control follows your repository permissions.
  • Simple setup — just add secrets in the platform UI.

What's not:

  • Tied to your CI/CD platform. Migrating means re-entering every secret.
  • No dynamic secrets or automatic rotation.
  • Secrets are only available inside the pipeline — you can't use them for ad-hoc dbt runs or debugging.
  • Limited audit trail compared to dedicated secret managers.
  • GitHub Actions secrets can't be read by pull requests from forks (which is actually a security feature, but it complicates open-source workflows).

Approach 5: Encrypted Credential Storage

The approaches above all share one characteristic: they separate where secrets are stored from where dbt runs. This means you always need a fetching mechanism — a script, a sidecar, or some glue code to get the secret from "over there" to "right here."

An alternative approach is to store credentials encrypted in the same system that runs dbt, and decrypt them at runtime only when needed.

This is what ModelDock does. When you enter your warehouse credentials, they're encrypted using AES-256-GCM before they ever touch the database:

Plaintext credentials
|
v
AES-256-GCM encryption (with unique IV per credential)
|
v
Encrypted blob stored in PostgreSQL
|
v
Decrypted only at runtime, into a temporary profiles.yml
|
v
profiles.yml is never persisted to disk

AES-256-GCM is an authenticated encryption scheme — it provides both confidentiality (the data is encrypted) and integrity (any tampering with the ciphertext is detected). The "256" refers to the key size in bits, and "GCM" (Galois/Counter Mode) provides the authentication tag that prevents silent modification.

The key detail is that profiles.yml is generated at runtime, passed to dbt, and never written to permanent storage. There's no file on disk for anyone to find, no environment variable to leak, and no wrapper script to maintain.

What's good:

  • No external dependencies — credentials and the system that uses them live together.
  • Credentials are encrypted at rest with a strong, authenticated cipher.
  • No plaintext files on disk, ever.
  • Rotation is straightforward — update the credential in one place, and the next dbt run picks it up.
  • No wrapper scripts, no glue code, no secret-fetching step.

What's not:

  • The encryption key itself needs to be secured (typically as a single environment variable on the application server).
  • You're trusting the platform to handle encryption correctly — if the implementation has a flaw, all credentials are at risk.
  • Less flexibility than a general-purpose secret manager if you need to share secrets across many different systems.

Comparison

ApproachEncryption at RestRotationAudit TrailSetup EffortCloud Lock-inCost
Plaintext profiles.ymlNoManualNoneNoneNoFree
Environment variablesNoManualNoneLowNoFree
AWS Secrets ManagerYesAutomaticYesMediumYes~$0.40/secret/month
GCP Secret ManagerYesAutomaticYesMediumYes~$0.06/10k accesses
Azure Key VaultYesAutomaticYesMediumYes~$0.03/10k operations
HashiCorp VaultYesDynamicYesHighNoFree (OSS) or paid
CI/CD SecretsVariesManualLimitedLowPlatform-tiedFree
Encrypted storage (AES-256-GCM)YesIn-appApplication-levelLowNoPlatform-dependent

Best Practices Checklist

Regardless of which approach you choose, follow these baseline practices:

  • Never commit credentials to Git. Add profiles.yml, .env, and any secret files to .gitignore. If you've already committed one, use git filter-branch or BFG Repo Cleaner to remove it from history, then rotate the credential immediately.
  • **Use env_var() in profiles.yml.** Even if you use a secret manager, the last mile is usually an environment variable. Template your profiles.yml with {{ env_var('...') }} so the file itself is safe to commit.
  • Rotate credentials regularly. At minimum, rotate warehouse passwords every 90 days. If you're using Vault with dynamic secrets, this happens automatically.
  • Apply least-privilege access. The dbt service account should only have the permissions it needs — typically read access to source schemas and write access to the target schema. Don't use an admin account.
  • Separate dev and prod credentials. Use different service accounts for development and production. A developer's credentials should never have write access to production tables.
  • Audit access to secrets. Use a system that logs who accessed which secret and when. If you're using environment variables, you don't have this — consider upgrading.
  • Encrypt at rest, not just in transit. TLS protects credentials on the wire. You also need them encrypted when stored. Environment variables and plaintext files fail this test.
  • Don't log credentials. Review your dbt log output and CI/CD logs to make sure passwords aren't leaking into log files. Use --log-format json and filter sensitive fields.
  • Have a breach response plan. Know what to do if a credential leaks: which passwords to rotate, which access to revoke, and who to notify.

Wrapping Up

There's no single right answer here. The best approach depends on your team size, cloud environment, security requirements, and how many other systems need access to the same secrets.

For a solo developer or small team, environment variables are a perfectly reasonable starting point. For organizations on a single cloud with strict compliance requirements, a managed secret manager is the standard choice. For multi-cloud or on-prem environments, Vault is the heavyweight option.

If you'd rather not build any of this yourself, [ModelDock](https://modeldock.run) encrypts your warehouse credentials with AES-256-GCM and generates profiles.yml at runtime — no plaintext files, no wrapper scripts, no secret manager to configure. You can try it free and have dbt running in production in under five minutes.

Ready to run dbt-core in production?

ModelDock handles scheduling, infrastructure, and credential management so you don't have to.

Start For Free