Why ShipDeck Replaced Docker: One Binary, No Disk Bloat
We created ShipDeck to replace Docker because Docker was eating disk space — gigabytes of it, sitting in overlay filesystems and build caches long after the build was done.
Every docker build for a Go service stacks copy-on-write layers: base image → package installs → source copy → compile. In our fleet, a service whose binary was a few megabytes routinely left behind hundreds of megabytes of overlay data, BuildKit cache, and stale images. Multiply that across a handful of services on each host and the waste added up fast. Our services compile to single binaries. Wrapping them in a container that layers an entire filesystem on top of them was burning storage we actually needed.
ShipDeck is a single Go binary. It takes a source repo and a TOML config file, then runs the full pipeline: build → package → SSH transfer → systemd install → Caddy reverse proxy → DNS record → live verification — one command, and the prior release is preserved so rollback is one command away after the first successful deploy. No daemon. No registry. No layered filesystems. Just the binary you built, running under systemd.
What DashCaddy needed from a deployer
DashCaddy runs as a constellation of services across multiple hosts. There's the product dashboard itself, a license server, monitoring agents, and auxiliary tools that talk to each other over Tailscale. Each service needs:
- A build step that produces a binary or static bundle
- A way to land that artifact on the right host
- A systemd unit so it stays running after a reboot
- A Caddy reverse-proxy block with a proper TLS certificate
- A DNS record that resolves in public and over the tailnet
- Health checks that confirm the deploy worked before it is marked done
That is six steps that have to happen in order, and every step can fail. Before ShipDeck, each of these was a hand-run script or a Docker Compose file that covered maybe three of the six.
Why Docker was the wrong tool
Docker is excellent at running isolated processes. It solves the "works on my machine" problem by shipping the entire filesystem along with the process. For a homelab running third-party apps — Vaultwarden, Immich, a media server — that isolation is genuinely useful.
But for your own software, which you already build from source, the container is dead weight. Here's why we replaced it.
Docker eats disk space. A single Go service under Docker: base image, build layers, cached intermediates — all accumulating in /var/lib/docker/overlay2. The actual binary is a few megabytes. In our fleet we saw that overhead routinely reach hundreds of megabytes per service. Multiply across a half-dozen services on each host, add BuildKit cache and stale images, and the gigabytes pile up fast. ShipDeck lands the binary directly — one file, no layers, no overlay, no cache to prune.
The Docker daemon is another moving part. Every Docker deploy requires the daemon to be running, healthy, and configured the same way on every host. It consumes memory even when idle. A systemd unit needs only systemd — which is already running on every Linux host.
Docker images can be harder to debug. When something goes wrong inside a container, the debugging path is docker exec into a filesystem that may not have the tools you need. A bare binary under systemd gives you journalctl, coredumpctl, and your own tooling — no extra layer.
Docker Compose has no built-in rollback. If you push a bad image and the container fails to start, Compose leaves you to fix it by hand. ShipDeck's blue-green release directories mean the previous working release is still sitting right there, and shipdeck rollback <name> swaps back to it — provided a prior successful deployment is recorded in the journal.
Docker has no DNS layer. ShipDeck's DNS step calls an operator-configured hook (SHIPDECK_DNS_CMD — in DashCaddy's fleet, this is wired to fleet-dns) to register the service record and gates on the resolver confirming it resolves before marking the deploy done. Containers just expose ports; connecting a name to them is always a separate step.
Docker needs separate tooling for TLS. ShipDeck writes and validates a Caddy block inline in the same deploy, with atomic swap and automatic rollback if the reload fails. With containers, you wire up Traefik or Caddy separately and hope the labels match.
What ShipDeck does instead
A ShipDeck deploy runs these steps in order, stopping on the first failure:
- Build — runs the build command you defined in the
Shipdeckfile, producing a binary - Package — bundles the binary and any assets into a release directory
- Transfer — copies the release over SSH to the target host under
/opt/<name>/releases/<epoch>/ - Install — symlinks
current→ the new release, writes (or validates) the systemd unit, and restarts the service - Caddy — writes a candidate Caddy block, validates it with
caddy validate, then atomically swaps it in and reloads - DNS — calls the operator-configured DNS hook (in our fleet,
fleet-dns add), then gates on the DNS resolver confirming the record resolves - Verify — probes the HTTP health endpoint over the tailnet (or public URL) and confirms a
200
If the build, package, or transfer step fails, the active installation is unchanged. If a later step (install, Caddy, DNS, or verify) fails, the deploy stops but the prior release directory is preserved on disk. shipdeck rollback <name> swaps current back to it and restarts the service — provided a prior successful deployment is recorded in the journal. Caddy's last-good config is automatically restored from the pre-deploy snapshot on validation or reload failure — that part is unattended. Successful deploys append a completion row to /var/lib/shipdeck/journal.jsonl recording the sanitized repository URL and exact commit — status and rollback reference the journal, so neither needs the original checkout.
Reproducible deploys with commit pinning
One feature that makes ShipDeck fundamentally different from Docker workflows: it pins Git commits.
When you deploy from a Git source, the Shipdeckfile declares an exact 40-character lowercase commit hash. ShipDeck fetches that ref, checks out that exact commit, verifies HEAD matches, and builds from that tree. No ambiguity about what code is running in production. No "the image tag says latest but nobody remembers which commit built it." This is table stakes for fleet operations; Docker Compose offers no equivalent without a separate registry and tag discipline.
Blue-green with instant rollback
Every deploy lands in a timestamped release directory:
/opt/myservice/releases/20261008T120000Z/
/opt/myservice/releases/20261007T090000Z/ ← previous
/opt/myservice/current → releases/20261008T120000Z
The current symlink is the atomic switch. If the new release fails verification, shipdeck rollback myservice re-points it to the previous directory and restarts — provided a prior successful deployment is recorded in the journal. That previous release is never touched by the deploy, so the rollback is instant — no pulling old images, no rebuilding, no guessing what the last working version was.
No daemon. No registry. No layered filesystems.
ShipDeck is a CLI tool, not a daemon. You run shipdeck deploy . from a checkout; it does the work and exits. There is no persistent shipdeck process to monitor, upgrade, or debug. The journal is a flat JSONL file. The host registry is a TOML file at /etc/shipdeck/hosts.toml.
This means ShipDeck can be called from CI, from a Makefile, from a cron job, or from another CLI tool. Docker, by contrast, needs the daemon running on every host that builds or runs containers — and if the daemon is down, the pipeline is dead.
Getting started with ShipDeck
ShipDeck ships as a single binary. The target host needs an SSH server and systemd; the invoking machine needs the SSH client. Caddy and the DNS hook (SHIPDECK_DNS_CMD) are configured per-deployment — ShipDeck works without either for local-mode deploys, but in fleet operation both are typically wired in. Build-time tools (like Go for Go projects) run on the machine that invokes ShipDeck. There is no persistent daemon or agent on either side.
# Register a fleet host
shipdeck host add edge-1 --address 100.64.1.10 --user root --key ~/.ssh/edge-1
# Scaffold from a template
shipdeck init --template=go
# Fill in the Shipdeckfile:
# [service]
# name = "myservice"
# repo = "https://git.example.com/me/myservice.git"
# ref = "main"
# commit = "0123456789abcdef0123456789abcdef01234567"
# build_cmd = "go build -buildvcs=false -o bin/myservice ./cmd/myservice"
# binary = "bin/myservice"
#
# [deploy]
# host = "edge-1"
# systemd_unit = "myservice.service"
# port = 8901
#
# [caddy]
# block = \"\"\"
# myservice.example.com {
# reverse_proxy 127.0.0.1:8901
# }
# \"\"\"
# tailnet_only = true
#
# [dns]
# zone = "example.com"
# record = "myservice.example.com"
# target = "100.64.1.10"
#
# [verify]
# http = "https://myservice.example.com/health"
# timeout = 30
# Set the DNS hook in your environment
export SHIPDECK_DNS_CMD="fleet-dns"
# Dry-run, then deploy
shipdeck plan .
shipdeck deploy .
Omitting [caddy] and [dns] selects local mode — the service runs on the target host without TLS or DNS registration.
After a successful deploy, the service is live with the configured Caddy TLS block and DNS record. shipdeck status myservice re-probes everything and reports health. shipdeck rollback myservice swaps to a prior successful deployment — provided one is recorded in the journal.
One command, not six
The thing that made Docker the wrong tool for DashCaddy's fleet wasn't any single missing feature. It was the gap between "I built this binary" and "it is running behind TLS on every host with a DNS record and health checks." Docker closed some of that gap. ShipDeck closes all of it in one command.
More importantly, it closes it in a way that can be rolled back, verified, and replayed — which is what fleet operations actually needs.