Skip to main content

Working in CLI

Overview​

Using the console client tuna, you can get secrets, automatically configure the current environment, or pass configuration to the env of the required application. The console also lets you manage the structure itself — projects, environments, configs and service keys.

CommandsWhat for
list, get, set, delete, uploadReading and changing secrets in a config
runRunning an application with secrets in environment variables
download, substituteExporting secrets to a file or a template
setup, openBinding a directory to a config, opening it in a browser
projects, environments, configsManaging the structure and service keys

To get secrets through the tuna console application or API, you need a user API key or Configuration Service Key. For local development, it's more convenient to use global user API keys, but for CI/CD, Production, and other shared environments, we strongly recommend using Configuration Service Keys.

Why is tuna secrets better than direnv for local development?​

direnv is a great utility for local configuration, but it still makes you monitor the local .env file, and if new variables appeared in Production, you may not know about it immediately and spend hours on pointless debugging. In tuna secrets, there is automatic secret synchronization, which greatly simplifies this process.

Why is tuna secrets better than Gitlab/Github/Bitbucket Variables?​

Actually, the reason here is the same as with direnv. For example, you have a special project designed for building and publishing an application using goreleaser and it may require many keys and tokens for different external integrations, while for different environments, and using Gitlab/Github/Bitbucket Variables for this is inconvenient, and it's even more inconvenient to monitor this, plus the complexity in access control. In tuna secrets, access to environments is regulated by RBAC, and you can precisely distribute access only to the necessary people/robots/scripts. And by updating a secret or adding a new one, it will immediately start working in CI.

Authorization​

To access secrets, you need a Service Key of a specific configuration or a user API key. It can be passed through the --api-key flag, the TUNA_API_KEY environment variable, or saved to the config file with tuna config save-api-key <your-key>

The keys differ not only in scope, but also in the set of available commands:

KeyWhat it can do
Configuration service key (tst_)Read secrets of its own config: run, download, list, get. With write access — also set, delete and upload into the same config
User API key (tak_)Everything above plus structure management: projects, environments, configs and issuing service keys

A service key used against another config returns 403, and against structure management commands — 401. So keep a configuration service key in CI/CD, and create projects and configs with a user key.

Project and configuration​

Almost every command works in the context of a project and a configuration. There are three ways to set them, in order of priority:

  1. the --project (-p) and --config (-c) flags;
  2. the TUNA_SECRETS_PROJECT and TUNA_SECRETS_CONFIG environment variables;
  3. a directory bound to a config with the setup command.

The --scope flag (. by default) tells the client which directory's binding to use. See the setup section for details.

tuna secrets list --project api-php --config stage
tuna secrets list -p api-php -c stage
tuna secrets list # project and config from the directory binding

Output format​

The client separates data from service messages:

  • data goes to stdout;
  • status messages, warnings and errors go to stderr.

That is why the output of any command can be redirected to a file or piped without catching extra lines:

tuna secrets list --only-names > names.txt
tuna secrets list | grep API_

Tables​

For humans, commands print a borderless table: headers in caps, an empty cell is -, dates are RFC3339 in your local time zone. Such a table is easy to cut with awk and does not fall apart in a narrow terminal.

$ tuna secrets list
NAME VALUE TYPE NOTE UPDATED
JKLNRFEDUILNNIF hmac:cea41705 string - 2026-09-17T11:56:51+04:00

Value fingerprints​

By default the VALUE column contains not the secret value but its fingerprint, like hmac:cea41705 — an HMAC-SHA256 of the value keyed with the command's key.

Equal values produce equal fingerprints, and the value cannot be restored from a fingerprint. So fingerprints can be compared across configs ("are the database passwords in stage and prod really different?") and printed into CI logs without disclosing the secret.

Here is what a comparison of two environments looks like: API_TOKEN and API_ENABLED are the same in dev and prod, while API_DB and LOG_LEVEL differ — and none of the values had to be disclosed.


Real values are shown by list --values and get:

$ tuna secrets list --values
NAME VALUE TYPE NOTE UPDATED
JKLNRFEDUILNNIF vcTpFuptQLtJ0beoTQA13rHNeAaGNtjs string - 2026-09-17T11:56:51+04:00

JSON and YAML​

The -o (--output) flag with json or yaml is available on every tuna secrets subcommand, including write commands: set, delete, upload, create, rename, lock and the rest print the result in machine-readable form.

$ tuna secrets set FOO=bar2 -o json
{
"FOO": {
"id": 42,
"name": "FOO",
"value": "bar2",
"fingerprint": "hmac:b274b135",
"value_type": "string",
"visibility": "masked",
"override": false,
"note": "",
"can_be_converged": false,
"can_be_promoted": false,
"reminder": null,
"created_at": "2026-09-18T06:43:10Z",
"updated_at": "2026-09-18T06:43:10Z"
}
}

In machine-readable output a secret comes with all its fields and with the real value — unlike the table, where a fingerprint is shown by default. Keep that in mind when redirecting such output to a file or a log. The exception is a secret with restricted visibility: the server returns neither the value nor the fingerprint, so both fields come back empty.

An empty result is always a valid [] or {} rather than no output at all, so a script parser needs no special case for it.

Raw values for scripts​

The --plain flag of get and configs tokens create prints only the value itself, without a trailing newline. This is handy for substitution into another command:

$ docker login -u robot -p "$(tuna secrets get REGISTRY_PASSWORD --plain)" registry.example.com

Errors​

Errors are printed to stderr, and the exit code is 1:

$ tuna secrets list -p test -c nonexistent
Error: Failed to list secrets in test/nonexistent: Config does not exist
Code: NotFound
Request ID: 83d3961b1452ab3c7174965acbfbd711

With -o json the error becomes JSON as well — it can be parsed and, for example, the request_id can be attached to a support request:

{"code":"NotFound","error":"Failed to list secrets in test/nonexistent: Config does not exist","request_id":"83d3961b1452ab3c7174965acbfbd711"}

Working with secrets​

List​

Lists the secrets of a config. The alias is ls.

FlagDescription
--valuesShow values instead of fingerprints
--only-namesNames only, in a single column
$ tuna secrets list
NAME VALUE TYPE NOTE UPDATED
JKLNRFEDUILNNIF hmac:cea41705 string - 2026-09-17T11:56:51+04:00

In the recording the LOG_LEVEL fingerprint changes together with the value, and list --values then shows the value itself:


Get​

Values of one or more secrets. Unlike list, get shows values right away.

FlagDescription
--plainValues only, without the table and without a trailing newline
$ tuna secrets get API_TOKEN API_DB
NAME VALUE TYPE NOTE UPDATED
API_DB postgres://postgres@db:5432/api string - 2026-09-17T11:56:51+04:00
API_TOKEN PwZayIp9NpNntNYd2jkAAQgViM6dXdIm string - 2026-09-17T11:56:51+04:00

If a requested secret is missing from the config, the command fails and prints nothing — this way a typo in a name does not turn into an empty variable.

Set​

Writes one or more secrets.

FlagDescription
--typeValue type: string, json, boolean, integer, decimal, email, url, yaml, xml, date, datetime
--noteA note for the secret
--visibilityValue visibility in the web interface: masked or unmasked
--no-interactiveFail instead of asking for the value interactively

The value can be passed in four ways:

$ tuna secrets set API_TOKEN=PwZayIp9NpNntNYd2jkAAQgViM6dXdIm # inline
$ tuna secrets set API_TOKEN PwZayIp9NpNntNYd2jkAAQgViM6dXdIm # as a separate argument
$ openssl rand -hex 32 | tuna secrets set API_TOKEN # from stdin
$ tuna secrets set API_TOKEN # interactively

Interactive input only kicks in for a single name without = when nothing arrived on stdin; with --no-interactive you get an error instead. The value may be multi-line, a private key for example: the input ends with an empty line followed by a line holding a single period.

Several secrets can be written in one call; the --type, --note and --visibility flags then apply to all of them:

$ tuna secrets set API_ENABLED=true API_DEBUG=false --type boolean

The command prints back the secrets it wrote:

$ tuna secrets set FOO=bar
NAME VALUE TYPE NOTE UPDATED
FOO hmac:bd154f88 string - 2026-09-18T10:43:10+04:00
Do not use the restricted visibility

The --visibility flag also accepts restricted, but you should not use it for now: the server stops returning the value of such a secret to everyone — including tuna secrets run — and tuna secrets download starts answering 403 for the whole config. The value cannot be recovered afterwards.

If somebody changed the same secret between the read and the write, the command reports it and fails without overwriting the other change. Repeating the call is enough in that case.

Delete​

Deletes one or more secrets. The alias is rm.

$ tuna secrets delete FOO
NAME VALUE TYPE NOTE UPDATED
API_TOKEN hmac:3e96898b string - 2026-09-18T10:43:09+04:00
DB_HOST hmac:829a119e string - 2026-09-18T10:43:09+04:00
DB_PORT hmac:620ef1fa string - 2026-09-18T10:43:09+04:00

The command prints the config as it remains after the deletion.

If at least one of the listed secrets is missing from the config, nothing is deleted.

Upload​

Uploads secrets from a .env or .json file.

$ tuna secrets upload .env
NAME VALUE TYPE NOTE UPDATED
API_TOKEN hmac:1ea77bae string - 2026-09-18T10:43:11+04:00
DB_HOST hmac:829a119e string - 2026-09-18T10:43:09+04:00
DB_PORT hmac:620ef1fa string - 2026-09-18T10:43:09+04:00
REDIS_URL hmac:3df78001 string - 2026-09-18T10:43:11+04:00

Like delete, the command prints the whole config — after the merge.

The file adds to the config: secrets with matching names are overwritten, the rest stay in place. upload does not replace the config as a whole.

Running applications​

The run command starts your application and passes secrets to it in environment variables. The secrets end up neither in a file nor in the shell history.

tuna secrets run -- yarn start

What the process receives​

FlagDescription
--commandExecute a string through the shell: --command "echo $HOME && env"
--only-secretsPass only the secrets and a minimum of system variables (PATH, HOME, PS1, USER, SHELL, TMPDIR)
--preserve-envA comma-separated list of names for which an already existing environment value wins; without a value the flag means true — preserve all of them
--name-transformerTransform names: upper-camel, camel, lower-snake, lower-kebab, tf-var, dotnet-env
--forward-signalsForward signals to the child process; enabled by default when stdout is not a terminal

tuna returns the exit code of the child process, so the wrapper can be placed into a CI job without breaking its logic.

$ tuna secrets run --project api-php --config stage -- yarn start
$ tuna secrets run --scope /home/user/src/api -- yarn start
$ tuna secrets run --only-secrets -- env

Automatic restart when a secret changes​

If you pass the --watch flag, the client subscribes to config changes and restarts the process with the new values.

tuna secrets run --watch -- yarn start

The restart does not happen instantly: the server checks the config once every 30 seconds and only then sends the event. The same delay can pass between a secret change and the application restart — so a couple of dozen quiet seconds mean --watch is working, not broken.

The moment of the restart is visible on stderr:

Secrets changed; restarting process

In the recording the application prints the value every five seconds: first the old one, then the restart line, then the new one.


This video best demonstrates the work:

In the video, we run this command:

tuna secrets run --watch -- bash -c 'while true; do echo -n "$(date -Is) [pid: $$] ==> " ; echo ${API_TOKEN} ; sleep 1; done'

Secrets as a file instead of environment variables​

Some applications read configuration only from a file. The --mount flag creates a temporary named pipe, puts the secrets into it and passes the path to the child process in the TUNA_SECRETS_MOUNT_PATH variable. In this mode the secrets do not go into the environment.

The pipe is created at the given path with 0600 permissions, but the values live only in the client's memory: every open for reading gets a full snapshot and an EOF, and no file with secrets is ever written to disk. Once the process exits, the pipe is removed.

$ tuna secrets run --mount ./secrets.json --mount-format json -- ./app --config "$TUNA_SECRETS_MOUNT_PATH"
FlagDescription
--mountPath of the pipe to create
--mount-formatContent format: env (default), env-no-quotes, json, yaml, docker
--mount-templateA template file rendered into the pipe instead of a fixed format
--mount-max-readsHow many times the file may be read, 0 for unlimited

The --mount-max-reads N flag removes the pipe after N reads — handy when exactly one process reads the secrets at startup.

info

Named pipes are not supported on Windows, so --mount will not work there.

If the client is killed with SIGKILL, the pipe stays on disk and the next run refuses to start with mount path already exists. Such a file has to be removed manually.

Offline mode​

run can keep the last successfully fetched secrets in an encrypted file and use them when the API is unavailable — the application will not stall because of network problems.

FlagDescription
--fallbackPath to the file; by default derived from the API address, the key, the project and the config
--fallback-only, --offlineRead only the file, without contacting the API
--fallback-readonlyUse the file but never update it
--no-fallbackNeither read nor write the file
--fallback-passphraseThe passphrase; by default derived from the key, the project and the config. Also read from TUNA_SECRETS_FALLBACK_PASSPHRASE

If the API answers that access is denied or the config is not found (401, 403, 404), the file is removed: a stale copy of secrets after access revocation is more dangerous than a stopped application. On network errors and 5xx, on the contrary, the client reads the file and prints a warning.

By default the passphrase is derived from the API key, the project and the config — which means that after a key rotation the old file can no longer be decrypted. If the file has to survive rotation, set the passphrase explicitly: the --fallback-passphrase flag takes precedence over the TUNA_SECRETS_FALLBACK_PASSPHRASE variable.

Old files can be removed with run clean:

$ tuna secrets run clean --dry-run
Would delete 0 fallback file(s)

$ tuna secrets run clean --max-age 720h
$ tuna secrets run clean --all

By default files older than 14 days are removed (--max-age 336h).

The first run goes to the API and writes the file along the way, the second one with --offline needs no network at all:


Exporting secrets​

Download​

Gets the secrets of a config into a file or to standard output.

tuna secrets download

FlagDescription
--formatjson (default), yaml, env, env-no-quotes, docker
--no-filePrint to standard output instead of writing a file
--name-transformerTransform names: upper-camel, camel, lower-snake, lower-kebab, tf-var, dotnet, dotnet-env
--scopeThe directory whose binding to use

The settings can also be given through environment variables.

Passing project and configuration​

The --project and --config flags take the name (alias) of the project and configuration as input. By default, the configuration will be saved to the tuna.json file in the current directory.

$ tuna secrets download --project api-php --config stage
Downloaded secrets to tuna.json

$ cat tuna.json
{
"API_DB": "postgres://postgres:postgres@localhost:5432/postgres",
"API_TOKEN": "PwZayIp9NpNntNYd2jkAAQgViM6dXdIm"
}

Passing format​

You can specify the format you need for the file.

$ tuna secrets download --project api-php --config stage --format json
Downloaded secrets to tuna.json

$ cat tuna.json
{
"API_DB": "postgres://postgres:postgres@localhost:5432/postgres",
"API_TOKEN": "PwZayIp9NpNntNYd2jkAAQgViM6dXdIm"
}

An unknown format is an error rather than a silent export of "something".

The docker format​

The docker format prints KEY=value pairs without quotes — the same as env-no-quotes. Such a file suits docker run --env-file, where quotes would become part of the value:

$ tuna secrets download --format docker --no-file
API_TOKEN=s3cr3t-token-value
DB_HOST=db.internal
DB_PORT=5432

$ tuna secrets download --format docker tuna.env
$ docker run --env-file tuna.env myapp

Name transformation​

The --name-transformer flag changes the naming style — useful when the consumer of the secrets does not expect SCREAMING_SNAKE_CASE:

$ tuna secrets download --no-file --format json --name-transformer camel
{
"apiDb": "postgres://postgres:postgres@localhost:5432/postgres",
"apiToken": "PwZayIp9NpNntNYd2jkAAQgViM6dXdIm"
}

The dotnet transformer exists only in download: it produces names like Section:Key, and a colon is not allowed in an environment variable name. That is why tuna secrets run does not offer it — for .NET it has dotnet-env with a double underscore.

Custom file​

You can pass a path in the command to define your own file.

$ tuna secrets download --project api-php --config stage foo-bar.json
Downloaded secrets to foo-bar.json

Output to stdout​

The --no-file flag disables writing to a file and prints the configuration to standard output.

$ tuna secrets download --project api-php --config stage --no-file --format env
API_TOKEN="PwZayIp9NpNntNYd2jkAAQgViM6dXdIm"
API_DB="postgres://postgres:postgres@localhost:5432/postgres"

Substitute​

Renders a template file with the secret values. It fits configs that have their own format: nginx.conf, application.yml, .ini and the like.

The template is a Go text/template, and the data is a set of name-value pairs:

config.tmpl
database_url = {{ .DB_URL }}
api_key = {{ index . "API-KEY" }}
extra = {{ index . "FEATURES" | fromjson | tojson }}
$ tuna secrets substitute config.tmpl --output config.ini
$ tuna secrets substitute config.tmpl | kubectl apply -f -

Without --output the result goes to standard output. With --output the file is written atomically and with 0600 permissions.

The {{ index . "DB-NAME" }} form is needed for names that are not Go identifiers. Besides the standard functions, tojson and fromjson are available.

If the template mentions a secret that is missing from the config, the command does not substitute an empty value — it exits with code 1. The message shows both the position in the template and the name itself:

$ tuna secrets substitute app.conf.tpl
Error: Failed to render template: template: secrets:2:11: executing "secrets" at <.API_TOKN>: map has no entry for key "API_TOKN"

For the same reason a {{ if .DEBUG }} condition on an optional secret is an error too. Write it through index — on a missing key it returns an empty string:

{{ if index . "DEBUG" }}debug{{ else }}quiet{{ end }}

The template in tuna secrets run --mount-template is rendered just as strictly — the engine is the same.

Setting up the environment​

Setup​

Binds a directory to a project and a config — after that --project and --config can be omitted. Bindings are stored in the client config file.

tuna secrets setup

Without flags the command offers a list of projects and configs to choose from.

Setting up without the menu​

As with download, in setup you can pass --project and --config:

$ tuna secrets setup --project api-php --config stage
SCOPE PROJECT CONFIG
/home/user/src/api api-php stage

The --no-interactive flag (or the TUNA_SECRETS_NO_INTERACTIVE variable) forbids asking for missing values — the command fails instead. That is what you want in CI and in install scripts.

Setting up a directory other than the current one​

If you want to configure a non-current directory, pass the required path to the --scope flag:

tuna secrets setup --project api-php --config stage --scope /home/user/src/api

The tuna.yaml file in a repository​

So that a new developer only needs a single tuna secrets setup, put a tuna.yaml file into the repository root:

tuna.yaml
setup:
- project: api-php
config: dev

For a monorepo you can describe several bindings, each entry with its own path relative to the root:

tuna.yaml
setup:
- project: api-php
config: dev
path: backend
- project: ui
config: dev
path: frontend

The file is read only when the project and the config are not set by flags or environment variables, and only in the --scope directory — the client does not walk up the tree.

What a binding looks like​

The result of setup goes into the client config file:

$ cat ~/.config/tuna/tuna.yml
secrets:
scoped:
/home/user/src/api:
project: api-php
config: stage

While you are in /home/user/src/api, there is no need to pass the project and the config. From another directory either pass them explicitly or point --scope at the bound one:

$ pwd
/home/user/src/worker

$ tuna secrets download --format env --no-file --scope /home/user/src/api
API_TOKEN="PwZayIp9NpNntNYd2jkAAQgViM6dXdIm"
API_DB="postgres://postgres:postgres@localhost:5432/postgres"

Open​

Opens the current config in the web interface:

tuna secrets open

If the browser could not be opened, the command prints the link. With -o json it prints just the address.

Managing the structure​

Projects, environments and configs can be created and changed right from the console — the same actions as in the web interface.

Projects​

$ tuna secrets projects
SLUG NAME DESCRIPTION CONFIGS SECRETS CREATED
test test - 5 12 2026-09-17T11:20:40+04:00
CommandDescription
projectsList of projects
projects get [project]Information about a project
projects create [name]Create a project: --slug, --description
projects update [project]Change it: --name, --slug, --description
projects delete [project]Delete it; without an argument it offers a list to choose from
$ tuna secrets projects create "API PHP" --slug api-php --description "Main API"

Environments​

$ tuna secrets environments --project api-php
SLUG NAME PERSONAL CONFIGS CREATED
dev Development yes 2026-09-18T10:43:08+04:00
stage Staging no 2026-09-18T10:43:09+04:00
prod Production no 2026-09-18T10:43:09+04:00
CommandDescription
environmentsList of the project's environments
environments get ENVIRONMENTInformation about an environment
environments create NAMECreate it: --slug, --personal-configs
environments rename ENVIRONMENTRename it: --name, --slug
environments delete ENVIRONMENTDelete an environment

The command aliases are envs and environment.

Configs​

$ tuna secrets configs --project api-php
SLUG ENVIRONMENT TYPE LOCKED CREATED LAST FETCH
dev dev original yes 2026-09-17T11:20:40+04:00 2026-09-17T16:20:35+04:00
prod/fghd prod duplicate no 2026-09-17T11:55:14+04:00 2026-09-17T16:17:42+04:00

The TYPE column tells the environment's main config (original) from its copy (duplicate), and LAST FETCH shows when the secrets were fetched last — it is a good hint of whether anyone uses the config at all.

CommandDescription
configsList of the project's configs, filtered by --environment
configs get [config]Information about a config
configs create [name]Create it in an environment: --environment, --locked
configs clone [config]Copy it within its environment: --name
configs rename [config]Rename it: --name
configs lock [config]Forbid changes
configs unlock [config]Lift the lock
configs delete [config]Delete a config
configs logsChange history: --page, --page-size

Change history​

$ tuna secrets configs logs
TIME ACTOR ACTION SECRET
2026-09-17T16:20:37+04:00 Evgeniy secret_delete CLAUDE_ML
info

The config change history is currently available for the last three days only — older records are deleted automatically. This is a current limitation, not a plan setting.

Service keys​

A service key is limited to a single config — this is what you should use in CI/CD instead of a user API key.

CommandDescription
configs tokensList of the config's keys
configs tokens createIssue a key: --description, --access, --max-age, --plain
configs tokens revoke IDRevoke a key by its identifier
create flagDescription
--accessread (default) or write
--max-ageLifetime as a Go duration, for example 720h; without it the key never expires
--descriptionA description to recognize the key by in the list
--plainPrint only the key itself, without the table and without a trailing newline

The key value is shown once — at creation:

$ tuna secrets configs tokens create --description ci --access read --max-age 720h
Save this token - it will not be shown again
ID DESCRIPTION ACCESS EXPIRES CREATED TOKEN
5 ci read 2026-10-18T10:43:31+04:00 2026-09-18T10:43:31+04:00 tst_...

$ tuna secrets configs tokens
ID DESCRIPTION ACCESS EXPIRES CREATED
6 ci-plain read 2026-10-18T10:43:31+04:00 2026-09-18T10:43:31+04:00
5 ci read 2026-10-18T10:43:31+04:00 2026-09-18T10:43:31+04:00

The "Save this token" warning goes to stderr, so in a script the key can be captured straight into a variable:

$ TUNA_API_KEY=$(tuna secrets configs tokens create --description "gitlab ci" --access read --max-age 720h --plain)