ssmm — AWS SSM Parameter Store helper for team-scoped .env sync
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.
Why another SSM wrapper?
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.
Install
Requires Rust 1.77+ (or whatever supports edition = "2024").
# or
Your 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.
Configure your team's prefix
ssmm requires an explicit prefix — set it once and forget it:
# option 1: env var (recommended for systemd services)
# option 2: per-invocation flag
If 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>.
Quick tour
# Put a whole .env file (CWD basename becomes <app>, snake_case → dash-case)
# ↳ /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
# List (CWD auto-detects app name via basename)
# Sync SSM → .env (systemd ExecStartPre friendly, mode 0600, idempotent)
# 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)
# Or skip the .env file entirely — SSM → process env directly (chamber-style).
# Parent env is inherited; SSM values overlay. Values never touch disk.
# stderr: ssmm: exec ./run.sh with 10 variables (app=10, shared=0, tag=0)
# Show one
# Manage tags on an existing parameter
# Dashboard of every app namespace
# Find duplicates (same key across apps, or identical values)
# Migrate parameters — three safe steps (SSM has no soft-delete; go slow)
# → /tmp/ssmm-migrate-backup-<ts>.json
Migrating an existing sync unit to exec mode
If 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
# actually write the drop-in and reload systemd
--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.
Onboarding a greenfield app in one command
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
# actually put + write drop-in + daemon-reload
- 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.
systemd integration
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.sh
Notes:
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.
Shared namespace and tag overlays
Values shared across multiple apps have two expressions in ssmm:
# Put a cross-app value directly under /<prefix>/shared/*
# Or tag an existing per-app parameter as shared
# sync automatically overlays /<prefix>/shared/* (disable with --no-shared)
# and any tag-matched parameter via --include-tag
Precedence when the same key name appears in multiple layers: app > include-tag > shared. Conflicts are logged to stderr.
Auto-detection
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.
Path values and portability
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
=
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.
Concurrency and throttling
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:
Advanced tier and custom KMS key
Two 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).
# 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.
Notes:
--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.
Security model
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).
What ssmm sync actually does
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)
What ssmm exec actually does
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.
What either mode protects against
- 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)
What neither mode protects against
- 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.
What exec additionally protects against (vs sync)
- 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.
If your threat model is stricter than either mode
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)
Similar tools
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.
Choose ssmm when
- 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.
Choose something else when
- 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.
Claude Code skill (bundled)
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)
# Option 2: copy (frozen at clone time)
Then /ssmm in a Claude Code session will expand into the workflow.
License
MIT. See LICENSE.