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.
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.ymlmy_project:target: prodoutputs:prod:type: snowflakeaccount: xy12345.us-east-1user: DBT_PROD_USERpassword: s3cretP@ssw0rd!role: TRANSFORMERdatabase: ANALYTICSwarehouse: TRANSFORMINGschema: PRODthreads: 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.ymlto 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 varsmy_project:target: prodoutputs:prod:type: snowflakeaccount: "{{ 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-1export DBT_SNOWFLAKE_USER=DBT_PROD_USERexport 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.ymlcan 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
envorprintenvon the host can see them. - On many Linux systems,
/proc/<pid>/environexposes the environment of running processes. - There's no encryption at rest — the values are stored in whatever mechanism sets the env vars (
.envfiles, 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.pyimport boto3import jsonimport osdef 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 secretmanagerimport json, osdef 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 SecretClientfrom azure.identity import DefaultAzureCredentialimport osdef 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.pyimport hvacimport osdef load_dbt_secrets():client = hvac.Client(url='https://vault.internal.company.com:8200',token=os.environ.get('VAULT_TOKEN'))# Static secretssecret = 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 userdef 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_TOKENused 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.ymlname: dbt Production Runon:schedule:- cron: '0 6 * * *'jobs:dbt-build:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- name: Install dbtrun: pip install dbt-snowflake- name: Run dbtenv: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.ymldbt-build:stage: transformimage: python:3.11variables:DBT_SNOWFLAKE_PASSWORD: $DBT_SNOWFLAKE_PASSWORD # Set in GitLab CI/CD settingsscript:- 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|vAES-256-GCM encryption (with unique IV per credential)|vEncrypted blob stored in PostgreSQL|vDecrypted only at runtime, into a temporary profiles.yml|vprofiles.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
| Approach | Encryption at Rest | Rotation | Audit Trail | Setup Effort | Cloud Lock-in | Cost |
|---|---|---|---|---|---|---|
| Plaintext profiles.yml | No | Manual | None | None | No | Free |
| Environment variables | No | Manual | None | Low | No | Free |
| AWS Secrets Manager | Yes | Automatic | Yes | Medium | Yes | ~$0.40/secret/month |
| GCP Secret Manager | Yes | Automatic | Yes | Medium | Yes | ~$0.06/10k accesses |
| Azure Key Vault | Yes | Automatic | Yes | Medium | Yes | ~$0.03/10k operations |
| HashiCorp Vault | Yes | Dynamic | Yes | High | No | Free (OSS) or paid |
| CI/CD Secrets | Varies | Manual | Limited | Low | Platform-tied | Free |
| Encrypted storage (AES-256-GCM) | Yes | In-app | Application-level | Low | No | Platform-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, usegit filter-branchor 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 jsonand 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.