seploy is a lightweight Git-to-VPS deployment tool. It takes a Dockerized
app from a GitHub repo, builds it on your VPS, runs it with Docker bound to
localhost, and exposes it to the world through Caddy (with automatic HTTPS).
No Kubernetes, no agents, no YAML pipelines. One static binary, one server.
seploy init
seploy https://github.com/JonTelep/lets-talk-statisticsThat's the whole deployment.
- Each app gets a directory under
/var/lib/seploy/apps/<app>with its git checkout, config, env file and runtime state. seploybuilds a Docker image tagged with the commit SHA and runs it as a container published only on 127.0.0.1 at a random free port.- A tiny Caddy site config (
/etc/caddy/conf.d/seploy-<app>.caddy) reverse proxies your domain to that port. Caddy handles TLS certificates. - Redeploys build the new commit, health check it, switch Caddy over, then stop the old container. Current + previous releases are kept; older ones are pruned.
Target platform is an ordinary Linux VPS — Ubuntu, Debian and Alpine are
the primary targets. seploy init installs anything missing with the
distro's package manager (apt or apk) and works with both systemd and
OpenRC.
curl -fsSL https://raw.githubusercontent.com/Telep-IO/seploy/main/install.sh | shThis downloads the prebuilt static binary for your architecture
(x86_64/arm64) from the latest GitHub release and installs it to
/usr/local/bin/seploy — nothing else needs to be installed first.
If no prebuilt binary matches your machine (or you set
SEPLOY_FROM_SOURCE=1), the script builds from source instead, which
requires Go 1.21+ and git:
# Ubuntu/Debian
sudo apt install -y golang-go git
# Alpine
apk add go gitIf your distro's Go is older than 1.21 (e.g. Ubuntu 22.04), install Go from https://go.dev/dl/ instead.
git clone https://github.com/Telep-IO/seploy
cd seploy
./install.shOr with make:
make build
sudo make installsudo seploy initThis will:
- Install missing dependencies (
git,docker,caddy,openssh) with apt (Debian/Ubuntu) or apk (Alpine). On Debian/Ubuntu releases that do not ship acaddypackage, the official Caddy apt repository is added automatically. - Enable and start Docker and Caddy (systemd or OpenRC).
- Create
/etc/seploy,/etc/seploy/keys,/var/lib/seploy/appsand/etc/caddy/conf.d. - Ensure
/etc/caddy/Caddyfileimports/etc/caddy/conf.d/*.caddy. - Write the default global config to
/etc/seploy/config.toml.
Point your domain's DNS at the VPS first, then:
sudo seploy https://github.com/JonTelep/lets-talk-statisticsYou'll be asked three things:
Domain for lets-talk-statistics: stats.example.com
Branch [main]:
Use SSH deploy key? [Y/n]:
Before building, seploy checks free space on the Docker data root (from
docker info, falling back to /var/lib/docker); if less than 2 GB is
free, the deploy aborts before touching any container, with a hint to run
docker system prune. (If the check itself fails, the deploy proceeds.)
Then seploy clones the repo, builds the Docker image, detects the app port
from the image's EXPOSE, starts the container on a random localhost port,
writes the Caddy config and reloads Caddy. Done — Caddy fetches a TLS
certificate and your app is live at https://stats.example.com.
As a last step, seploy fetches https://<domain> (or the app's healthcheck
path, if set) from the public internet to confirm it is actually reachable
— not just that the container passed its local health check on
127.0.0.1. It retries for up to 30 seconds, since Caddy may still be
fetching a TLS certificate on a first deploy. Success prints
edge: https://stats.example.com reachable (HTTP <code>); a failure prints
a warning with a hint (e.g. "the domain does not resolve yet", or "check:
systemctl status cloudflared" for a tunneled app whose cloudflared is down)
without failing the deploy, since the release itself is already live and
healthy.
If you answer yes to the deploy key prompt (the default), seploy:
- Generates a per-app ed25519 keypair at
/etc/seploy/keys/<app>_ed25519on the VPS. - Prints the public key and the GitHub settings URL:
https://github.com/<owner>/<repo>/settings/keys - Waits while you add it as a read-only deploy key.
- Clones over SSH (
git@github.com:owner/repo.git) using that key.
This also works fine for public repos and is the recommended flow.
If your domain is on Cloudflare, you can expose apps through a Cloudflare Tunnel instead of pointing DNS at the VPS. seploy creates the DNS record for you, ports 80/443 never need to be open to the internet, and the VPS works fine behind NAT.
One-time server setup:
sudo seploy tunnel setupThis downloads cloudflared, walks you through the Cloudflare login (it
prints a URL to open in your browser), creates a tunnel named seploy,
and installs it as a service. All tunnel traffic goes to Caddy, which
routes per app — so one tunnel serves every app on the server.
After setup, the Cloudflare credentials (cert.pem and the tunnel's
credentials file) live in /etc/cloudflared — the service and all seploy
tunnel operations use that copy, so they don't depend on the home directory
of whoever ran the login.
cloudflared installs are pinned to a specific release and checksum-verified:
seploy downloads the exact version it's tested against and checks its SHA256
against the digest Cloudflare published for that release before the binary
is ever installed — an unverified download is never installed. Get the
newest pinned version with:
sudo seploy tunnel upgradeThis downloads and verifies the new version, atomically replaces the
installed binary, restarts the cloudflared service if the tunnel is
already configured, and prints the resulting cloudflared --version output.
After that, app setup gains one question:
Expose via Cloudflare Tunnel (DNS record created automatically)? [Y/n]:
Answer yes and seploy creates the Cloudflare DNS record itself. No A
records, no waiting for propagation. If the domain already has a
conflicting DNS record — an A record, or a CNAME to a different tunnel —
seploy fails loudly and tells you what the domain currently resolves to
instead of silently leaving the app unreachable; a pre-existing CNAME that
already points at this server's tunnel (<tunnel-id>.cfargotunnel.com) is
left alone and treated as success. Check things with:
seploy tunnel statusTunneled apps are served by Caddy as plain HTTP on localhost — TLS is terminated at Cloudflare's edge, and nothing is exposed publicly.
seploy tunnel setup above needs an interactive login (it prints a URL
and waits for you to open it), which doesn't work well for scripted
provisioning or servers with no one watching the terminal. For those cases,
create the tunnel in the Cloudflare dashboard instead and hand seploy its
token:
-
In the Cloudflare dashboard, go to Zero Trust > Networks > Tunnels and create a tunnel (choose "Cloudflared" as the connector).
-
Copy the token shown on the install step — it's the long string after
--tokenin the install command Cloudflare suggests, not the whole command. -
Run, on the VPS:
sudo seploy tunnel setup --token <token>
(or set
SEPLOY_TUNNEL_TOKENin the environment instead of passing--token). This downloadscloudflaredand installs it as a service running that token — no login, notunnel create, noconfig.yml.
This is a remotely-managed tunnel: unlike seploy tunnel setup, which
owns a local config.yml and lets seploy create DNS records and ingress
rules for you, a token-mode tunnel's routing lives entirely in the
dashboard. For every app you expose via the tunnel, add a Public
Hostname in Zero Trust > Networks > Tunnels > <your tunnel> > Public
Hostname:
Domain: <your app's domain>
Service: http://localhost:80
That single step both creates the DNS record and routes the domain to
Caddy — no separate DNS entry is needed. seploy prints this same reminder
when you run seploy <repo-url> for a tunneled app while in token mode,
since it cannot create this DNS record automatically (token-mode tunnels
have no local cert.pem for seploy to authenticate DNS API calls with).
seploy tunnel status and seploy doctor both report when the tunnel is
running in token mode.
On OpenRC hosts, note that the token has to be embedded directly in
/etc/init.d/cloudflared's command_args (OpenRC has no equivalent of
systemd's token-file handling); seploy writes that file with mode 0700
so it's readable only by root, but be aware the token is there in plain
text.
Push to your branch, then:
sudo seploy deploy lets-talk-statisticsThis pulls the latest commit, builds a fresh image, starts the new container, health checks it, switches Caddy traffic, and stops the old container only after the switch succeeds — including when you redeploy the same commit. If the new container fails its health check, its last log lines are printed so you can see why, and the old release keeps serving.
Container logs are size-capped (10 MB × 3 files per container) and Docker's build cache is trimmed after each deploy, so long-running servers don't slowly fill their disk.
Add --no-cache to force a fresh build without Docker's build cache:
sudo seploy deploy lets-talk-statistics --no-cacheUse --all to redeploy every configured app, one at a time, in sorted
order. It's mutually exclusive with a positional app name, continues past
per-app failures, and prints a summary at the end:
sudo seploy deploy --allsudo seploy webhook enable lets-talk-statisticsThis prints a payload URL (https://<domain>/_seploy/hook) and a secret
to paste into the repo's GitHub webhook settings (Settings → Webhooks →
Add webhook, content type application/json, just the push event). From
then on, every push to the app's deploy branch triggers a deploy on the
server — no CI configuration needed.
Behind the scenes seploy runs a small listener on localhost (installed as
a service), and Caddy routes only /_seploy/hook on the app's domain to
it. Requests are verified with an HMAC signature; pushes to other branches
are ignored; pushes that arrive mid-deploy are coalesced into one
follow-up deploy.
sudo seploy webhook disable lets-talk-statistics # turn it offBad deploy? The previous release's container and image are kept:
sudo seploy rollback lets-talk-statisticsThis restarts the previous release, health checks it, switches Caddy traffic to it, then stops the broken one. Running rollback again switches back.
Older releases are kept too (see keep_releases under Configuration), so
you can roll back further than one step by targeting a specific commit:
sudo seploy rollback lets-talk-statistics --to a1b2c3d--to accepts a full commit SHA or any unambiguous prefix. It searches the
current, previous and history releases; if the SHA doesn't match anything,
or matches more than one release, the command lists the available commits
and when they were deployed. After a --to rollback, the release you left
becomes "previous" (so a plain seploy rollback switches straight back to
it), and history is left untouched.
To see what's available before rolling back:
seploy releases lets-talk-statisticsThis lists the current, previous and history releases with their commit,
deploy time, and whether the release's image is still on disk (an image
that's missing can no longer be targeted with rollback --to).
sudo seploy env set lets-talk-statistics REDIS_URL=redis://...
sudo seploy restart lets-talk-statisticsrestart recreates the current container from the already-built image
with the fresh env file — seconds instead of a full clone + build. It is
zero-downtime: the replacement container starts on a new port, is health
checked, and Caddy is switched over to it before the old container is
stopped. If the replacement fails its health check, the old container is
left running untouched and restart reports an error.
sudo seploy doctorChecks the server (docker daemon, caddy, ports 80/443, disk space, public
IP) and every app (container running, domain DNS actually pointing at this
server, tunnel and webhook services healthy), and prints a fix hint for
anything that fails. For tunneled apps it verifies the domain's CNAME
target is this server's tunnel (<tunnel-id>.cfargotunnel.com), not just
that the domain resolves — so it catches a stray A record or a CNAME to
someone else's tunnel; if the CNAME lookup itself fails it falls back to a
plain resolve check and notes that the CNAME target wasn't verified.
New-app setup also runs a DNS preflight and warns if the domain doesn't
point at the server yet.
For every deployed app it also does the same public-edge reachability check
as deploy — fetching https://<domain> (or its healthcheck path) from
the internet — and reports it as a pass/fail line with a hint (DNS not
resolving, TLS handshake failing, connection refused, or an HTTP >= 500
from Caddy/cloudflared/the app itself).
seploy logs lets-talk-statistics # dump logs
seploy logs lets-talk-statistics -f # followsudo seploy env set lets-talk-statistics DATABASE_URL=postgres://...
sudo seploy env set lets-talk-statistics NODE_ENV=production
seploy env list lets-talk-statistics # keys only
seploy env list lets-talk-statistics --show-values # keys and valuesEnv vars are stored in /var/lib/seploy/apps/<app>/env (mode 0600) and
passed to the container via --env-file. Changes take effect on the next
seploy restart <app> (fast) or seploy deploy <app> (full rebuild).
Containers are replaced on every deploy, so anything written inside one is
lost. If your app keeps state on disk — a SQLite database, uploads — give
it a named volume in app.toml:
volumes = ["data:/app/data"]This mounts the Docker volume seploy-<app>-data at /app/data in every
release, so the data survives deploys, restarts and rollbacks.
seploy remove intentionally keeps data volumes and prints how to delete
them.
On a small VPS hosting several apps, cap each one so a leak can't take
down the box. In the app's app.toml:
memory = "512m" # docker --memory
cpus = "1.5" # docker --cpusApplied on the next deploy or restart.
seploy list
seploy status lets-talk-statisticssudo seploy remove lets-talk-statistics
sudo seploy remove lets-talk-statistics --yes # skip confirmationThis stops and removes the app's containers and images, deletes its Caddy
config, reloads Caddy, and removes /var/lib/seploy/apps/<app>. The SSH
deploy key in /etc/seploy/keys/ is intentionally kept — delete it
manually (and remove it from GitHub) if you no longer need it.
If the app was exposed through a Cloudflare Tunnel (expose = "tunnel"),
its DNS record is not removed — the cloudflared CLI has no command to
delete DNS records. seploy remove prints a reminder; delete the CNAME
record for the app's domain yourself in the Cloudflare dashboard
(DNS → Records) if it's no longer used.
- The repo must contain a
Dockerfileat its root (or at the path set indockerfileinapp.toml). Without one, deployment fails with:This repo must be containerized. Add a Dockerfile and try again. - The image should
EXPOSEexactly one TCP port so seploy can detect it. Otherwise setportmanually in the app config. - The app must listen on
0.0.0.0inside the container (containers are published only on the host's loopback, so this is safe). - Optional: set
healthcheck = "/healthz"inapp.tomlto make deploys wait (up to 30s by default, orhealth_timeoutif set) for a 2xx/3xx response before switching traffic. - Optional: set
health_timeout = "90s"(a Go duration like"90s"or"3m") inapp.tomlto change how long deploys wait for the health check (default 30s) before failing the deploy. - Optional: set
build_timeout = "10m"(a Go duration like"10m"or"1h") inapp.tomlto change how longdocker buildis allowed to run (default 30m) before the deploy fails.
/etc/seploy/
config.toml # global config
keys/ # per-app SSH deploy keys (0600)
/var/lib/seploy/
apps/
<app>/
app.toml # app config
state.json # current/previous release state
env # env vars (0600)
repo/ # git checkout
/etc/caddy/conf.d/
seploy-<app>.caddy # per-app Caddy site config
Global config (/etc/seploy/config.toml):
data_dir = "/var/lib/seploy"
config_dir = "/etc/seploy"
caddy_conf_dir = "/etc/caddy/conf.d"
caddyfile = "/etc/caddy/Caddyfile"
default_branch = "main"
keep_releases = 3 # images of the last N releases; containers only for the current and previous release
webhook_port = 8316App config (/var/lib/seploy/apps/<app>/app.toml):
name = "lets-talk-statistics"
repo_url = "https://github.com/JonTelep/lets-talk-statistics"
clone_url = "git@github.com:JonTelep/lets-talk-statistics.git"
branch = "main"
domain = "stats.example.com"
dockerfile = "Dockerfile"
port = 3000
healthcheck = ""
health_timeout = "" # optional, Go duration like "90s" or "3m"; default 30s
build_timeout = "" # optional, Go duration like "10m" or "1h"; default 30m
ssh_key = "/etc/seploy/keys/lets-talk-statistics_ed25519"
expose = "" # "" = Caddy + DNS, "tunnel" = Cloudflare Tunnel
memory = "" # optional, e.g. "512m"
cpus = "" # optional, e.g. "1.5"
volumes = [] # optional, e.g. ["data:/app/data"]"This repo must be containerized."
Add a Dockerfile to the repo root, commit, push, redeploy.
"Could not determine the app port."
The image exposes zero or multiple ports. Add EXPOSE <port> to the
Dockerfile, or set port = <port> in the app's app.toml and redeploy.
"Could not clone repo."
For private repos, make sure the printed deploy key was actually added at
https://github.com/<owner>/<repo>/settings/keys. Also check the branch
name exists.
"Caddy reload failed. The app container is running, but traffic was not switched."
Validate the config with caddy validate --config /etc/caddy/Caddyfile and
check journalctl -u caddy. The new container keeps running; rerun
seploy deploy <app> once Caddy is happy.
Site unreachable / TLS errors.
Run sudo seploy doctor — it checks DNS, services, and ports for every
app. For direct exposure, DNS must point at this VPS and ports 80/443 must
be open in your firewall; check journalctl -u caddy -f while Caddy
provisions the certificate. For tunneled apps, check
systemctl status cloudflared.
Webhook deploys are not firing.
sudo seploy doctor checks the listener. On GitHub, the webhook's recent
deliveries page shows the server's response; journalctl -u seploy-webhook -f shows it from this side. Pushes to branches other than the app's
deploy branch are ignored by design.
Container starts then dies.
seploy logs <app> (or docker logs <container>). Common causes: missing
env vars (seploy env set ...) or the app binding to 127.0.0.1 inside
the container instead of 0.0.0.0.
Alpine: caddy (no such package).
The caddy package lives in Alpine's community repository. Uncomment the
community line in /etc/apk/repositories, run apk update, and re-run
seploy init.
"another deployment of X is already in progress" A deploy is running (or a stale lock is held by a dead process — the flock is released automatically when that process exits).
- App containers are published only on
127.0.0.1, never0.0.0.0. The only public entry point is Caddy on 80/443. - Env files and SSH private keys are written with mode
0600; the keys directory is0700. - Deploy keys are per-app and should be added to GitHub as read-only.
- App names are strictly validated (
[a-z0-9][a-z0-9-]*[a-z0-9]) since they are used in paths and container names. - All external tools (git, docker, caddy, ssh-keygen, systemctl) are invoked with argument arrays — no shell string interpolation.
- Commands that touch system paths require root and tell you to use sudo.
make fmt # gofmt the tree
make test # go test ./...
make build # build ./seploy