Composer requires access to the Docker (or Podman) socket to manage containers and compose stacks. This is inherent to its purpose -- it's a Docker management tool.
The Docker socket grants the ability to:
- Create, start, stop, remove containers
- Pull images
- Execute commands inside containers
- Access Docker volumes and networks
This is equivalent to root access on the host. There is no way to meaningfully restrict this for a Docker management tool that needs to run docker compose up/down.
| Mitigation | How |
|---|---|
| Non-root process | composerd runs as the composer user (not root), via su-exec privilege drop |
| Minimal capabilities | cap_drop: ALL + only CHOWN, SETUID, SETGID, DAC_OVERRIDE (for entrypoint PUID/PGID setup) |
| No new privileges | security_opt: no-new-privileges:true prevents privilege escalation |
| Container scope validation | Container start/stop/restart restricted to Docker Compose-managed containers |
| Compose exec allowlist | Stack console only permits safe subcommands (ps, logs, top, config, images, exec, etc.) |
- Run Composer behind a reverse proxy with TLS -- see reverse-proxy.md for Caddy, Traefik, and nginx configs
- Set
COMPOSER_ENCRYPTION_KEY-- encrypts git credentials and webhook secrets at rest - Use strong passwords -- the bootstrap password becomes the admin account
- Use API keys with minimal roles -- for automation, create Operator or Viewer keys, not Admin
The self-upgrade endpoint (POST /api/v1/system/upgrade) and _system
webhooks launch a short-lived helper container that pulls a new image and
recreates composer. Threat model and mitigations:
- Admin-only / HMAC-only triggers. The REST endpoint requires an admin session or API key; the webhook path validates the per-webhook HMAC secret before any dispatch. No other role can start an upgrade.
- Image prefix constraint. The target image must start with
COMPOSER_UPGRADE_IMAGE_PREFIX(defaultghcr.io/erfianugrah/composer). This prevents the endpoint from being used to run arbitrary images with a mounted Docker socket. Treat the prefix value as security-sensitive config. - The helper is as privileged as composer itself. It gets the Docker
socket (required to recreate composer) and runs as root inside its
container. It is labeled
io.composer.upgrade-helper=true, publishes no ports, and is removed after completion (or swept at the next boot). - No new secret surface. The helper receives only paths and the target
image reference; the ack sentinel and args file live in the data volume
(args file is written
0600). - Audit. The
system_upgraderow recordsstarted_by(user ID orwebhook:<id>), the target image, and the outcome; both trigger paths also produce standard audit log entries.
Encryption is automatic -- no configuration needed:
- Git credentials (tokens, SSH keys, passwords) are encrypted with AES-256-GCM before storage in the database
- Webhook secrets are encrypted with AES-256-GCM before storage in the database
- Docker registry credentials (passwords / PATs in
registry_credentials) are encrypted with AES-256-GCM before storage. See Docker Registry Auth - SSH key files on disk at
/home/composer/.ssh/are encrypted at rest on startup (see SSH Key Files for the targeting policy) - Per-stack SSH key files can be specified via the
ssh_key_filefield (auth methodssh_file), allowing individual stacks to use dedicated keys .envfiles are written with0600permissions (owner read/write only)- A unique 12-byte nonce is generated per encryption operation
- Encrypted values are prefixed with
enc:for identification (both DB strings and key files) - Backwards compatible: unencrypted data from before encryption is read normally
On every startup Composer scans a list of SSH directories for plaintext private key files and encrypts them in place. When go-git needs an SSH key for clone/pull operations, the file is transparently decrypted in memory.
The scan is explicit and opt-in — it never infers a target from $HOME:
- Default target:
/home/composer/.ssh(the canonical container path). Every composer Docker image runs as thecomposeruser with that HOME, so this covers the production case. On a developer machine the directory doesn't exist, so the scan is a no-op. - Additional targets: set
COMPOSER_SSH_DIR=/path/to/dirto have the scan also cover a custom location. Use this when running composerd outside the official container with SSH material you want encrypted.
This policy was tightened after an incident where running composerd ad-hoc on a developer workstation encrypted the operator's personal ~/.ssh directory. The old behaviour unconditionally scanned $HOME/.ssh and /home/composer/.ssh; the new behaviour only touches paths the operator asked for.
A file is touched only if both conditions hold:
- Its filename is not on the skip list (
config,authorized_keys, anything starting withknown_hosts, anything ending in.pub, already-encrypted files with anenc:prefix, empty files) - Its first line contains a recognised private-key BEGIN marker (
-----BEGIN OPENSSH PRIVATE KEY-----,-----BEGIN RSA PRIVATE KEY-----, etc.)
This two-step check means arbitrary non-key files a user drops in the SSH directory (notes, backups, random text) are left alone even if their filename doesn't match a skip rule.
Mounting an SSH key file into the container (for example via -v ~/.ssh:/home/composer/.ssh:rw) will cause those keys to be encrypted on first boot. Mount read-only if you want to keep the plaintext intact on the host:
volumes:
- ${HOME}/.ssh:/home/composer/.ssh:roIf a key got encrypted and you still have the corresponding encryption.key file, use cmd/decryptssh to reverse it:
COMPOSER_DATA_DIR=/path/to/data go run ./cmd/decryptssh -- ~/.ssh/id_foo
# or
COMPOSER_ENCRYPTION_KEY='...' go run ./cmd/decryptssh -- ~/.ssh/id_fooThe tool accepts --dry-run to preview, writes to a .tmp sibling and renames only on success so a mid-write crash leaves the encrypted original intact.
Key resolution (in priority order):
COMPOSER_ENCRYPTION_KEYenv var (explicit override, SHA-256 derived)COMPOSER_DATA_DIR/encryption.keyfile (auto-generated on first run)- If neither exists, a 32-byte random key is generated, saved to the key file, and used
The key file is created with 0600 permissions (owner-read only). Back it up -- losing it means encrypted credentials and SSH keys can't be decrypted.
Composer can transparently decrypt SOPS-encrypted .env files and compose YAML files before deployment:
- Detection: checks for SOPS markers in dotenv (
sops_version=), YAML (sops:key), and JSON ("sops"key) formats - Decryption: shells out to the bundled
sopsbinary (v3.12.2) for reliable, format-aware decryption - Timing:
.envand compose files are decrypted in-place after git pull and beforedocker compose up - Age key resolution (per-stack overrides global):
- Per-stack
age_keyfield in git credentials (stored encrypted in DB) COMPOSER_SOPS_AGE_KEYenv varSOPS_AGE_KEYenv var (standard SOPS convention)SOPS_AGE_KEYSenv var (multi-line format with comments)COMPOSER_SOPS_AGE_KEY_FILE/SOPS_AGE_KEY_FILEenv varCOMPOSER_DATA_DIR/age.keyfile~/.config/sops/age/keys.txt(standard SOPS location)
- Per-stack
- No auto-generation of age keys -- the user must bring their own or explicitly generate one
- Decrypted files are written with mode
0600
For git-backed stacks, .env is read from the repo root (<stack>/.env) by default — matching docker compose's native behaviour. Set env_path on the stack (relative to the repo root, e.g. deploy/unraid/.env) when the .env lives next to a compose file in a subdirectory. The path is honoured by every code path that reads or writes the file: SOPS decrypt/re-encrypt, the API getter/setter, and the UI editor.
Composer authenticates docker compose pull / docker compose up against private registries without modifying the host's ~/.docker/config.json.
- Storage:
registry_credentialstable, secrets encrypted with AES-256-GCM (fail-closed — never written in plaintext) - Scope: a credential is global (
stack_name = '', applied to every stack) or per-stack (stack_name = '<name>', overrides the global entry for the same registry on that one stack) - Multi-registry: one row per registry. A stack pulling from both
ghcr.ioand a private mirror just gets two credentials merged into itsDOCKER_CONFIGbefore each deploy. - Isolation: before every
pull/up/build, composer writes an ephemeralconfig.jsonto a fresh tempdir (mode0600) and setsDOCKER_CONFIG=<dir>for that one child process. The tempdir is removed when the operation completes — secrets never leak into the host's docker config. - API: CRUD at
/api/v1/registries(admin mutations, viewer reads). Secret values are never returned — only asecret_setboolean + last-4 preview. - Bootstrap from env:
COMPOSER_REGISTRY_AUTHS(inline JSON),COMPOSER_REGISTRY_AUTHS_FILE(path),COMPOSER_REGISTRY_AUTHS_OVERWRITE(force reseed on every boot). Idempotent by default — existing DB rows are preserved.
Seed via env on first boot:
COMPOSER_REGISTRY_AUTHS=[{"registry":"ghcr.io","username":"you","secret":"ghp_..."}]
Then manage from the UI (/settings for globals, per-stack Registries tab for overrides) or via API.
- 32 bytes of
crypto/rand, base64url-encoded - Hashed with SHA-256 before storage -- database leak does not expose usable tokens
HttpOnly,SameSite=Lax,Path=/- 7-day TTL with background cleanup every 5 minutes
- Session fixation prevention: old sessions are revoked on new login
- Role changes invalidate existing sessions
- Format:
ck_prefix + 32 random hex bytes (67 characters total) - Only the SHA-256 hash is stored in the database -- the plaintext key is shown once on creation and never again
- Compared using constant-time comparison (HMAC-based)
- Optional expiry date
- Each key has an assigned role (Admin, Operator, or Viewer)
- Hashed with bcrypt (cost 12)
- Minimum 8 characters, maximum 72 bytes (bcrypt limit)
- Constant-time comparison for all credential checks
- Failed login attempts don't reveal whether the email exists
Three roles in a strict hierarchy:
Admin > Operator > Viewer
| Permission | Admin | Operator | Viewer |
|---|---|---|---|
| View stacks, containers, logs | Yes | Yes | Yes |
| Create/update/delete stacks | Yes | Yes | No |
| Deploy/stop/restart/pull | Yes | Yes | No |
| Terminal exec | Yes | Yes | No |
| Stack console (compose commands) | Yes | Yes | No |
| Create pipelines (shell steps) | Yes | No | No |
| Run/update/delete pipelines | Yes | Yes | No |
| Manage users | Yes | No | No |
| Manage API keys | Yes | Yes (own) | No |
| System config (SSH keys, tokens) | Yes | No | No |
| View audit log | Yes | No | No |
RBAC is enforced at the handler level via middleware.CheckRole(). Pipeline creation requires admin role because pipelines can execute shell commands on the host. Pipeline updates with shell/docker steps also require admin.
CSRF protection is enforced via X-Requested-With: XMLHttpRequest header requirement on mutating requests (POST/PUT/DELETE) that use cookie-based authentication. API key and webhook requests are exempt. SameSite=Lax cookie attribute provides additional browser-level protection.
When OAuth is enabled (via COMPOSER_GITHUB_CLIENT_ID or COMPOSER_GOOGLE_CLIENT_ID):
- Auto-creation: Users who authenticate via OAuth are automatically created with the
viewerrole. If no users exist, the first OAuth user getsadmin. - Provider tracking: The
auth_providercolumn records whether a user authenticated vialocal,github, orgoogle - Password: OAuth users get a cryptographically random placeholder password (64 bytes from
crypto/rand) - Session: OAuth sessions use the same SHA-256 hashed storage as password-based sessions
All responses include:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; ...X-Content-Type-Options: nosniffX-Frame-Options: DENYX-XSS-Protection: 0(modern guidance: disabled to avoid IE vulnerabilities)Referrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=()Strict-Transport-Security(when behind TLS proxy)
- OpenAPI spec (
/openapi.json,/docs) requires authentication (viewer+ role) - Proxy headers (
X-Real-IP,X-Forwarded-For) only trusted whenCOMPOSER_TRUSTED_PROXIESis set - Rate limiting uses
RemoteAddrdirectly (not spoofable headers) unless behind a trusted proxy
Pipeline shell_command steps execute arbitrary commands on the host. Mitigations:
- Admin-only: Pipeline creation requires admin role
- Scrubbed environment: Shell commands run with a clean environment (no inherited DB URLs, API tokens, or OAuth secrets)
- PATH restricted: Only standard system directories
- HTTP requests: Use Go's
net/http(not curl), onlyhttp://andhttps://schemes allowed
If you find a security vulnerability, please report it privately via GitHub Security Advisories rather than opening a public issue.