Deploying Docker Compose stacks over SSH with ssd
How ssd deploys containers with nothing on the server but SSH and Docker: git archive, builds on the server, numbered image tags and rollback.
ssd is an open-source command line tool, written in Go, that deploys containerized services to a remote server over SSH. It supports two runtimes, Docker Compose and K3s, from the same configuration file. This website is deployed with it.
This post covers how a deploy works, the design decisions visible in the code, and where the approach stops fitting.
Why agentless
A server that runs containers already has two things: an SSH daemon and a container runtime. ssd uses those and nothing else. There is no agent on the server and no image registry. Authentication is whatever your ~/.ssh/config already says about the host.
Every step of a deploy is an ordinary shell command sent over SSH. ssd calls the system ssh binary with connection multiplexing (ControlMaster=auto, ControlPersist=60s), so the many commands in one deploy share a single connection. When something fails, the failing command is one you can run by hand.
What a deploy does
ssd deploy web runs these steps in order. The order is taken from deploy/deploy.go.
- Take a local file lock for the stack, so two deploys from the same machine cannot interleave.
- Run
pre_deployhooks locally, then therequire_cleancheck. Nothing has touched the server yet. - On the first deploy, generate
compose.yaml, write it to a temporary file, validate it withdocker compose config, and move it into place only if validation passed. - Copy any configured
filesto the stack directory. - Read the current version from
compose.yamland add one. - Start any
depends_onservices that are not already running. - Resolve build arguments and secrets. A missing reference aborts here, before anything is built.
- Ship the build context, build the image on the server, and tag it.
- Point
compose.yamlat the new tag and start the service. - Run
post_deployhooks, then prune image tags beyond the retention count.
Shipping the code with git archive
The build context is not copied from your working directory. ssd pipes git archive --format=tar HEAD into ssh server 'tar xf -', into a temporary directory on the server. For a monorepo service with context: ./apps/web, it archives only that path and strips the leading components on extraction.
This has two consequences. Only committed, tracked files are shipped, so .gitignore is respected without a separate ignore list. And uncommitted edits are never deployed. Without a guard, that second point is silent: you edit a file, forget to commit, and the server rebuilds the previous commit while reporting success. The require_clean option turns that into an error.
services:
website:
require_clean: true
pre_deploy:
- make gen
post_deploy:
- sh purge.sh
pre_deploy runs before the clean-tree check on purpose. A hook that regenerates committed files, followed by the check, catches the case where you regenerated and did not commit. post_deploy runs after the new version is live, which is the correct moment for a CDN purge or a smoke test. A failing post-deploy hook exits non-zero even though the service is already running, so a failed purge does not hide behind a green deploy.
Building on the server
The image is built where it will run, with docker build inside the extracted context. There is no push and no pull, and therefore no registry to operate. The cost is that the build uses the server's CPU and memory, next to the services it is about to replace.
Versions are integers in compose.yaml
Built images are tagged ssd-<project>-<service>:<n>, where the project is the last segment of the stack path. The current version is not stored anywhere except the image: line of compose.yaml on the server. ssd reads it with a regular expression, increments it, builds the new tag, and updates the line with a single sed over SSH.
That makes rollback simple. ssd rollback web rewrites the tag to n - 1 and recreates the service. By default ssd keeps the last two tags per service (the current one and the rollback target) and prunes older ones after each successful deploy. Cleanup failures only warn; they never fail a deploy.
Starting without downtime
The default strategy is rollout, which uses the docker-rollout CLI plugin. ssd installs the plugin on first use if it is missing. The plugin starts a new container next to the old one and removes the old one once the new one is healthy. The alternative, strategy: recreate, runs docker compose up -d --force-recreate for the service.
Configuration
A minimal configuration is a server name and one service:
server: myserver
services:
app:
# name defaults to "app", stack defaults to /stacks/app
Setting a domain generates Traefik labels for the service, with HTTPS through Let's Encrypt by default. domains plus redirect_to serves several hostnames and sends all but one to the primary with a 302. A service with no domain gets no Traefik labels and can publish ports directly instead, which suits services reached over Tailscale or a Cloudflare tunnel.
Environment overlays sit next to the base file as .ssd/ssd.dev.yaml and .ssd/ssd.prod.yaml. They are deep-merged, so an overlay only states what differs, and are selected with ssd deploy --env prod.
Secrets in builds
ssd separates build inputs into two kinds.
build_args become --build-arg KEY=VALUE. They are fine for versions and flags, but Docker records their values in the image history. build_secrets use BuildKit secret mounts instead. For each value, ssd writes a file into a private mktemp -d directory on the server with umask 077, outside the build context, and removes it through a shell trap on exit, hangup, interrupt or termination. The Dockerfile reads the value from /run/secrets/KEY in the one RUN step that needs it, and it never becomes a layer.
Both accept references to values already stored on the server, so the credential does not need to exist on the machine running the deploy:
services:
api:
build_secrets:
MAXMIND_LICENSE_KEY: ${secret:MAXMIND_LICENSE_KEY}
A reference to a missing or empty key aborts before the build starts. ssd never builds with a silently empty credential. Progress output lists key names only, and the build output passes through a writer that replaces any resolved value with ***. That writer holds output back to the last newline so a secret split across two writes is still caught, and it caps the held buffer at 32 KiB so a process that never prints a newline cannot grow it without limit.
The README states the remaining exposure plainly: the value is briefly visible in ps on the server while the build command runs.
The same file on K3s
Setting runtime: k3s keeps the file and the commands and changes what runs underneath. Images are built with nerdctl directly into the k8s.io containerd namespace, so K3s can use them without a registry. Manifests are validated with kubectl apply --dry-run=server before they replace the current ones. A rollout waits on kubectl rollout status with a 300-second timeout. Environment files become a ConfigMap, and ssd secret values live in a Kubernetes Secret.
This site uses that path: one service on four domains, three of which redirect to the primary, with two replicas and a health check against /api/health.
ssd provision prepares a server for either runtime. For Compose it installs Docker, the rollout plugin and Traefik with Let's Encrypt. For K3s it installs K3s, nerdctl and BuildKit, and configures the bundled Traefik. ssd provision check verifies each piece without changing anything.
Trade-offs and limits
- One server per configuration.
serveris a single host. There is no scheduling across machines. If a workload needs several nodes, it needs a cluster, not a deploy script. - The lock is local. The deploy lock is a file lock in the temp directory of the machine running ssd. It prevents two deploys from one laptop colliding; it does not coordinate two people deploying the same stack from two laptops.
- Builds share the server. Building on the target avoids a registry and costs CPU and memory on a machine that is serving traffic.
- The server file is the state. Versions live in
compose.yamlon the server. Editing that file by hand changes what ssd thinks is deployed. - K3s provisioning targets x86-64. The provisioning step downloads the
linux-amd64builds of nerdctl and BuildKit. - Compose replicas. Docker Compose honours
deploy.replicasoutside swarm mode only with--compatibility, which ssd does not pass.ssd scaleworks for live scaling; on K3s, replicas are applied directly.
Within those limits, the whole system is a Go binary on your machine and standard tools on the server.
Try it
Install with Homebrew, the install script or go install:
brew install byteink/tap/ssd
# or
curl -sSL https://raw.githubusercontent.com/byteink/ssd/main/install.sh | sh
# or
go install github.com/byteink/ssd@latest
Initialise a project and deploy:
ssd init -s myserver -d myapp.example.com -p 3000
ssd provision
ssd deploy app
Then inspect and, if needed, go back one version:
ssd status
ssd logs app -f
ssd rollback app
The source, test tiers and full configuration reference are on GitHub. ssd is MIT licensed. Our other public tools are listed on the open-source page, and the way we build and run products is described under product engineering.
More articles
- ByteBucket: S3-compatible object storage in one Go binaryHow ByteBucket verifies AWS Signature V4, stores objects on a plain filesystem, and keeps writes durable and memory bounded.
- Local speech-to-text behind an OpenAI-compatible APIHow voiced runs whisper.cpp on an Apple Silicon Mac behind the OpenAI transcription endpoint, loading models on demand and shedding load.
- Adding AI to an existing productHow to add an AI feature to software that already works, from choosing the workflow to rolling it out, without breaking what is there.