> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tofunmiadewuyi.com/custos/llms.txt
> Use this file to discover all available pages before exploring further.

# Machine secrets

> Deliver a secret set to a host and launch an app with custosd exec.

A **secret set** is a named bundle of key/value pairs — one app's `.env`. Sets come in two kinds,
and a set is strictly one or the other.

| | Private set (default) | Public set |
| - | - | - |
| Purpose | deployable secrets | dev secrets a team shares |
| Read by | the machine, via `custosd exec` | people, via `GET /sets/{id}/reveal` |
| Bind to a host | yes | **never** |
| Reveal to a person | **never** — `403` | yes |

The boundary is enforced by the database, not by convention. Two triggers hold it:
`no_public_set_binding` rejects binding a public set to a host, and `reject_bound_set_public` rejects
making a bound set public. The API returns `409` with *unbind all hosts before making this set
public* if you try the second one.

So a deployable secret can never be read out of the UI, and a shared dev secret can never be shipped
to a machine. Pick the kind when you create the set; flipping it later means unbinding every host
first.

The rest of this page is about private sets — the delivery path. The daemon holds a set in memory
only, never on disk, and hands it to a local process through `custosd exec`.

## Create a set

```bash theme={null}
curl -sS -X POST "$CUSTOS_URL/sets" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"app-prod","entries":[{"key":"DATABASE_URL","value":"postgres://..."}]}'
```

Creating a set needs the global `set.add` permission. Pass `"public": true` for a team-shared set;
the default is private and therefore deployable. Update entries later with
`PUT /sets/{id}/entries` for the whole map, or `PUT /sets/{id}/entries/{key}` for one value.

## Bind it to a host

```bash theme={null}
curl -sS -X POST "$CUSTOS_URL/hosts/$HOST_ID/sets" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"set_id":"...","as_user":"app"}'
```

Binding exposes the set's secrets to the host, so it requires authority over both ends: `set.read`
on the set and `host.access` on the host. `as_user` is optional and restricts which Unix user may
consume the set. `DELETE /hosts/{id}/sets/{setId}` unbinds it.

The bind pushes a freshly sealed bundle to the host immediately if its daemon is connected, and on
reconnect otherwise.

## Launch the app

The important pattern is always:

```bash theme={null}
custosd exec --set SET_NAME -- APP_RUN_CMD
```

`custosd exec` fetches the bound machine-secret set from the local daemon, injects those values into
the child process environment, then replaces itself with `APP_RUN_CMD`.

## Socket access

Run `custosd exec` as a user that belongs to the `custos` Unix group. This applies to interactive
shell users and to service users used by systemd, PM2, Docker, or other process managers:

```bash theme={null}
sudo usermod -aG custos USER_NAME
```

Group changes take effect in a new login session. Log out and back in before testing an interactive
user; restart a service after changing its service user's groups.

The daemon creates `/run/custos/secrets.sock` with mode `0660` and ownership `custos:custos`.
`0660` means the owning user and group may read from and write to the socket (`6` = `4` read + `2`
write), while all other users have no access (`0`). Root can bypass this filesystem restriction.

An `as_user` binding is an additional restriction checked after the caller connects. Omitting
`as_user` allows any caller with access to the socket; it does not grant socket access to users
outside the `custos` group.

## Docker

Use this when the app runs in a container, but `custosd` runs on the host.

```bash theme={null}
custosd exec --set SET_NAME -- docker run --rm {{custos.expand:-e}} IMAGE_NAME
```

`custosd exec` injects the set into the `docker run` process. Docker then copies each named variable
from the Docker client's environment into the container because the flags are passed as `-e NAME`
instead of `-e NAME=value`.

Writing one `-e NAME` per variable does the same thing, but a set with a hundred variables needs a
hundred flags. `{{custos.expand:TEMPLATE}}` is a placeholder that `custosd exec` replaces, before
starting the command, with `TEMPLATE` repeated once per name in the set, sorted. The example above
expands to `docker run --rm -e TEST_1 -e TEST_2 -e TEST_3 IMAGE_NAME`.

Only names are expanded, never values, so secrets stay out of the command line where `ps` would show
them.

## Expansion templates

A template is split on spaces into arguments. Write `{}` where the name belongs; if the template has
no `{}`, the name is added as a final argument. That covers the shapes command line tools use:

| Template | Expands each name to |
| - | - |
| `{{custos.expand:-e}}` | `-e NAME` |
| `{{custos.expand:--env={}}}` | `--env=NAME` |
| `{{custos.expand:--pass env {}}}` | `--pass env NAME` |

So the same placeholder works for anything that reads a named variable from the environment of the
command that starts it, such as a Docker build:

```bash theme={null}
custosd exec --set SET_NAME -- docker build {{custos.expand:--build-arg}} -t IMAGE_NAME .
```

A space in a template separates one argument from the next, so no single argument can contain a
space. There is no quoting or escaping inside a template to work around this.

Quote the whole placeholder whenever the template contains a space, as in the `--pass env {}` row
above:

```bash theme={null}
custosd exec --set SET_NAME -- COMMAND "{{custos.expand:--pass env {}}}"
```

Without the quotes the shell splits the placeholder into separate arguments before `custosd` reads
it, and the command fails. Quotes are also what let a template contain a comma. A single-word
template such as `{{custos.expand:-e}}` needs no quotes.

A command with no placeholder runs exactly as before.

If the binding uses `as_user`, run the command as that exact Unix user.

## PM2

Use this when PM2 starts the app process directly on the host.

```bash theme={null}
custosd exec --set SET_NAME -- pm2 start ecosystem.config.cjs
```

The environment values are injected into the `pm2` command. PM2 then records that environment for the
started process.

If a process is already running, restart it through `custosd exec` so PM2 receives the fresh values:

```bash theme={null}
custosd exec --set SET_NAME -- pm2 restart APP_NAME --update-env
```

If the binding uses `as_user`, run the command as that exact Unix user.

## systemd

Use this when systemd owns the app lifecycle.

```ini theme={null}
[Unit]
Description=example app
After=network-online.target custosd.service
Wants=network-online.target
Requires=custosd.service

[Service]
User=app
Group=app
ExecStart=/usr/local/bin/custosd exec --set SET_NAME -- /usr/local/bin/app
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
```

As described under Socket access, add the service user to the `custos` group and restart the service
after its group membership is active:

```bash theme={null}
sudo usermod -aG custos app
sudo systemctl daemon-reload
sudo systemctl enable --now example-app.service
```

If the binding uses `as_user`, set `User=` to that exact Unix user.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.