Skip to content
Solo ProTeamBusinessEnterprise

Using in CI

Injecting secrets into a process without touching disk, rendering config files, exporting a namespace, and bulk-loading a .env file.

Commands on this page: run · export · template · import

Which one do you want?#

Three commands, three different jobs. Reaching for the wrong one is the most common way to get stuck on this page:

You want Command What you get
Secrets inside a running program, nothing on disk run environment variables in that one process
A real .env file export --out .env a dotenv file on disk, mode 0600
A config file with its placeholders filled in template app.conf.tmpl --out app.conf a rendered file on disk, mode 0600

run writes nothing. It creates no file, modifies no file, and puts nothing into your shell — it starts one program with the secrets in that program's environment, and they are gone when it exits. That is the reason to prefer it. If you ran it and are waiting for a file to change, you wanted one of the other two.

Getting the key into the job#

The CLI resolves its credential as --token → PULSVAULT_TOKEN → the profile in ~/.pulsvault/config. So if the job's environment already holds PULSVAULT_TOKEN, login is unnecessary — and no credential is ever written to the runner's disk, which is what you want on a machine whose filesystem outlives the job.

Issue the key first: Machine Identities → your service account → API keys, which shows the pv_live_… token exactly once. See API keys for what to scope it to and how long to let it live.

GitLab CI#

Store it in Settings → CI/CD → Variables, and name the variable PULSVAULT_TOKEN:

text
Key:     PULSVAULT_TOKEN
Value:   pv_live_…
Type:    Variable
Flags:   ☑ Masked    ☑ Protected

GitLab injects CI/CD variables into the job as environment variables under exactly that name, so there is nothing to export — the CLI picks it up on its own:

yaml
deploy:
  script:
    - pulsvault run -n acme-web/prod/product/web -- ./deploy.sh

Masked keeps the value out of the job log. Protected restricts it to protected branches and tags, which is what stops a merge request from a fork reading your production credentials.

The key lives in GitLab, not on the runner

This holds however you run CI — GitLab.com or self-hosted, shared runners or your own runner on your own VPS. GitLab hands the variable to whichever runner picks the job up, for the life of that job only; nothing is provisioned on the runner host, and nothing survives the job. Putting the key in the runner's own config.toml instead would leave it sitting in plaintext on that machine for every project the runner serves — do not.

GitHub Actions#

Store it in Settings → Secrets and variables → Actions → New repository secret, named PULSVAULT_TOKEN. Unlike a GitLab variable, a GitHub secret is not an environment variable until you map it, so the step needs an env: block:

yaml
- name: Deploy
  env:
    PULSVAULT_TOKEN: ${{ secrets.PULSVAULT_TOKEN }}
  run: pulsvault run -n acme-web/prod/product/web -- ./deploy.sh

Already stored under another name#

If the key is already a CI variable called something else, map it rather than duplicating it — one copy of a credential is easier to rotate than two:

bash
export PULSVAULT_TOKEN=$CI_PULSVAULT_KEY     # only when it is stored under a different name
Point the CLI at your own deployment with `PULSVAULT_HOST`

Self-hosted PulsVault needs one more variable — PULSVAULT_HOST, the API base including the /api/v1 suffix. It is not a secret, so a plain unmasked CI variable is fine. Against api.pulsvault.com it is not needed at all.

It always travels with PULSVAULT_TOKEN. A key stored by pulsvault login is only ever sent to the host it was saved with, so an override on its own is refused rather than handing that key to another server. The host must be https:// — plain http is refused for anything but the machine you are on.

Injecting into a process#

run loads a namespace's secrets as environment variables and execs your command. Values live only in the child process's environment — never a file, never the shell's history.

Everything after -- is your own command, handed over untouched. PulsVault does not supply it and does not care what it is:

bash
pulsvault run -n acme-web/prod/product/web -- ./deploy.sh
pulsvault run -n acme-web/prod/product/web -- npm run migrate
pulsvault run -n acme-web/prod/product/web -- terraform apply -auto-approve
pulsvault run -n acme-web/prod/product/web -- java -jar app.jar

Your program reads the values with whatever it already uses for environment variables — process.env, os.Getenv, System.getenv, $DB_PASSWORD. Nothing in it needs to know PulsVault exists.

`./deploy.sh` on this page stands for *your* program

There is no file you have to create, and no extension that means anything. Every example below writes ./deploy.sh where it means "whatever you already run" — npm start, python manage.py migrate, java -jar app.jar, a compiled binary. Substituting yours is the whole of the change:

bash
pulsvault run -n acme-web/prod/product/web -- npm start

Adopting run is deleting your .env loading and prefixing the command you already use. The application is not modified.

Flags after the program name belong to the program. Flag parsing stops at the first thing that is not one of ours, so --verbose below reaches ./deploy.sh whether or not you wrote the separator:

bash
pulsvault run -n acme-web/prod/product/web -- ./deploy.sh --verbose   # explicit, and what we teach
pulsvault run -n acme-web/prod/product/web ./deploy.sh --verbose      # identical

Keep writing --. It costs nothing, it is unambiguous to a reader, and it is the only form that is also correct in every other tool your pipeline calls.

This is the form to reach for by default. Anything that writes a .env leaves a plaintext file behind that something has to remember to delete.

`run` execs directly — there is no shell

The command is executed, not interpreted, so a script needs a shebang (#!/usr/bin/env bash) and the executable bit. Without them you get:

text
error: cannot execute ./deploy.sh: the file is empty
       `run` execs directly — there is no shell — so the file needs a shebang
       (#!/usr/bin/env bash), or run it through an interpreter:
         pulsvault run -n <namespace> -- bash ./deploy.sh

For the same reason, -- echo $DB_PASSWORD prints nothing useful: your shell expands $DB_PASSWORD before pulsvault starts. To read a value from inside the child, ask for a shell explicitly — -- sh -c 'echo "$DB_PASSWORD"'.

A folder prefix works too, which is useful when one namespace holds several unrelated credential groups. Note that it also shortens the variable names — see Names:

bash
pulsvault run -n acme-web/prod/product/web           -- ./app   # POSTGRES_URL, REDIS_URL
pulsvault run -n acme-web/prod/product/web/postgres  -- ./app   # just that folder, and just URL

One script, every environment#

$DB_PASSWORD in a script is a lookup, not a blank to be filled in — the shell resolves it at the moment that line runs. PulsVault never modifies the file, so the same script does different work depending only on which namespace you hand it:

bash
$ cat deploy.sh
#!/usr/bin/env bash
echo "deploying against: $DB_URL"

$ pulsvault run -n acme-web/dev/product/web  -- ./deploy.sh
deploying against: postgres://localhost:5432/dev

$ pulsvault run -n acme-web/prod/product/web -- ./deploy.sh
deploying against: postgres://db.prod.internal:5432/app

One file, both times, byte-identical afterwards. What that buys:

  • One script instead of deploy.dev.sh and deploy.prod.sh, which drift apart the moment someone fixes a bug in only one of them.
  • Nothing to .gitignore — the script holds variable names, so it is safe to commit and to read in a merge request.
  • Rotation costs nothing. Change the secret; the next run picks it up. No redeploy, no file to update, no stale copy anywhere.
  • Nothing is left on the runner when the job ends, because nothing was written.

Shortening the paths#

Every path on this page starts with the project — acme-web. Setting PULSVAULT_PROJECT lets you drop that first segment and write the rest:

bash
export PULSVAULT_PROJECT=acme-web
pulsvault run -n prod/product/web -- ./deploy.sh        # == acme-web/prod/product/web

It is a convenience and nothing more: it changes which paths you may write, never which secrets the key may read. A path that already starts with the project is left alone, so fully-qualified paths keep working once it is set — which is why the rest of this page spells them out in full. See Profiles & defaults for how it resolves against --project and a stored profile.

Names#

A variable name is the secret's path relative to the prefix you named, uppercased. The prefix itself never appears in the variable — so the same secret has different names depending on whether you loaded its namespace or its folder:

You run Secret's full path Variable
-n acme-web/prod/product/web …/product/web/db/api-key DB_API_KEY
-n acme-web/prod/product/web …/product/web/postgres/url POSTGRES_URL
-n acme-web/prod/product/web/postgres …/product/web/postgres/url URL
-n acme-web/prod/product/web …/product/web/2fa/seed _2FA_SEED

Slashes, hyphens and dots become underscores; a name that would start with a digit gets a leading _. If two paths collapse to the same name the CLI warns and the later one wins.

Naming a folder therefore shortens every variable in it — useful when an application wants bare URL and PASSWORD, and a trap if it wants them qualified. It also removes what kept two folders apart: …/postgres and …/redis both yield URL, so load the namespace when you need both.

Check before you commit to a set of names, without running anything or printing a value:

bash
pulsvault run -n acme-web/prod/product/web --dry-run

:::caution A folder name repeated in the secret name is repeated in the variable A secret stored at postgres/postgres_url becomes POSTGRES_POSTGRES_URL when its namespace is loaded. The rule is doing exactly what it says, and the result is nobody's intent. Name a secret for what it is inside its folder — postgres/url → POSTGRES_URL.

pulsvault mv <old-path> <new-path> renames one with its full version history, in a single transaction.

You do not have to notice this yourself. set, gen and mv all say so at the moment the name is chosen, which is the only moment it is free to change:

text
$ pulsvault set acme-web/prod/product/web/db2/db2_url 'postgres://…'
wrote acme-web/prod/product/web/db2/db2_url (version 1)
note: the folder "db2" is repeated in "db2_url", so it appears twice in the variable — DB2_DB2_URL, not DB2_URL
      (loading the folder itself, -n …/db2, gives DB2_URL — which some layouts want)
      to name it once from anywhere:  pulsvault mv acme-web/prod/product/web/db2/db2_url acme-web/prod/product/web/db2/url

:::

The path *is* the variable name — there is no mapping step

run and export derive every name from the path, and offer no flag to override it. So the variable your application expects is decided by whoever names the secret. If the app wants DATABASE_PASSWORD, store it at database/password — db/password yields DB_PASSWORD, and nothing will warn you about the mismatch.

When you cannot change either side, map it in the child:

bash
pulsvault run -n acme-web/prod/product/web -- \
  sh -c 'DATABASE_PASSWORD="$DB_PASSWORD" ./deploy.sh'

Or use the GitHub Action / GitLab template, which take explicit secretPath -> ENV_NAME mappings instead of deriving them — see CI/CD pipelines.

If the namespace resolves to nothing at all, run says so and still runs your command, so a mistyped namespace cannot masquerade as a correct one:

text
warning: acme-web/prod/product/web resolved 0 secrets — the command runs with nothing injected

Seeing what was injected#

When it resolves something, run names it — on a terminal, once the child has actually started:

text
$ pulsvault run -n acme-web/prod/product/web -- ./deploy.sh
injected 4 secret(s) from acme-web/prod/product/web: DB_PASSWORD, DB_URL, GH_TOKEN, STRIPE_KEY

Names, never values, so the line is safe to leave in your scrollback. It goes to stderr and only when a human is reading it — redirect stderr, or pass -q, and it is not there at all, so a job log is byte-for-byte what it was before this line existed. stdout belongs entirely to your program either way.

That answers "did it inject anything?" after the fact. --dry-run answers it before:

bash
pulsvault run -n acme-web/prod/product/web --dry-run
text
dry run: 4 secret(s) from acme-web/prod/product/web would be injected into: no command given
DB_PASSWORD
DB_URL
GH_TOKEN
STRIPE_KEY

It starts nothing. The names go to stdout, one per line and sorted, so a script can read them and two plans of the same namespace diff clean — the listing order a server happens to return is not something to build a comparison on. The heading goes to stderr with everything else advisory, so the pipe stays clean:

bash
pulsvault run -n acme-web/prod/product/web --dry-run | wc -l      # how many variables?
diff <(pulsvault run -n acme-web/prod/staging --dry-run) \
     <(pulsvault run -n acme-web/prod/product/web --dry-run)      # which ones differ?

--dry-run needs no command, and takes one if you have it — --dry-run -- ./deploy.sh names the program it would have started, which is a cheap way to confirm the CLI read your command line the way you meant it. It refuses the same flag combinations a real run would, and reports two secrets whose paths collapse to one variable name before anything holds a credential. Combined with --watch it prints the plan once and says so: a plan does not restart.

Reach for `--dry-run` before you reach for `printenv`

It is the only one of the two that cannot put a secret on your screen, and the only one that works before the program exists.

What the child actually sees#

The child gets your environment, with the namespace added on top — not the namespace alone. It has to work that way: strip PATH and the child cannot find bash, node or anything else, so nothing runs at all.

One variable is never inherited: PULSVAULT_TOKEN. The key that read the secrets has no business in your program's environment, where a crash dump, an error reporter that captures the environment or anyone able to read /proc/<pid>/environ would carry it off — with every secret that key can read behind it. If the child itself runs pulsvault, hand the key over deliberately:

bash
pulsvault run -n acme-web/prod/product/web --pass-token -- ./deploy.sh   # deploy.sh calls pulsvault

--pass-token gives the child exactly the key this command authenticated with, wherever that came from — --token, PULSVAULT_TOKEN or a stored profile.

The order matters. Secrets are appended after the inherited variables, and a process environment keeps the last definition of a name — so a secret shadows a stale DB_PASSWORD already exported in your shell, and the child sees one value rather than two entries to choose between.

That is why -- printenv prints sixty lines: four of them are yours, the rest is the shell you launched from. To look at just the injected half, ask for it directly rather than filtering a dump:

bash
pulsvault run -n acme-web/prod/product/web --dry-run                    # names only, runs nothing
pulsvault export acme-web/prod/product/web --format json | jq 'keys'    # names only, from the export

:::caution printenv writes secrets to your terminal Whole-environment dumps land in your shell history, your terminal's scrollback and your CI job log. Print one variable deliberately, or print names only. If you have already dumped a live credential, rotate it — it is now in more places than you can clear. :::

`run` execs directly, so `deploy.sh` and `./deploy.sh` are different commands

A bare name is looked up in $PATH and never in the current directory — the same rule your shell follows, and the reason . was taken out of the default $PATH. run will not quietly fall back to the working directory: the process it starts is holding your decrypted credentials, so a file dropped into a checked-out repo must not be able to claim the name npm or make.

It does tell you everything it can see, in one attempt:

text
$ pulsvault run -n acme-web/prod/product/web -- deploy.sh
error: cannot execute deploy.sh: not found in $PATH
       there IS a deploy.sh in the current directory, but it is not executable,
       and a bare name is looked up in $PATH only:
         chmod +x deploy.sh   then run it as ./deploy.sh

--clean: only the secrets#

--clean replaces the inherited half with a short, fixed list — HOME, PATH, TERM, TMPDIR, TZ — plus the namespace. Reach for it when a build should not be able to read the ambient environment it happens to be launched from:

bash
pulsvault run -n acme-web/prod/product/web --clean -- ./build.sh

Those five are the ones that decide whether a program works, not what it computes: without PATH the child cannot be found, and without HOME most tools write their caches to / or refuse to start.

Locale variables are dropped on purpose. No LANG, no LC_*, so the child lands in the C locale and sorts and formats identically wherever it runs — which is the reason to ask for a clean environment in the first place. --keep LANG brings yours back.

--keep carries anything else through, repeated or comma-separated. A proxy is the usual case, since a clean environment has no route out of a corporate network:

bash
pulsvault run -n acme-web/prod/product/web --clean --keep HTTPS_PROXY,NO_PROXY -- ./build.sh
pulsvault run -n acme-web/prod/product/web --clean --keep NODE_ENV --keep CI -- npm run build

--keep without --clean is refused rather than ignored: it reads as "narrow the environment to these", and on a child that already inherits everything it would quietly do nothing.

Variables a secret may not set#

Some variable names decide how a program starts rather than what it computes: LD_PRELOAD loads a library into it, NODE_OPTIONS and JAVA_TOOL_OPTIONS add arguments to the runtime, BASH_ENV runs a file before any bash script, PATH picks which node is node. A secret at …/ld_preload would become exactly that — so anyone able to write one secret in the namespace could run their own code in your process, with every other value it was given.

run refuses to start when the namespace would set one, and export refuses to write it:

text
error: refusing to set acme-web/prod/product/web/ld_preload → LD_PRELOAD: these variables change how a program starts or where it finds code, so anyone able to write a secret here could run code in this process
       if this namespace is meant to set them, pass --allow-env NAME for each

The refused set is PATH, HOME, SHELL, IFS, PS4, ENV, BASH_ENV, SHELLOPTS, BASHOPTS, CDPATH, NODE_OPTIONS, NODE_PATH, PYTHONPATH, PYTHONSTARTUP, PYTHONHOME, PERL5OPT, PERL5LIB, RUBYOPT, RUBYLIB, JAVA_TOOL_OPTIONS, _JAVA_OPTIONS, JDK_JAVA_OPTIONS, SSL_CERT_FILE, SSL_CERT_DIR, and every name starting LD_, DYLD_, GIT_ or PULSVAULT_.

When a namespace really is meant to set one, say so per name — repeated or comma-separated:

bash
pulsvault run -n acme-web/prod/product/web --allow-env NODE_OPTIONS -- npm start

--allow-env naming a variable that is not refused is itself an error, so a typo cannot pass for a decision.

Exit codes and signals#

run is a supervisor, not a launcher. It stays alive for as long as your program does and then reports what happened to it:

  • The exit status is your program's, unchanged.
  • A mistake in the command line itself reports 2, before a single secret is read: an unknown flag, a missing --namespace, a missing command. run names which one it was rather than printing a usage block for you to compare against what you typed.
  • A program that never started reports 127 (not found) or 126 (found, but not runnable — no executable bit, no shebang, wrong format). Those are the statuses a shell uses, and they separate a mistake in the job definition from a command that ran and failed.
  • A program killed by a signal reports 128+signum — the same number bash reports. An OOM kill is 137, a SIGTERM is 143. A CI retry rule can act on those, which is the whole reason to distinguish them from an ordinary non-zero exit.
  • SIGINT, SIGTERM, SIGHUP and SIGQUIT are forwarded to your program. docker stop, a cancelled pipeline job and Ctrl-C all reach it, and run waits for it to finish rather than leaving the shell to draw a prompt over a process still writing to the terminal.
  • A program that ignores the stop signal is not waited on forever. It gets --stop-timeout seconds (default 10) and is then SIGKILLed. A second Ctrl-C skips the wait.
bash
pulsvault run -n acme-web/prod/product/web --stop-timeout 30 -- ./server   # a JVM that drains slowly
pulsvault run -n acme-web/prod/product/web --stop-timeout 0  -- ./server   # no grace at all
Exit code 137 in a pipeline

That is a SIGKILL, and in a container it is almost always the OOM killer. It reaches your CI as 137 rather than as a generic failure, so a memory-limit retry can be written against it.

Restarting on change#

--watch re-reads the namespace on an interval and restarts the child when a value changes — the shape you want for a long-running process whose credentials rotate underneath it:

bash
pulsvault run --watch --interval 60 -n acme-web/prod/product/web -- ./server

A restart is a real stop: SIGTERM, then --stop-timeout seconds, then SIGKILL. A server with a shutdown hook that stalls delays the restart by that much and no more.

Every restart reports what the new child was given, the same way the first start does — a namespace can gain or lose a variable between polls, not only change a value, and secrets changed on its own does not say which happened. If it loses all of them, the empty-namespace warning above is printed again rather than restarting your process into silence.

Only a genuine change restarts anything. The comparison is over the values themselves, so a namespace that comes back in a different order — but with the same secrets in it — is not a change, and your process keeps running.

Exporting#

This is the one that produces a file. If you reached for run expecting .env to be written, this is what you wanted.

export prints the same variables instead of running something:

bash
pulsvault export acme-web/prod/product/web                        # dotenv (default)
pulsvault export acme-web/prod/product/web --format json
eval "$(pulsvault export acme-web/prod/product/web)"              # into the current shell

A folder prefix works here as well:

bash
pulsvault export acme-web/prod/product/web/postgres --out postgres.env
A dotenv file is plaintext on disk — and a redirect decides its permissions

export … > .env writes every value in the clear. Worse, > means the shell creates the file, under its own umask — 0002 on a typical Linux login, which produces a group-writable 0664 file full of production credentials.

Prefer run, which never persists anything. When a file is genuinely required, use --out rather than a redirect: PulsVault creates it itself with mode 0600, and replaces a symlink at that path instead of writing through it.

bash
pulsvault export acme-web/prod/product/web --out .env     # 0600, whatever your umask is
pulsvault export acme-web/prod/product/web  > .env        # 0664 under umask 0002

Either way, keep it out of the image and delete it in the same job step.

Failing loudly#

Both run and export fail if any secret cannot be loaded, rather than starting your process with a variable silently missing. --skip-missing downgrades that to a warning when a partial set is genuinely acceptable.

export applies the same refused-names rule as run, for the same reason: a .env file is sourced or evaled into a shell, which is a process starting too. --allow-env NAME works here as well.

A namespace too large to load is the same kind of failure. The server caps a recursive listing (5000 secrets by default) and marks a cut one; run and export then stop instead of using it, because a process started with part of its environment missing fails in ways that are hard to trace back to here. --skip-missing does not override this — it covers a secret that failed to load, not a listing that was never complete. Split the namespace, or point the command at a narrower path.

Rendering a config file#

Some applications want a real config file rather than environment variables. template renders a Go text/template, resolving placeholders through the API:

bash
pulsvault template app.conf.tmpl --out app.conf
pulsvault template app.conf.tmpl --out app.conf --watch --interval 60
text
database_url = {{ secret "acme-web/prod/product/web/db/url" }}
username     = {{ secret "acme-web/prod/product/web/db/creds" "username" }}

The two-argument form pulls one key out of a JSON secret. Without --out the result goes to stdout. Repeated references to the same path are fetched once per render.

--out writes mode 0600 unconditionally — including when the destination already existed with looser permissions, which is the case that matters: a config file left at 0644 by an earlier build would otherwise have kept it. The write is atomic (rendered to a temporary file, then renamed), so --watch never exposes a half-written config, and a symlink at the destination is replaced rather than followed.

Bulk-loading a file#

import reads a .env or JSON file and writes it in atomic batches of up to 100 keys — within a batch either every key lands or none does, so a rejected key never leaves a half-written batch:

bash
pulsvault import .env.production --namespace acme-web/prod/product/web --dry-run
pulsvault import .env.production --namespace acme-web/prod/product/web
pulsvault import config.json --namespace acme-web/prod/product/web --format json

--format defaults to the file extension. Key names are lowercased into secret paths — DB_PASSWORD becomes db-password under the namespace.

Always run --dry-run first. It prints the exact paths that would be written, touching nothing:

text
would import acme-web/prod/product/web/db-password (from DB_PASSWORD)
would import acme-web/prod/product/web/api-key (from API_KEY)
Files over 100 keys import as several batches

Each batch is atomic on its own, but they are not atomic together: if the third batch of a 250-key file is rejected, the first two are already committed. --dry-run catches the mistakes that cause this — run it first and the whole file either passes validation or does not.

Keys with no value#

FOO= is legal dotenv and means an empty value — but far more often it is a placeholder nobody filled in. Importing it writes a secret that reads as configured and holds nothing, so import refuses the file and names the keys before any batch is sent:

text
pulsvault import .env.production --namespace acme-web/prod/product/web
error: 2 key(s) in .env.production have no value: API_TOKEN, SMTP_PASS
       remove them, give them values, or pass --allow-empty to import them as empty

Fill them in, delete those lines, or — if a blank value is genuinely what your app expects — pass --allow-empty. The opt-in travels per key rather than per file, so nothing else in the batch is loosened by it.

A complete job#

With PULSVAULT_TOKEN already in the job's environment, the script itself carries no credential handling at all:

bash
pulsvault status                                                   # fail fast on a dead key
pulsvault validation-status acme-web/prod/product/web/db --max-age 1h   # known-good recently?
pulsvault run -n acme-web/prod/product/web -- ./deploy.sh          # deploy with them injected

Each line exits non-zero on failure, so no && chaining or output parsing is needed — the job stops at the first real problem, with a message naming which.

The middle line reads the result PulsVault's scheduled checks already produced, so it costs nothing and sends no traffic to your provider. Swap it for pulsvault validate acme-web/prod/product/web --all when the job genuinely needs a live probe — after rotating a credential, or before promoting a build.

The exit code says which thing went wrong

Both commands distinguish a credential that genuinely failed from one that could not be checked and from a path nobody configured, so a validator timeout does not fail a deploy the way a revoked token does. See Validating credentials.

A 403 that is not about the key#

Every other failure on this page is about the job. This one is not: if the subscription covering these secrets lapses, the server refuses the machine plane exactly as it refuses the console, and pipelines start failing with 403 while every key and policy is still correct.

It arrives in two stages, and only the second one stops a deploy:

Account state Reads (get, list, run, export) Writes (set, patch, delete, import)
Read-only window keep working 403 ACCOUNT_READ_ONLY
Locked 403 ACCOUNT_LOCKED 403 ACCOUNT_LOCKED

So a deployment job that only injects values keeps running for the whole read-only window, and a rotation or bootstrap job that writes them fails from its first day. When the window closes, both stop.

The CLI says which of the two you are in, and says it is not the key:

text
this is the subscription, not this key
  The read-only window has closed and the account is locked, so reads are refused too.
  Nothing has been deleted. Subscribe again in the console and access returns at once.
  This key stays valid throughout — do not revoke or re-issue it.
Do not make this a retry rule

403 here is not transient and will not clear on its own — retrying burns pipeline minutes against an answer that cannot change until somebody renews the plan. It is also not a signal to rotate the credential: a freshly minted key is refused identically. Full detail, including what login does against a locked account, is in Signing in.

Was this page helpful?