📖 English · 日本語
A small Rust CLI that treats AWS SSM Parameter Store as the source of truth
for a team's .env files, with a flat-key convention and tag-based overlays.
ssmm is intentionally narrow-scoped: it assumes you run Linux services
(typically via systemd ExecStartPre) that consume a generated .env file
with EnvironmentFile=. If that matches your setup, the tool is opinionated
enough to remove a lot of shell-script boilerplate.
Two delivery modes, same prefix convention and overlay rules:
ssmm syncmaterializes a.envfile (mode 0600) that systemd loads viaEnvironmentFile=. Drop-in replacement for plaintext.envwith zero app-side changes.ssmm execinjects SSM values directly into a child process's environment viaexecvp— no file on disk. Use when your threat model disallows plaintext secrets on the filesystem.
See Security model for the tradeoff.
Other opinions baked in:
- Parameters live under
/<team>/<app>/<key>— the first segment is your team namespace (for IAM policy scoping), the second is the app, and the key is flat (kintone-api-token, notkintone/api/token). SecureStringvsStringis auto-detected from the key name (conservative: unknown keys default toSecureString). Only structural-looking suffixes (_path/_dir/_channel/_name/_host/_port/_region/_endpoint) map toString._urlis NOT in the safe list since URLs commonly embed credentials (e.g.postgres://user:pass@host/db, Slack webhook URLs) — URL-bearing keys staySecureStringby default. Override per key with--plain KEYif you really need plaintext.- Each parameter is automatically tagged
app=<app>so you can filter cross-namespace by tag later.
Requires Rust 1.77+ (or whatever supports edition = "2024").
cargo install ssmm # from crates.io
# or
cargo install --git https://github.com/kok1eee/ssmmYour IAM role needs: ssm:PutParameter, ssm:GetParametersByPath,
ssm:GetParameters, ssm:DescribeParameters, ssm:DeleteParameter(s),
ssm:AddTagsToResource, ssm:RemoveTagsFromResource,
ssm:ListTagsForResource. SSM SecureString uses the AWS-managed key
(alias/aws/ssm) by default.
ssmm requires an explicit prefix — set it once and forget it:
# option 1: env var (recommended for systemd services)
export SSMM_PREFIX_ROOT=/myteam
# option 2: per-invocation flag
ssmm --prefix /myteam list --allIf neither is set, ssmm exits with:
Error: no prefix configured. Pass --prefix /<your-team> or set $SSMM_PREFIX_ROOT=/<your-team>.
All subcommands operate under this root prefix. Parameters end up at
/<prefix>/<app>/<key>.
# Put a whole .env file (CWD basename becomes <app>, snake_case → dash-case)
cd your-app/
ssmm put --env .env
# ↳ /myteam/your-app/kintone-api-token (SecureString [auto: default], len=...)
# ↳ /myteam/your-app/slack-channel (String [auto: suffix], len=...)
# Override the type per key when the heuristic gets it wrong
ssmm put --env .env --secure DATABASE_URL --secure SENTRY_DSN
ssmm put --env .env --plain-key METRICS_URL --plain-key PUBLIC_HOST
ssmm put --env .env --plain-all # everything String (public-config apps)
# List (CWD auto-detects app name via basename)
ssmm list
ssmm list --keys-only
ssmm list --all # across every app under /myteam
ssmm list --tag env=prod
# Sync SSM → .env (systemd ExecStartPre friendly, mode 0600, idempotent)
ssmm sync --out ./.env
# ssmm: wrote 10 variables to ./.env (app=10, shared=0, tag=0)
# Strict mode: exit non-zero if a shared / tag key is overridden by an app key
# (good for systemd ExecStartPre — you want the service to FAIL, not silently diverge)
ssmm sync --out ./.env --strict
# Or skip the .env file entirely — SSM → process env directly (chamber-style).
# Parent env is inherited; SSM values overlay. Values never touch disk.
ssmm exec -- ./run.sh --flag value # use `--` so child flags aren't eaten
ssmm exec --app myapp --include-tag shared=true -- python -m myapp
# stderr: ssmm: exec ./run.sh with 10 variables (app=10, shared=0, tag=0)
# Variant overlay — `--app` is repeatable. Later --app wins on key
# collisions, so a "common" base + a variant overlay works without
# duplication. Same --app syntax on `sync` and `list`.
ssmm exec --app knowledge-bot-common --app knowledge-bot-soumu -- /app/bin
# stderr: ssmm: exec /app/bin with 20 variables (apps=knowledge-bot-common:17,knowledge-bot-soumu:3, shared=0, tag=0)
# Precedence (low → high): shared < include-tag < apps[0] < apps[1] < ... < apps[N]
# --strict makes any cross-layer collision (incl. app-vs-app) exit non-zero.
# Show one
ssmm show kintone-api-token
# Manage tags on an existing parameter
ssmm tag add kintone-api-token shared=true owner=backend
ssmm tag list kintone-api-token
ssmm tag remove kintone-api-token owner
# Dashboard of every app namespace
ssmm dirs
# Find duplicates (same key across apps, or identical values)
ssmm check --duplicates --values
# Migrate parameters — three safe steps (SSM has no soft-delete; go slow)
ssmm migrate /old-prefix/app /myteam/app # step 1: copy only
ssmm migrate /old-prefix/app /myteam/app --delete-old # step 2: dry-run + backup dump
# → /tmp/ssmm-migrate-backup-<ts>.json
ssmm migrate /old-prefix/app /myteam/app --delete-old --confirm # step 3: actually delete sourcesIf you already have units running in sync mode (ExecStartPre=ssmm sync ... +
EnvironmentFile=...env) and want to switch to exec mode, generate the
drop-in with ssmm migrate-to-exec instead of hand-editing:
# dry-run (default): prints the proposed drop-in
ssmm migrate-to-exec \
--unit myapp.service \
--app myapp \
--exec-cmd "/usr/bin/uv run python app.py --mode prod" \
--keep-env-file /etc/defaults/common \
--pre-exec "/usr/bin/playwright install chromium"
# actually write the drop-in and reload systemd
ssmm migrate-to-exec ... --apply-
--exec-cmdis the command to run after SSM injection. Paste the existingExecStart=value fromsystemctl cat <unit>verbatim; ssmm deliberately does not auto-parse systemd's output sinceshow/catformat differs across versions and drop-in resets. -
--keep-env-file PATHpreserves non-SSMEnvironmentFile=entries (e.g. a machine-wide PATH setup). Everything else is cleared so the old.envstops being read. -
--pre-exec CMDrepopulatesExecStartPre=after clearing it; useful when the originalExecStartPremixedssmm syncwith other prep steps (playwright install, cache warm-up) — list only the steps you still need. -
--applywrites<drop-in-dir>/exec-mode.confand runssystemctl [--user|--system] daemon-reload. Without--applyit's a pure stdout dry-run. -
Revert is one command:
rm <drop-in> && systemctl daemon-reload. -
Tested against sdtab-managed units; the generated drop-in coexists with sdtab's own
<unit>.d/v2-syslog-identifier.confstyle drop-ins. If you later runsdtab upgrade, verifyexec-mode.confsurvives — report back if it doesn't. -
--cwd-app(v0.7.0+): emitWorkingDirectory=<cwd>and drop--app <app>from the generatedExecStart=. The runningssmmauto-detects the app from the CWD basename, so the drop-in gets shorter and sdtab tables stop having to repeat the app name in thecmd:field. Runssmm migrate-to-exec --cwd-appfrom inside the app's repo directory (the path you'dcdinto) — that path becomes the drop-in'sWorkingDirectory=.cd ~/amu-tazawa-scripts/hikken_schedule # CWD basename = hikken_schedule → app hikken-schedule ssmm migrate-to-exec \ --unit sdtab-hikken-bashtv.service \ --app hikken-schedule \ --exec-cmd "/home/ec2-user/.local/share/mise/shims/uv run python main_spreadsheet.py --mode bashtv" \ --pre-exec "/home/ec2-user/.local/bin/uv run playwright install chromium" \ --cwd-app --apply
Resulting drop-in
ExecStart=becomes:WorkingDirectory=/home/ec2-user/amu-tazawa-scripts/hikken_schedule ExecStart=/home/ec2-user/.cargo/bin/ssmm exec -- /home/ec2-user/.local/share/mise/shims/uv run python main_spreadsheet.py --mode bashtv
ssmm onboard combines put --env <file> and migrate-to-exec for apps
that are not yet in SSM. It reads the .env, puts each key, generates
the systemd drop-in, and runs daemon-reload — all from one invocation.
# dry-run (default): prints put plan + drop-in preview, reads no files
ssmm onboard \
--unit myapp.service \
--app myapp \
--env ./myapp.env \
--exec-cmd "/usr/bin/uv run python app.py --mode prod" \
--keep-env-file /etc/defaults/common \
--pre-exec "/usr/bin/playwright install chromium"
# actually put + write drop-in + daemon-reload
ssmm onboard ... --apply- Default is fail-if-any-key-exists. Running
onboardtwice won't silently overwrite a secret you rotated between runs. Pass--overwriteto opt into replace-existing semantics. Dry-run with--overwritestill lists the colliding keys under a# WILL OVERWRITEheader so destructive intent is visible. - Empty values in the
.envare filtered out (matchingput's behaviour), so trailingFOO=lines don't trigger spurious "would overwrite" noise. - Values never appear in dry-run output (names and
len=Nonly); there is a snapshot test pinning this property. - If apply fails partway — SSM put succeeds but
daemon-reloadfails — the error tells you tossmm delete <app> -rto revert the SSM half. The systemd drop-in if written can be removed byrm <path>(shown in the error). - Use
migrate-to-execinstead when the app is already in SSM and you only need to switch modes.onboard's default-fail guard will block you from double-putting. --cwd-app(v0.7.0+) works the same as onmigrate-to-exec: the generated drop-in getsWorkingDirectory=<cwd>and theExecStart=omits--app, so thecmd:column in a sdtab table no longer needs to repeat the app slug. Runssmm onboard --cwd-appfrom inside the app's repo directory.
Two shapes, pick based on threat model. Both work with user-scoped systemd units (as shown below) and system units alike.
# (a) sync-mode — drops a mode-0600 .env next to the app, then starts it.
# Existing apps that read EnvironmentFile= work unchanged.
# ~/.config/systemd/user/myapp.service
[Service]
Environment=SSMM_PREFIX_ROOT=/myteam
ExecStartPre=/home/you/.cargo/bin/ssmm sync --app myapp --out /opt/myapp/.env
EnvironmentFile=/opt/myapp/.env
ExecStart=/opt/myapp/run.sh# (b) exec-mode — no .env on disk; ssmm exec replaces itself with the app,
# passing SSM values via environ. Use when plaintext-on-disk is unacceptable.
[Service]
Environment=SSMM_PREFIX_ROOT=/myteam
ExecStart=/home/you/.cargo/bin/ssmm exec --app myapp -- /opt/myapp/run.shNotes:
ssmm syncis idempotent: if the generated content matches the existing file byte-for-byte, it's a no-op (ssmm: no change).ssmm execusesexecvpso systemd sees the child process directly —Type=simplesemantics, signal delivery, MainPID, and journal output all work as if systemd had started the app itself. No supervisor wrapper.- Always put
--before the child command in exec mode, so flags for the child (--port,-H, etc.) are not consumed by ssmm.
Values shared across multiple apps have two expressions in ssmm:
# Put a cross-app value directly under /<prefix>/shared/*
ssmm put --app shared --env /path/shared.env
# Or tag an existing per-app parameter as shared
ssmm tag add kintone-api-token shared=true
# sync automatically overlays /<prefix>/shared/* (disable with --no-shared)
# and any tag-matched parameter via --include-tag
ssmm sync --include-tag shared=truePrecedence when the same key name appears in multiple layers: app > include-tag > shared. Conflicts are logged to stderr.
When --app is omitted, ssmm picks the name from the current directory:
/home/you/services/my_api/→my-api(snake_case → dash-case)/home/you/services/billing-svc/→billing-svc
Override with --app <name> any time.
SSM stores parameter values as opaque bytes; ssmm does not expand
shell-style shortcuts like ~ on the way in or out. When a value is a
filesystem path, prefer the $HOME-relative form and let the consuming
app expand it at runtime:
# SSM parameter (portable)
GOOGLE_SERVICE_ACCOUNT_KEY_PATH=~/.credentials/service-account.json
# Python — app side
import os
path = os.path.expanduser(os.getenv("GOOGLE_SERVICE_ACCOUNT_KEY_PATH") or "")Why this matters: a hard-coded absolute path (e.g. /home/ec2-user/...)
in SSM works on that one host but silently breaks local dev on a
different $HOME. With ~ + expanduser, one SSM value serves every
environment. The same applies to path-like env vars in general — store
them portable, expand on read.
SSM's PutParameter has a low per-account TPS (~3/s for standard
parameters). ssmm defaults to --write-concurrency=3 with AWS SDK
adaptive retry (max_attempts=10), so bulk imports complete without
manual backoff. Reads default to --read-concurrency=10. Both are
adjustable per invocation:
ssmm --write-concurrency 1 put --env .env # tighter throttle-avoidance
ssmm --read-concurrency 20 list --all # faster on high-limit accountsTwo opt-in knobs for cases where the defaults don't fit:
# Advanced tier: raises per-parameter limit from 4KB to 8KB.
# Required for certificates, PEM keys, or large JSON blobs.
# Costs $0.05/month per Advanced parameter (Standard is free).
ssmm --advanced put --env .env
# Custom KMS key: use a team-scoped CMK instead of the default
# AWS-managed key (`alias/aws/ssm`). Useful when you want to restrict
# decrypt permission to a subset of IAM principals via key policy.
ssmm --kms-key-id alias/myteam-ssm put --env .envNotes:
--kms-key-idonly affects newly-created SecureString parameters. Existing parameters keep their original key (AWS does not allow re-keying in place — delete and recreate if you need to rotate).- When migrating values that exceed 4KB, pass
--advancedtossmm migrateas well, or the copy step will fail withValidationException. - Tier downgrade (Advanced → Standard) is not supported by SSM; once Advanced, the parameter stays Advanced until deleted.
ssmm is not a hardened secret manager. It's a .env-compatible
convenience layer over SSM. Decide based on your threat model, and pick
the right delivery mode (sync or exec).
ssmm sync calls GetParametersByPath with --with-decryption, writes
the resulting KEY=VALUE lines to the output path, and chmod 0600s
the file. Decrypted SecureString values live:
- In memory on the host running
ssmm sync - In the on-disk file (mode 0600, owner-only readable)
- In the target process's environment after
systemdreadsEnvironmentFile=(→ readable via/proc/<pid>/environto the same UID / root)
ssmm exec performs the same GetParametersByPath + decryption, but
then execvps the child command with SSM values added to the inherited
environment. Decrypted SecureString values live:
- In memory on the host during the SSM fetch
- In the child process's environment (readable via
/proc/<pid>/environto the same UID / root)
Specifically, step 2 of sync — the on-disk file — does not exist
under exec. That is the primary difference between the two modes.
- Accidental commit of plaintext
.envto git (SSM is the source of truth) - Unauthorized teammates who have SSM read permission but not host login
- Drift between hosts (central management vs hand-copied
.envfiles)
- Same-UID process snooping:
/proc/<pid>/environis readable by same-UID processes (and root). Both modes expose values here once the app is running. - Host compromise: attacker with filesystem read sees the process
environment; under
syncthey also see the plaintext.env. - Systemd journal / CloudWatch log exfiltration:
ssmmis designed so that parameter values never appear in error messages or log output (only parameter names / counts / lengths / SHA-256 hashes). If you find a path that does leak values into stderr or journalctl, please open an issue — it's considered a bug. When integrating with CloudWatch / fluent-bit / Datadog logs, verify your own wrappers also preserve this discipline.
- Backup exfiltration: no plaintext file, so
/opt/myapp/backups don't leak secrets (unless the backup also captures process memory //proc, which is uncommon). - Unauthorized file read by a different UID on the same host: the
sync'd
.envis 0600 but still a file.exec-mode values only exist in the process's environ, protected by kernel process isolation.
Consider tools that avoid even same-UID environ exposure:
- HashiCorp Vault + agent — short-lived leases, audit logs
- SOPS + age/KMS — encrypted-at-rest files, decrypt in-app only
- Runtime secret brokers (AWS Secrets Manager SDK called from within the app, rotated values, scoped to short-lived in-memory handling)
I haven't benchmarked against the following in detail, so treat this as orientation, not authoritative comparison. Issues / PRs welcome if my positioning is wrong:
- chamber — SSM-backed
exec-time env injection.
ssmm execis the equivalent mode inssmm(same underlying mechanism: decrypt +execvp+ env overlay).ssmmadds a 3-segment prefix convention/<team>/<app>/<key>, a shared namespace, and tag overlays that chamber does not model. - aws-vault — exec-time AWS credential injection. Different problem (IAM credentials vs. app secrets) but the same "no plaintext on disk" philosophy.
- dotenv-vault — hosted
.env.vaultformat; not AWS-native. - HashiCorp Vault — full secret lifecycle management (leasing, rotation, audit). Different tier of tool.
- Your team keeps multiple services that share a secret namespace
and you want a prefix convention (
/<team>/<app>/<key>) with IAM policy scoping at the team boundary. - You want both
.envfile generation (for legacyEnvironmentFile=consumers) and chamber-style exec-time injection from one tool, with one IAM policy and one mental model. - You want CWD-auto-detection of the app name, a shared namespace for cross-app values, and tag-based overlays out of the box.
- You need secret rotation, leasing, or audit logs → HashiCorp Vault.
- You're not on AWS → SOPS + age, dotenv-vault, or Doppler.
- Your app needs to fetch secrets at runtime (not at process start) → call the AWS Secrets Manager / Parameter Store SDK directly from the app.
This repo ships a Claude Code
skill at .claude/skills/ssmm/SKILL.md covering typical
put → sync → systemd workflows, migration patterns, and the
SecureString heuristic override knobs. If you use Claude Code, drop
it in by either symlink or copy:
# Option 1: symlink (updates follow git pull)
ln -s $(pwd)/.claude/skills/ssmm ~/.claude/skills/ssmm
# Option 2: copy (frozen at clone time)
cp -r .claude/skills/ssmm ~/.claude/skills/ssmmThen /ssmm in a Claude Code session will expand into the workflow.
MIT. See LICENSE.