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:
Key: PULSVAULT_TOKEN
Value: pv_live_…
Type: Variable
Flags: ☑ Masked ☑ ProtectedGitLab 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:
deploy:
script:
- pulsvault run -n acme-web/prod/product/web -- ./deploy.shMasked 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.
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:
- name: Deploy
env:
PULSVAULT_TOKEN: ${{ secrets.PULSVAULT_TOKEN }}
run: pulsvault run -n acme-web/prod/product/web -- ./deploy.shAlready 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:
export PULSVAULT_TOKEN=$CI_PULSVAULT_KEY # only when it is stored under a different nameSelf-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:
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.jarYour 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.
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:
pulsvault run -n acme-web/prod/product/web -- npm startAdopting 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:
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 # identicalKeep 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.
The command is executed, not interpreted, so a script needs a shebang (#!/usr/bin/env bash) and the
executable bit. Without them you get:
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.shFor 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:
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 URLOne 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:
$ 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/appOne file, both times, byte-identical afterwards. What that buys:
- One script instead of
deploy.dev.shanddeploy.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:
export PULSVAULT_PROJECT=acme-web
pulsvault run -n prod/product/web -- ./deploy.sh # == acme-web/prod/product/webIt 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:
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:
$ 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:::
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:
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:
warning: acme-web/prod/product/web resolved 0 secrets — the command runs with nothing injectedSeeing what was injected#
When it resolves something, run names it — on a terminal, once the child has actually started:
$ 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_KEYNames, 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:
pulsvault run -n acme-web/prod/product/web --dry-rundry run: 4 secret(s) from acme-web/prod/product/web would be injected into: no command given
DB_PASSWORD
DB_URL
GH_TOKEN
STRIPE_KEYIt 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:
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.
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:
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:
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.
:::
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:
$ 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:
pulsvault run -n acme-web/prod/product/web --clean -- ./build.shThose 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:
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:
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 eachThe 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:
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.runnames 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) or126(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 numberbashreports. An OOM kill is137, a SIGTERM is143. 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, andrunwaits 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-timeoutseconds (default 10) and is then SIGKILLed. A second Ctrl-C skips the wait.
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 allThat 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:
pulsvault run --watch --interval 60 -n acme-web/prod/product/web -- ./serverA 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:
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 shellA folder prefix works here as well:
pulsvault export acme-web/prod/product/web/postgres --out postgres.envexport … > .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.
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 0002Either 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:
pulsvault template app.conf.tmpl --out app.conf
pulsvault template app.conf.tmpl --out app.conf --watch --interval 60database_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:
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:
would import acme-web/prod/product/web/db-password (from DB_PASSWORD)
would import acme-web/prod/product/web/api-key (from API_KEY)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:
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 emptyFill 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:
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 injectedEach 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.
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:
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.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.
Related#
- CI/CD pipelines — the GitHub Action and GitLab CI template
- Validating credentials — gating a deploy
- Command reference — exact flags