Agentless remote deployment tool for Docker Compose and K3s.
ssd is a lightweight CLI tool that simplifies deploying containerized applications to remote servers via SSH. Supports both Docker Compose and K3s (Kubernetes) runtimes. No agents, no complex setup—just SSH access.
- Simple: Convention-over-configuration approach
- Flexible: Works with monorepos and simple projects
- Agentless: Only requires SSH access
- Dual runtime: Docker Compose or K3s — same ssd.yaml, same commands
- Smart: Auto-increments build numbers
- Fast: Builds on the server, no image registry needed
- Reliable: Zero-downtime deployments with automatic version tracking
- Secret-safe build inputs:
build_args/build_secretsvalues can reference a secret or env var already on the server (${secret:KEY}); the resolved value never reaches the terminal, logs, orssd config, andbuild_secretskeeps it out of the image history too - Honest about what ships: deploys send
git archive HEAD, sorequire_cleanrefuses to deploy a dirty tree instead of silently shipping the last commit - Polished output: Docker-style live progress in your terminal — spinner + per-step timer, frozen ✓/✗ summary on completion. Falls back to plain text in CI and pipes automatically.
Quick install (Linux/macOS)
curl -sSL https://github.com/ghraw/byteink/ssd/main/install.sh | shHomebrew (macOS/Linux)
brew install byteink/tap/ssdGo
go install github.com/byteink/ssd@latestLinux packages
Download from Releases:
- Debian/Ubuntu:
ssd_*_linux_amd64.deb - RHEL/Fedora:
ssd_*_linux_amd64.rpm
Windows
Download ssd_Windows_x86_64.zip from Releases, extract, and add to PATH.
- Initialize your project:
# Interactive mode
ssd init
# Or with flags
ssd init -s myserver -d myapp.example.com -p 3000- Deploy:
ssd deploy appThat's it! ssd will:
- Sync your code to the server via rsync
- Build the container image on the server
- Auto-increment the version number
- Update compose.yaml (or K8s manifests for k3s) and restart the stack
# Initialize with K3s runtime
ssd init -s myserver -r k3s -d myapp.example.com -p 3000
# Provision the server (installs K3s, nerdctl, buildkit, configures Traefik)
ssd provision
# Deploy
ssd deploy appssd looks up its config in this order:
--config <path>(explicit override).ssd/ssd.yaml(preferred — keeps the repo root clean)ssd.yaml(legacy — kept for back-compat with existing projects)
Fresh ssd init writes to .ssd/ssd.yaml and adds .ssd/.gitignore
so generated artifacts under .ssd/.cache/ stay out of version
control. Existing projects with ./ssd.yaml are left alone.
If you're still on the legacy layout, ssd migrate moves your
./ssd.yaml into .ssd/ssd.yaml and seeds the .gitignore. Until
you migrate, every command prints a one-line warning to stderr.
For multiple environments, drop sibling files next to the base config:
.ssd/
├── ssd.yaml # base / shared
├── ssd.dev.yaml # dev overlay
└── ssd.prod.yaml # prod overlay
Apply with --env:
ssd deploy --env prod
ssd deploy -e devOverlays are deep-merged onto the base — only the keys you set in the overlay are overridden, everything else inherits.
# ssd.yaml
server: myserver
services:
app:
# name defaults to service key ("app")
# stack defaults to /stacks/app# ssd.yaml
server: myserver
stack: /custom/stacks/myapp # Shared by all services
services:
web:
name: myapp-web
context: ./apps/web
dockerfile: ./Dockerfile # relative to context# ssd.yaml
server: myserver
stack: /stacks/myproject # All services share this stack
services:
web:
context: ./apps/web
dockerfile: ./Dockerfile # relative to context, not to the repo root
api:
context: ./apps/api
dockerfile: ./Dockerfiledockerfile is resolved inside the build context: ssd ships the contents of
context to the server and builds from there, so context: ./apps/web plus
dockerfile: ./apps/web/Dockerfile looks for apps/web/Dockerfile within
apps/web and fails.
Deploy specific service:
ssd deploy web# ssd.yaml
server: myserver
services:
web:
name: myapp-web
stack: /stacks/myapp
context: ./apps/web
dockerfile: ./Dockerfile # relative to context
target: production # Docker build target stage (optional)
build_args: # --build-arg KEY=VALUE (optional)
BUILD_CHANNEL: stable
build_secrets: # BuildKit --secret mounts (optional)
MAXMIND_LICENSE_KEY: ${secret:MAXMIND_LICENSE_KEY}
domain: example.com # Enable Traefik routing
path: /api # Path prefix routing (optional)
https: true # Default true, set false to disable
port: 3000 # Container port, default 80
ports: # Host:container port mappings (optional)
- "3000:3000"
- "8080:80"
depends_on: # Simple list or map with conditions
- db
- redis
files:
./config.yaml: /app/config.yaml # Local file -> container path
volumes:
postgres-data: /var/lib/postgresql/data
redis-data: /data
healthcheck:
cmd: "curl -f http://localhost:3000/health || exit 1"
interval: 30s
timeout: 10s
retries: 3server: myserver
services:
website:
require_clean: true # abort if the context has uncommitted tracked changes
pre_deploy: # run locally, in order, in the build context
- sh advisories.sh
- make genssd ships the build context with git archive HEAD, so uncommitted tracked
changes are never deployed. Without require_clean that is silent: edit a
file, deploy without committing, and the server rebuilds the previous version
while the deploy reports success.
pre_deploy: shell commands run locally, sequentially, with the working directory set to the resolvedcontext. The first non-zero exit aborts the deploy and prints the command and its output. Nothing has touched the server at that point.require_clean:trueaborts when the context has uncommitted tracked changes — staged or unstaged, submodule pointer moves included. Untracked files are not an error (git archivenever shipped them). The check is scoped to the context path, so a dirty file elsewhere in the repo is irrelevant. Default isfalse, which prints a warning instead of aborting.
pre_deploy runs before require_clean. That ordering is the point: hooks
regenerate committed artifacts, and the check then catches "you regenerated and
did not commit". Neither field applies to pre-built (image:) services, which
sync no build context.
Both fields can be set at the root level and are inherited by every service; a
service-level value always wins (including require_clean: false and
pre_deploy: []).
server: myserver
services:
api:
dockerfile: ./Dockerfile
build_args:
MAXMIND_ACCOUNT_ID: ${secret:MAXMIND_ACCOUNT_ID}
MAXMIND_LICENSE_KEY: ${secret:MAXMIND_LICENSE_KEY}
BUILD_CHANNEL: stablePassed to the builder as --build-arg KEY=VALUE — docker build on compose,
nerdctl --namespace k8s.io build on k3s. A value is either a literal or a
reference to a value already stored on the server for that service:
| Reference | Resolved from |
|---|---|
${secret:KEY} |
the service secret store — the {service}-secret Secret on k3s; on compose, which has no secret store, the service env file {service}.env |
${env:KEY} |
the service env file, {service}.env |
Set the referenced values first:
ssd secret api set MAXMIND_LICENSE_KEY=... # k3s
ssd env api set MAXMIND_LICENSE_KEY=... # composeA reference to a key that is missing or empty aborts the deploy before anything is built, naming the key and where ssd looked. ssd never builds with a silently empty credential.
Secrecy rules — a resolved value is never printed:
- progress output lists key names only (
Build args: MAXMIND_ACCOUNT_ID, ...) - error messages name keys, never values
ssd configprintsKEY: ${secret:KEY}unresolved- the build output stream is masked, so a build step that echoes the value (or
a failing
curlprinting a URL with the key in it) shows***
Notes:
- Build args are per service and merge through env overlays
(
.ssd/ssd.<env>.yaml), so dev and prod can carry different values. - Not valid on pre-built (
image:) services — nothing is built. - A literal value may not contain
${; use a reference or a plain literal. --build-argvalues are recorded in the image history (docker historyon the server). For credentials, preferbuild_secretsbelow.
services:
api:
build_secrets:
MAXMIND_LICENSE_KEY: ${secret:MAXMIND_LICENSE_KEY}Same values and same ${secret:} / ${env:} references as build_args — only
the transport differs. ssd writes each value to a private temp file on the
server (outside the build context, mode 600, removed however the build exits)
and passes BuildKit --secret id=KEY,src=…, so the credential is mounted into
the one RUN that needs it and never becomes a layer or a history entry.
The Dockerfile has to read it from the mount instead of an ARG:
# build_args
ARG MAXMIND_LICENSE_KEY
RUN curl "…license_key=$MAXMIND_LICENSE_KEY" -o GeoLite2.tar.gz
# build_secrets
RUN --mount=type=secret,id=MAXMIND_LICENSE_KEY \
curl "…license_key=$(cat /run/secrets/MAXMIND_LICENSE_KEY)" -o GeoLite2.tar.gzbuild_args |
build_secrets |
|
|---|---|---|
| Flag | --build-arg K=V |
--secret id=K,src=… |
| In image history | yes | no |
| Dockerfile | ARG K |
RUN --mount=type=secret,id=K |
| Use for | versions, channels, flags | credentials |
A key may not appear in both maps. Residual exposure: the value is briefly
visible in ps on the server while the build command runs — that is the same
for both, and it is not what image history is.
server: myserver
services:
api:
files:
./config.yaml: /app/config.yaml # relative to project
/opt/shared/ca.pem: /etc/ssl/ca.pem # absolute path outside projectCopy local files to the stack directory and bind-mount into the container. Files are transferred via SSH on every deploy, independent of git tracking (works with .gitignored files). Relative paths resolve from the working directory where ssd is run. Absolute paths work for files outside the project. Basenames must be unique per service.
# ssd.yaml
server: myserver
services:
web:
depends_on:
db:
condition: service_healthy
redis:
condition: service_startedConditions: service_started (default), service_healthy (requires healthcheck), service_completed_successfully.
# ssd.yaml
server: myserver
services:
app:
ports:
- "3000:3000" # Expose on host for Tailscale/CF tunnelWhen no domain or domains is set, the service is deployed without Traefik labels or the traefik_web network. Use ports to map host:container ports for access via Tailscale, Cloudflare tunnels, or direct host access.
# ssd.yaml
server: myserver
services:
nginx:
image: nginx:latest # Use pre-built image, skip build step
domain: example.com# ssd.yaml
server: myserver
services:
web:
domains:
- example.com
- www.example.com
- api.example.com
port: 3000All domains work independently, no redirects. Useful for multi-brand apps, different locales, or A/B testing.
# ssd.yaml
server: myserver
services:
web:
domains:
- example.com
- www.example.com
- old-domain.com
redirect_to: example.com # All other domains redirect to this
port: 3000When redirect_to is set, all other domains automatically redirect to it with a 302 temporary redirect. Common use cases:
- www redirect: Redirect www to non-www (or vice versa)
- Domain migration: Redirect old domains to new primary domain
- Multi-TLD consolidation: Redirect .net, .org to primary .com
# ssd.yaml
server: myserver
stack: /stacks/myapp
services:
api:
context: ./apps/api
dockerfile: ./Dockerfile # relative to context
domain: api.example.com
port: 8080
depends_on:
- db
healthcheck:
cmd: "curl -f http://localhost:8080/health || exit 1"
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:16-alpine
volumes:
postgres-data: /var/lib/postgresql/data
healthcheck:
cmd: "pg_isready -U postgres"
interval: 10s
timeout: 5s
retries: 5Service-level fields:
name: Service name (defaults to service key)stack: Path to stack directory on server (defaults to/stacks/{name})context: Build context path (defaults to.)dockerfile: Dockerfile path, relative tocontext(defaults to./Dockerfile). ssd syncs the contents ofcontextto the server and builds there, so a path that repeats the context prefix will not be foundimage: Pre-built image to use (skips build step if specified)target: Docker build target stage for multi-stage builds (e.g.,production)build_args: Map of build-time args passed as--build-arg KEY=VALUE. Values are literals or references to values stored on the server:${secret:KEY}(k3s Secret, or the env file on compose) and${env:KEY}(env file). A missing or empty reference aborts the deploy; resolved values are never printed or logged. Recorded in image history — for credentials usebuild_secretsbuild_secrets: Same shape asbuild_args, delivered as BuildKit--secretmounts (RUN --mount=type=secret,id=KEY) instead. The value never enters an image layer or the image history. A key cannot appear in both mapsdomain: Single domain for Traefik routingdomains: Multiple domains for Traefik routing. Cannot use bothdomainanddomainsredirect_to: When set, all domains except this one redirect to it (302 temporary). Must be one of the domains indomainsarraypath: Path prefix for routing (e.g.,/api). Requiresdomainordomains. GeneratesPathPrefixrule withStripPrefixmiddlewarehttps: Enable HTTPS (default:true)port: Container port (default:80)ports: Host:container port mappings (e.g.,["3000:3000"]). Maps directly to Docker Composeports:depends_on: Service dependencies (list or map with conditions)volumes: Map of volume names to mount pathsfiles: Map of local file paths to container mount paths. Copied to stack directory and bind-mounted on every deploy. Works with.gitignored filesrequire_clean: Abort the deploy when the build context has uncommitted tracked changes (default:false, which warns). Inherits from rootpre_deploy: Shell commands run locally in the build context before the sync, in order. A non-zero exit aborts the deploy. Runs before therequire_cleancheck. Inherits from roothealthcheck: Health check configuration (exactly one ofcmd/exec)cmd: Shell command, rendered as["CMD","sh","-c",cmd]exec: Array form, rendered as["CMD",arg0,arg1,...]. Use for scratch images with no shell.interval: Check interval (e.g.,30s)timeout: Command timeout (e.g.,10s)retries: Number of retries before unhealthy
Root-level fields:
server: SSH server name (from~/.ssh/config)stack: Default stack path for all servicesrequire_clean: Default for all servicespre_deploy: Default for all services
ssd init # Interactive mode
ssd init -s myserver # Non-interactive with flagsFlags:
-s, --server- SSH host name (required in non-interactive mode)--stack- Stack path (e.g.,/dockge/stacks/myapp)--service- Service name (default:app)-d, --domain- Domain for Traefik routing--path- Path prefix for routing (e.g.,/api)-p, --port- Container port-f, --force- Overwrite existingssd.yaml
ssd deploy|up|update [svc...] # Deploy service(s) (all if omitted; comma/space subset; pulls in missing deps)
ssd down [service] # Stop services (or all if omitted)
ssd rm [service] # Permanently remove services (or entire stack)
ssd restart <service> # Restart without rebuilding
ssd rollback <service> # Rollback to previous version
ssd status|ps [service] # What's running (whole stack, or one service)
ssd logs <service> [-f] # View logs, -f to follow
ssd scale <service> <count> # Live-scale a service (does not edit ssd.yaml)ssd status (alias ssd ps) prints one row per running instance, identical
across runtimes:
arcline on hl-master
SERVICE STATUS UPTIME VERSION PORTS
backend running 25m 7 8092→8090
bytebucket running 25m 0.10.0 9000→9000
kb running (healthy) 7m 8 8102→8100
web exited - 8 -
The PORTS column is omitted when nothing publishes a port — the norm on k3s, where traffic arrives through the Ingress.
Set a persistent replica count in ssd.yaml:
services:
web:
deploy:
replicas: 3 # default 1- k3s: written to Deployment
spec.replicasand applied on deploy. - compose: written to
services.<svc>.deploy.replicas. Docker Compose v2 honors this in non-swarm mode only when deploying withdocker compose --compatibility. ssd does NOT add this flag; document it in your own deploy wrapper if you need >1 replica persisted across restarts. For ephemeral scaling, usessd scale.
Live-scale without editing ssd.yaml (matches kubectl scale):
ssd scale web 3
ssd scale worker 0 # scale down to zeroDeploy behavior:
deploy,up, andupdateare aliases- With no argument, deploys all services; images build alphabetically, then
services start in dependency order (
depends_on) so a dependency is Ready before a dependent whose readiness probe needs it - With one or more service names (
ssd update web,api), deploys those plus any of theirdepends_ondependencies not already running — so updating one service never fails because its DB was never started. Deps already running are left untouched; auto-included ones are printed first - Example:
ssd deploy apialso startsdbifapidepends on it anddbisn't up yet
ssd config # Show all services config
ssd config <service> # Show specific service configssd env <service> set KEY=VALUE # Set environment variable
ssd env <service> list # List all environment variables
ssd env <service> rm KEY # Remove environment variableNote: Environment variables are stored in {service}.env files in the stack directory on the server. For k3s, they are synced into a {service}-env ConfigMap on every deploy.
services:
web:
env_file: ./.env # local path, relative to project rootWhen env_file is set, the local file is uploaded to
{stack}/{service}.env (mode 600) on every deploy. This overwrites
any values set via ssd env set. To manage env vars via CLI only, remove
env_file from ssd.yaml first.
ssd provision # Provision server from ssd.yaml
ssd provision --server myserver --email admin@x.com # Explicit server and email
ssd provision check # Verify server readiness
ssd provision check --server myserver # Check a specific serverProvisions the target server with:
- Docker and Docker Compose installation
- docker-rollout plugin for zero-downtime deploys
- Traefik reverse proxy with automatic HTTPS (Let's Encrypt),
--pingendpoint, and Docker healthcheck traefik_webDocker network for service discovery
All steps are idempotent and safe to run multiple times.
provision check verifies that Docker, Docker Compose, docker-rollout, the traefik_web network, and Traefik are all present and running.
ssd reclaims disk space on the server in two ways: automatically on deploy (tag retention, per-service) and manually via ssd prune (orphans, images, build cache, dangling).
Per-deploy tag retention:
cleanup:
retention: 2 # keep last N image tags (root default, applied to all services)
services:
web:
cleanup:
retention: 5 # per-service override- Default retention is 2 (current + rollback target).
- Minimum is 1;
0disables auto cleanup on deploy. - Per-deploy cleanup is warn-only — it never fails a deploy.
Manual prune:
ssd prune # Remove orphaned services (default, preserved)
ssd prune --images # Remove old image tags beyond per-service retention
ssd prune --build-cache # Prune build cache entries older than 168h
ssd prune --dangling # Remove unreferenced images
ssd prune --all # All of the above
ssd prune --keep N # Override retention for --images/--all
ssd prune --dry-run # Preview, combinable with any flagBuild cache pruning is opt-in only — never runs automatically on deploy. Threshold is 168h (7 days).
ssd completion install # auto-detect $SHELL (bash, zsh, fish)
ssd completion install --shell zsh # pick the shell explicitly
ssd completion bash > /path/to/ssd # print script to stdoutInstalled paths:
- bash:
~/.local/share/bash-completion/completions/ssd - zsh:
~/.zsh/completions/_ssd(add the directory tofpathin.zshrcbeforecompinit) - fish:
~/.config/fish/completions/ssd.fish(auto-loaded by fish)
Completes top-level commands, sub-commands (env/secret set|list|rm,
provision check, completion install), common flags, and dynamically
lists services from your ssd.yaml.
ssd version # Show version
ssd help # Show help- Reads
ssd.yamlfrom current directory - SSHs into the configured server (uses
~/.ssh/config) - Rsyncs code to a temp directory (excludes .git, node_modules, .next)
- Builds Docker image on the server (or skips if using pre-built
image) - Parses current version from compose.yaml, increments it
- Recreates the service with
docker compose up -d --force-recreate - Cleans up temp directory
- SSH access to target server (configured in
~/.ssh/config) - Docker and Docker Compose on the server
- A
compose.yamlalready set up in the stack directory - rsync installed locally
# Clone and setup
git clone https://github.com/byteink/ssd.git
cd ssd
make setup # Configures git hooks for linting
# Build and test
make build # Build binary
make test # Run unit tests
make lint # Run linter
# Test tiers (development is strict red/green TDD across all tiers)
make test-integration # Real SSH/Docker in throwaway containers (needs Docker)
make test-e2e # Full deploy in an isolated docker-in-docker sandbox
make test-e2e-full # Full-fidelity rollout deploy — run before a release
make test-all # Unit + integration + full e2e (the release gate)Tests never touch your host Docker: integration and e2e tiers run everything inside throwaway containers (e2e uses a docker-in-docker sandbox), torn down automatically. The whole suite must be green before any release.
ssd ships with a Claude Code skill that lets Claude deploy and manage your services via the /ssd slash command.
ssd skillThis symlinks the bundled skill directory into ~/.claude/skills/ssd. The skill auto-updates whenever you run brew upgrade ssd.
/ssd deploy web
/ssd status api
/ssd logs web -f
/ssd rollback api
Or ask Claude naturally and it will use ssd when appropriate.
MIT
Built by ByteInk