Skip to content

Secrets and configs

A secret is a payload the cluster keeps encrypted and delivers into a container's memory. A config is the same machinery without the secrecy — a file you want in /etc without baking it into an image.

printf 'hunter2' | satl secret create db_password -
satl config create app.conf ./app.conf

satl service create --name web \
    --secret db_password \
    --secret source=api_key,target=keys/api,uid=1000,gid=1000,mode=0400 \
    --config source=app.conf,target=/etc/app/app.conf \
    registry.example.com/app:1

Manager-only

This is cluster state, so it is served by managers. On a worker node the same command answers Docker's own refusal, verbatim, with HTTP 503:

Error response from daemon: This node is not a swarm manager. Worker nodes
can't be used to view or modify cluster state. Please run this command on a
manager node or promote the current node to a manager.

Creating one

satl secret create <name> <file> reads the payload from a file, or from stdin when the file is -. There is no flag that takes the value inline, deliberately: a secret on a command line is a secret in the shell history and in ps output.

printf 'hunter2' | satl secret create db_password -
satl secret create db_password ./password.txt
satl config create app.conf ./app.conf

satl secret ls, satl secret inspect and satl secret rm complete the set, and satl config mirrors all four. inspect returns metadata only — the payload is never served back by the API, for either kind.

Size limits: a secret payload is under 500 KiB, a config under 1000 KiB. The CLI enforces them before it encodes or sends anything (secret data is N bytes; the limit is 500 KiB), and the daemon enforces the same limits for raw API callers.

Referencing one from a service

--secret and --config take Docker's syntax: a bare name, or a comma-separated form.

--secret db_password
--secret source=api_key,target=keys/api,uid=1000,gid=1000,mode=0400
--config source=app.conf,target=/etc/app/app.conf

src= is an accepted alias for source=, keys are case-insensitive, and mode is octal.

References are resolved by ID: the CLI lists the objects once to turn your name into an id before posting the spec. A reference naming something that does not exist is refused at service-create time, rather than failing every task the service ever schedules.

satl service update has no --secret-add/--secret-rm/--config-add/ --config-rm. Existing references survive an update untouched — the CLI reposts the stored spec — but changing the set needs the REST API.

Where it lands inside the container

Secret Config
target relative, rooted at /run/secrets absolute, or relative and rooted at /
storage a per-task tmpfs a read-only nullfs file-mount
default mode 0444 0444
owner 0:0 unless given 0:0 unless given

So --secret db_password puts the payload at /run/secrets/db_password, and --secret source=api_key,target=keys/api puts it at /run/secrets/keys/api.

An absolute secret target is a 400:

invalid secret target /etc/api_key: secret target must be a relative path;
secrets are mounted under /run/secrets

Docker allows an arbitrary absolute path and bind-mounts the file there. SatL delivers every secret through one per-task tmpfs, because a secret written anywhere else would be a secret on the node's disk. A config target may be absolute — that is how a config lands in /etc — and neither kind may contain a .. component.

File.UID and File.GID must be numeric

Docker resolves user and group names from the image's /etc/passwd and /etc/group. SatL does not read the image's user database, so a name is refused when the task is planned: the task goes REJECTED with a message naming the reference, rather than being silently owned by root. Use uid=1000,gid=1000.

/run/secrets is a writable tmpfs, and the file mode is the protection

Docker remounts its secret tmpfs read-only. SatL's is writable, with each file carrying the mode and ownership you asked for — so an unprivileged process in the jail cannot alter them, and root inside the jail can. The protection is the file mode, not the mount flag. Making the mount read-only needs a second mount pass after the files are written, which is not done yet.

The guarantee

A secret is encrypted at rest on managers, in memory only on workers

  • On managers, the payload lives in the Raft store, and the whole Raft log and its snapshots are encrypted at rest with the node's dek. There is no plaintext copy on a manager's disk.
  • On workers, the payload arrives over the mutually authenticated dispatcher stream, lives in the agent's memory, and is written only into the per-task tmpfs inside the jail. The node's local task database stores secret references, never payloads; after an agent restart the payloads are re-fetched from its session.
  • The log never carries a payload. Secret names appear (materialized dependency payload, secret assigned/withdrawn); the bytes do not. If a payload ever shows up in /var/log/messages that is a bug worth reporting — the integration suite greps for exactly that.

Configs get the same delivery path without the secrecy claim: their payloads are written under the task's bundle directory on the node's disk before being mounted read-only into the jail.

Rotation is by replacement

There is no update verb. satl secret has create, ls, inspect and rm, and the REST endpoint says so:

$ curl -s --unix-socket /var/run/satl.sock -X POST http://localhost/secrets/db_password/update
{"message":"secrets are immutable; rotate by creating a new secret, updating the services
that use it, and removing the old one"}

That 501 is the documented path, spelled out in the error itself. Docker's own endpoint accepts a whole spec and honours only a change of Labels — the payload is immutable there too — and a 200 that silently ignored the Data you just sent is the worst of the three possible answers.

The rotation, in full:

# 1. create the new secret under a new name
printf 'new-password' | satl secret create db_password_v2 -

# 2. update the services that use it (this replaces their tasks)
satl service update --image <same or new> web     # + the spec change over the API

# 3. remove the old one, once nothing references it
satl secret rm db_password

Step 2 is what actually rotates anything: a running task holds the payload it was given at creation, in memory, and only a new task gets the new one. Creating a secret changes nothing on its own.

Step 2 needs the REST API today

satl service update has no flag for changing the secret set, so the spec edit has to go over POST /services/{id}/update. The task replacement it triggers is an ordinary rolling update and obeys the service's update policy.

Removal is refused while anything references it

$ satl secret rm db_password
Error response from daemon: secret db_password is in use by the following
service(s): api, web. Update or remove them first

A 409, naming the services to fix first — at most four names, then "and N more". Docker answers 400 here; SatL follows its own network precedent, because "the object exists but is in the wrong state" is what 409 means and it is what the CLI turns into a usable exit code.

"In use" is deliberately wider than Docker's check: it counts every service whose task template merely references the object — whose next task would be unpreparable — as well as every non-terminal task holding it. A terminal task is history, not a use.

That refusal is what keeps a running task from losing a secret it was promised. The dispatcher tolerates the race anyway — a secret deleted mid-flight is withdrawn from the nodes and logged — but in normal operation the API makes that path unreachable.

What is refused, and why

Field Answer
SecretSpec.Driver 400, "secret drivers are not supported". There are no driver plugins, so an external store cannot be honoured — and a secret quietly stored in the cluster instead would be exactly the leak the driver existed to prevent
Templating 400 when its Name is non-empty, for both kinds. There is no template engine, and a config whose {{ }} placeholders were never expanded is a broken file delivered as a correct one
File.Mode above 0o7777 400. Go's os.FileMode has type bits above the permission bits, and SatL will not silently mask them
?filters= on list 501 rather than ignored. Filter with the client

satl secret ls prints Docker's DRIVER column, always blank, because Docker's layout has it. IDs are printed in full — SatL ids are 25 characters, and there is no --no-trunc because nothing is truncated.