Skip to content

Latest commit

 

History

22 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

seploy — simple deploy

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-statistics

That's the whole deployment.

How it works

  • Each app gets a directory under /var/lib/seploy/apps/<app> with its git checkout, config, env file and runtime state.
  • seploy builds 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.

Install

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.

Quick install (curl)

curl -fsSL https://raw.githubusercontent.com/Telep-IO/seploy/main/install.sh | sh

This 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 git

If your distro's Go is older than 1.21 (e.g. Ubuntu 22.04), install Go from https://go.dev/dl/ instead.

From a checkout

git clone https://github.com/Telep-IO/seploy
cd seploy
./install.sh

Or with make:

make build
sudo make install

Initialize the server

sudo seploy init

This will:

  1. Install missing dependencies (git, docker, caddy, openssh) with apt (Debian/Ubuntu) or apk (Alpine). On Debian/Ubuntu releases that do not ship a caddy package, the official Caddy apt repository is added automatically.
  2. Enable and start Docker and Caddy (systemd or OpenRC).
  3. Create /etc/seploy, /etc/seploy/keys, /var/lib/seploy/apps and /etc/caddy/conf.d.
  4. Ensure /etc/caddy/Caddyfile imports /etc/caddy/conf.d/*.caddy.
  5. Write the default global config to /etc/seploy/config.toml.

Deploy an app

Point your domain's DNS at the VPS first, then:

sudo seploy https://github.com/JonTelep/lets-talk-statistics

You'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.

Private repos (deploy keys)

If you answer yes to the deploy key prompt (the default), seploy:

  1. Generates a per-app ed25519 keypair at /etc/seploy/keys/<app>_ed25519 on the VPS.
  2. Prints the public key and the GitHub settings URL: https://github.com/<owner>/<repo>/settings/keys
  3. Waits while you add it as a read-only deploy key.
  4. Clones over SSH (git@github.com:owner/repo.git) using that key.

This also works fine for public repos and is the recommended flow.

Cloudflare Tunnel (skip DNS entirely)

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 setup

This 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 upgrade

This 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 status

Tunneled apps are served by Caddy as plain HTTP on localhost — TLS is terminated at Cloudflare's edge, and nothing is exposed publicly.

Tunnel setup without a browser (token)

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:

  1. In the Cloudflare dashboard, go to Zero Trust > Networks > Tunnels and create a tunnel (choose "Cloudflared" as the connector).

  2. Copy the token shown on the install step — it's the long string after --token in the install command Cloudflare suggests, not the whole command.

  3. Run, on the VPS:

    sudo seploy tunnel setup --token <token>

    (or set SEPLOY_TUNNEL_TOKEN in the environment instead of passing --token). This downloads cloudflared and installs it as a service running that token — no login, no tunnel create, no config.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.

Redeploy

Push to your branch, then:

sudo seploy deploy lets-talk-statistics

This 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-cache

Use --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 --all

Deploy automatically on push (webhooks)

sudo seploy webhook enable lets-talk-statistics

This 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 off

Rollback

Bad deploy? The previous release's container and image are kept:

sudo seploy rollback lets-talk-statistics

This 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-statistics

This 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).

Restart (apply env changes without rebuilding)

sudo seploy env set lets-talk-statistics REDIS_URL=redis://...
sudo seploy restart lets-talk-statistics

restart 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.

Doctor

sudo seploy doctor

Checks 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).

Logs

seploy logs lets-talk-statistics        # dump logs
seploy logs lets-talk-statistics -f     # follow

Environment variables

sudo 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 values

Env 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).

Persistent data (volumes)

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.

Resource limits

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 --cpus

Applied on the next deploy or restart.

Status and listing

seploy list
seploy status lets-talk-statistics

Remove an app

sudo seploy remove lets-talk-statistics
sudo seploy remove lets-talk-statistics --yes   # skip confirmation

This 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.

App requirements

  • The repo must contain a Dockerfile at its root (or at the path set in dockerfile in app.toml). Without one, deployment fails with: This repo must be containerized. Add a Dockerfile and try again.
  • The image should EXPOSE exactly one TCP port so seploy can detect it. Otherwise set port manually in the app config.
  • The app must listen on 0.0.0.0 inside the container (containers are published only on the host's loopback, so this is safe).
  • Optional: set healthcheck = "/healthz" in app.toml to make deploys wait (up to 30s by default, or health_timeout if set) for a 2xx/3xx response before switching traffic.
  • Optional: set health_timeout = "90s" (a Go duration like "90s" or "3m") in app.toml to 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") in app.toml to change how long docker build is allowed to run (default 30m) before the deploy fails.

File layout

/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 = 8316

App 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"]

Troubleshooting

"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).

Security notes

  • App containers are published only on 127.0.0.1, never 0.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 is 0700.
  • 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.

Development

make fmt     # gofmt the tree
make test    # go test ./...
make build   # build ./seploy

About

Simple deploy utility used for vps

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages