<!-- cmdz — Secrets and environment variables, done right. Source: https://www.cmdz.com/blog/secrets-and-environment-variables -->
# Secrets and environment variables, done right
> Where secrets live, why `env pull` writes names and never values, what an agent can and cannot see, and how to rotate a credential without a redeploy.

Where secrets live, why `env pull` writes names and never values, what an agent can and cannot see, and how to rotate a credential without a redeploy.

25 Mar 2026 · 3 min read · cmdz

Every platform has environment variables. The differences are in what happens to them afterwards.

## Setting them

```bash
cmdz env set STRIPE_SECRET_KEY=sk_live_… --app web
cmdz env set --from-file .env.production --app web
cmdz env list --app web
```

Values are encrypted at rest, decrypted only when injected into a running workload, and never written to a build log, an audit line or an error message.

## `env pull` writes names, never values

```bash
cmdz env pull --app web > .env.local
```

```
DATABASE_URL=
STRIPE_SECRET_KEY=
NEXT_PUBLIC_SITE_URL=
RESEND_API_KEY=
```

Names, no values. This surprises people once and then they stop wanting it any other way.

The reasoning: the overwhelmingly common use for pulling env is "what does this app expect", not "give me the production keys". Filling in your local values is a one-time cost. A command that writes production secrets into a file in your working directory is a command that eventually writes them into a repository.

If you genuinely need a value, ask for it explicitly, one at a time, with a step-up passkey confirmation:

```bash
cmdz env reveal STRIPE_SECRET_KEY --app web
```

That is logged in the audit trail with your name on it.

## What an agent can see

An agent with `env:read` gets the **names**. Never the values. There is no scope, no profile and no tool that returns a secret value to an agent.

This is a deliberate asymmetry and it is the right one. An agent needs to know that a `DATABASE_URL` exists to reason about your application — to notice it is missing, to understand a connection error, to explain why a service is not attached. It does not need the string, ever, and giving it the string puts your production credentials into a context window and possibly into a provider's logs.

An agent can also **set** a variable with `env:write`. It can write a value it does not get to read back, which is exactly the shape of trust you want.

## System variables are read-only

Some variables are managed by the platform: `DATABASE_URL` from an attached Postgres, `REDIS_URL` from Valkey, the bucket credentials from object storage.

Nothing can overwrite these — not you, not your agent. The API rejects it with `env_var_readonly`.

The reason is that these are derived from the attachment. If you overwrite `DATABASE_URL` by hand and we later rotate the credential, your app breaks in a way that takes an hour to find. Detach the service if you want to point the app somewhere else.

## Rotating without a redeploy

```bash
cmdz env set STRIPE_SECRET_KEY=sk_live_new… --app web
cmdz restart web
```

A restart re-injects and reconnects. No rebuild, no image, a few seconds.

For platform-managed credentials, rotation is a single command and the workload picks up the new value on its own:

```bash
cmdz services rotate postgres --app web
```

## The build-time exception

Anything a client bundle needs is baked in at **build** time, not injected at runtime. In Next.js that is anything prefixed `NEXT_PUBLIC_`; in Vite, `VITE_`.

Two consequences:

**Changing one requires a rebuild.** We trigger it for you, which is why that particular deploy took forty seconds instead of five.

**Never put a secret in one.** A `NEXT_PUBLIC_` variable is in the JavaScript your visitors download. This is not a platform rule, it is how bundlers work, and it is one of the most common ways an API key ends up public. If it is prefixed for the client, it is public.

## Per environment

Production, staging and previews each have their own set. Previews inherit from a preview set that you control, so a pull request never gets production credentials — which matters, because a preview URL is public by default and a debug endpoint added at two in the morning should not be able to read your live Stripe key.

## What we never do with them

- Never write them to a build log. The build environment redacts known values.
- Never include them in an error message or a stack trace we render.
- Never show them in the audit trail — the entry says a variable changed, not to what.
- Never send them to an agent.
- Never store them in the repository. There is no `.env` in any deploy artefact.

## The rule that covers most of it

If a value would be bad to see in a screenshot, it belongs in the platform and not in your repository, not in your shell history and not in a chat window.

The platform is the only one of those with an audit trail.

## Set your limit and start.

One click with a passkey, then you verify a payment method once to start your 14-day free trial (€ 10 of credit). After that it is prepaid pay-as-you-go — you only ever spend credit you have already bought, and no invoice ever arrives above the amount you set.

- [Create account](https://app.cmdz.com/signup)
- [Read the docs](https://docs.cmdz.com)
