Skip to content

About

Zero-downtime Docker deploys to any server via SSH. Single binary, no management server.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

teploy

Docker deploys to any Linux server you can SSH into, with blue/green zero-downtime under Caddy.
Single binary. No management server.

Release CI License


Why teploy?

Most deploy tools require either a management server (Coolify, Dokploy) or longer configuration (Kamal). Teploy is a single binary that deploys Docker containers to any server you can SSH into. Three lines of config, one command to deploy.

# teploy.yml
app: myapp
domain: myapp.com
server: 1.2.3.4
teploy deploy

With the default Caddy ingress your app is live with automatic HTTPS, blue/green zero-downtime deploys, and rollback to the previous version if the readiness gate fails. (ingress: host publishes a raw port instead: recreate-style deploys with seconds of downtime — see docs/supported-workloads.md.)

Install

# macOS (Homebrew)
brew install useteploy/tap/teploy

# Windows (Scoop)
scoop bucket add teploy https://github.com/useteploy/scoop-bucket
scoop install teploy

# Download binary (macOS/Linux) — verifies the release SHA-256 before install
(
  set -e
  os=$(uname -s | tr '[:upper:]' '[:lower:]')
  arch=$(uname -m); case "$arch" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
  file="teploy_${os}_${arch}.tar.gz"
  tmp=$(mktemp -d); cd "$tmp"
  curl -fsSLO "https://github.com/useteploy/teploy-cli/releases/latest/download/$file"
  curl -fsSLO "https://github.com/useteploy/teploy-cli/releases/latest/download/checksums.txt"
  grep " $file\$" checksums.txt > checksum.txt
  if command -v sha256sum >/dev/null 2>&1; then sha256sum -c checksum.txt || exit 1; else shasum -a 256 -c checksum.txt || exit 1; fi
  tar xzf "$file"
  sudo mv teploy /usr/local/bin/teploy
  cd - >/dev/null; rm -rf "$tmp"
)

# From source (needs Go 1.26 or newer)
go install github.com/useteploy/teploy/cmd/teploy@latest

Quickstart

# 1. Provision your server (installs Docker + Caddy)
teploy setup <your-server-ip>

# 2. Deploy
teploy deploy

No config file needed to start: running teploy deploy in a fresh project offers a short interactive setup (app name, domain or raw port, server) and writes the resulting 3-line teploy.yml before deploying — so your config is always a real file you can read, diff, and check in, never hidden state. Prefer to answer the questions up front? teploy init runs the same flow standalone.

How it works

  1. Builds your app (Dockerfile auto-detection or Nixpacks)
  2. Starts a new container alongside the old one
  3. Health checks the new container
  4. Routes traffic via Caddy (automatic HTTPS)
  5. Stops the old container — no downtime during the switch under Caddy
  6. Rolls back to the previous version if the readiness gate fails

Features

Feature Description
Zero-downtime deploys New container starts and passes health checks before old one stops (Caddy blue/green; ingress: host deploys by recreate — brief downtime, documented)
Automatic HTTPS Caddy provisions and renews TLS certificates
Rollback teploy rollback reverts to the previous version and health-gates it before answering
Multi-process Run web, worker, and scheduler from the same image
Accessories Manage Postgres, Redis, etc. alongside your app
Environment variables teploy env set KEY=value — stored securely on server
Encrypted secrets teploy secret set KEY value — encrypted at rest
Preview environments teploy preview <branch> — deploy branches to temporary URLs
Multi-server Deploy to multiple servers with parallel rolling deploys
Load balancing Automatic Caddy LB config across app servers
Maintenance mode teploy maintenance on — instant 503 page
Deploy hooks Run migrations before deploy, seed after deploy
Asset bridging Shared volume prevents 404s on hashed assets during deploys
Destinations teploy deploy -d staging — per-environment config overlays
Notifications Webhook notifications on deploy/rollback/failure
Backups teploy backup — volumes to S3; teploy server backup-target — the per-server target
Templates One-command deploys for common self-hosted apps
Deploy locking teploy lock — freeze deploys during incidents; teploy lock status --json reports who holds the lock
Vulnerability gate scan: true — Trivy scan blocks deploys on fixable CRITICALs
Host audit setup wires auditd + sudo session recording by default (ausearch -k teploy-exec, sudoreplay); skip with --no-harden
JIT mesh access teploy network grant --ttl 2h — time-boxed keys that auto-revoke

Config

Minimum config is 3 lines. Everything else is optional:

app: myapp
domain: myapp.com
server: 1.2.3.4
port: 3000
build_local: true
platform: linux/amd64
stop_timeout: 30
drain_seconds: 10             # request drain: keep the old container serving in-flight
                              # requests this long AFTER the traffic switch, before it is
                              # stopped (0 = stop immediately, the default). The full graceful
                              # budget is drain_seconds + stop_timeout. Teploy's Caddy routing
                              # cannot count in-flight requests per upstream, so the window IS
                              # the drain policy — size it to your longest normal request.
memory: 2g                    # cgroup RAM cap (docker units: 512m, 2g). Unset = unlimited.
cpu: "1.5"                    # CPU cap in cores. Unset = unlimited.
keep_versions: 3              # auto-prune older versions after deploy (0 = keep all, default)
                              # `teploy pin <version>` protects a version from this prune

# Build a Dockerfile that lives in a subdirectory (monorepos). Omit both
# to use ./Dockerfile with the project root as context (the default).
# `dockerfile` is resolved relative to `context`; `context` is relative to
# this teploy.yml. Example: a Dockerfile under server/ that COPYs shared
# packages from the repo root —
#   context: .
#   dockerfile: server/monolith/Dockerfile
# Or build just one workspace of a monorepo —
#   context: apps/api
# These apply only when Teploy builds the image (no `image:` set).

# Custom TLS cert — e.g. Cloudflare Origin Certificate behind a CF-proxied
# domain where ACME can't reach the origin. Cert + key are LOCAL file paths,
# uploaded to the server on deploy. Default is ACME (automatic HTTPS).
tls:
  cert: ./certs/origin.crt
  key: ./certs/origin.key

# Routing layer. Default "caddy" — Teploy writes the Caddyfile. "external"
# means you front the app with Cloudflare Tunnel / Tailscale Funnel / nginx
# / ALB / etc., and Teploy must not touch Caddy. The container still joins
# the teploy network with its app-name alias so the external thing can
# reach it. "host" publishes the app directly on a fixed host port (no
# Caddy, no proxy) — reach it at IP:port. Handy for a private/tailnet box.
ingress: caddy                # "caddy" (default), "external", or "host"

# Only with ingress: host. The host IP to publish `port` on (default
# 0.0.0.0 — all interfaces). A fixed host port can't be zero-downtime
# blue/green, so host ingress deploys by recreate (stop old, start new):
# a few seconds of downtime per deploy for a stable, directly-reachable
# port. Single replica only; container rollback isn't available (the prior
# container is removed) — redeploy the previous version instead.
# bind: 0.0.0.0

# Edge hardening for Caddy-fronted apps (reverse-proxied / load-balanced).
# The lightweight, Caddy-native slice — not rate limiting (needs a custom
# Caddy build) or a full WAF (front with Cloudflare for that). Blocked
# requests get a 403. Only with ingress: caddy; rejected on static/external.
firewall:
  allow_ips: ["203.0.113.0/24", "198.51.100.7"]  # if set, ONLY these may connect
  deny_ips: ["9.9.9.9"]                            # blocklist (IPs or CIDRs)
  block_user_agents: ["masscan", "badbot"]         # case-insensitive substring
  max_body_size: 10MB                              # request body cap
# IP rules match the DIRECT connection (Caddy's remote_ip). That's the real
# client when Teploy's Caddy faces the internet (the default). If you front it
# with Cloudflare/another proxy, remote_ip is the PROXY's IP — use that proxy's
# own IP rules instead, or set trusted_proxies via caddy_extra.

# Put the app behind a login — the self-hostable "deployment protection".
# Caddy ingress + reverse-proxy only. Use basic_auth (bcrypt hashes; generate
# with `caddy hash-password`) and/or forward_auth to your own identity proxy
# (Authelia, oauth2-proxy, OIDC gateway).
access:
  basic_auth:
    alice: "$2a$14$...bcrypt-hash..."
  # forward_auth:
  #   url: authelia:9091
  #   uri: /api/authz/forward-auth
  #   copy_headers: [Remote-User, Remote-Email, Remote-Groups]

# Record deploys/rollbacks in a teploy-observe audit trail (compliance).
# Fire-and-forget; a failed emit never fails a deploy. token is an observe
# editor+ credential. Omit the block to disable.
audit:
  endpoint: https://observe.example.com
  token: ${OBSERVE_AUDIT_TOKEN}
  site: default

volumes:
  data: /app/data

processes:
  web: "npm start"
  worker: "npm run worker"

# Readiness gate — what "healthy" means before traffic switches, and how
# long to wait. mode selects the probe:
#   http — status-based only: GET path, 200 = ready. A 404/redirect FAILS
#          (no fallback). Best when the app has a real health endpoint.
#   tcp  — a TCP dial against the published port; nothing is fetched.
#          The connection is held ~1s: a listener that closes it at once
#          (Docker's proxy does, when nothing in the container listens)
#          is NOT ready. For apps with no HTTP surface (game servers, TCP
#          brokers).
#          Setting `path` alongside is rejected — nothing would fetch it.
#   auto — compatibility default (also what an omitted mode means): HTTP
#          GET first; a 404/3xx falls back to a TCP dial. The historical
#          behavior, kept so existing configs deploy identically.
# timeout_seconds is the TOTAL deadline for the gate (not per-try); the
# deploy output states the mode and deadline before the gate runs, e.g.
# "Readiness: HTTP GET /healthz (30s deadline)".
health:
  mode: http                  # http | tcp | auto (default auto/compat)
  path: /healthz              # default /health (http/auto only)
  timeout_seconds: 30         # total gate deadline (default 30)
  interval_seconds: 1         # time between attempts (default 1)

# Per-process HEALTHCHECK overrides. disable: true passes --no-healthcheck
# so the container ignores the image's HEALTHCHECK — useful when a worker
# shares an image with web but has no HTTP listener for the inherited probe.
healthcheck:
  worker:
    disable: true

hooks:
  pre_deploy: "npm run migrate"
  post_deploy: "npm run seed"

# Vulnerability gate: trivy-scan the image on the server before containers
# start. HIGH+CRITICAL findings are reported; fixable CRITICALs block the
# deploy (unfixable base-image CVEs don't wedge you). DB caches server-side.
scan: true

accessories:
  postgres:
    image: postgres:16
    port: 5432
    # Cap what this container can take from the host. Worth setting on any
    # database or engine that holds a working set in memory: an engine's own
    # memory-budget setting is its accounting of its own allocations, so a
    # leak in a path it doesn't count grows past it. Only the cgroup limit is
    # enforced by the kernel — and without one, an accessory that runs away
    # gets the OOM killer picking victims host-wide, taking unrelated
    # containers with it.
    memory: 2g
    cpu: "2"
    env:
      POSTGRES_PASSWORD: auto                  # generated once, persisted server-side
      # or reference an encrypted secret (set with `teploy secret set DB_PASSWORD=...`);
      # the app container receives the same secret, so both sides agree:
      # POSTGRES_PASSWORD: secret:DB_PASSWORD

  # Self-hosted S3 (object storage / backup target). `command:` overrides the
  # image command — needed by images that want an explicit verb.
  minio:
    image: minio/minio:latest
    port: 9000
    command: server /data --console-address :9001
    env:
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: secret:MINIO_ROOT_PASSWORD
    volumes:
      data: /data

  # Self-hosted push notifications (point `notifications:` webhooks at it).
  ntfy:
    image: binwiederhier/ntfy:latest
    port: 80
    command: serve
    volumes:
      cache: /var/cache/ntfy

assets:
  path: /app/public/assets
  keep_days: 14

notifications:
  webhook: https://hooks.slack.com/services/xxx
  # Sign webhook deliveries so the receiver can tell a real one from anyone who
  # learned the URL. Sent as:
  #   X-Teploy-Timestamp: <unix seconds>
  #   X-Teploy-Signature: sha256=hex(HMAC-SHA256(secret, timestamp + "." + body))
  # Identical to teploy-observe's scheme, so one verifier handles both. Signing
  # the timestamp with the body lets a receiver bound replay. Applies to
  # type: webhook only — Slack and ntfy never check these headers.
  #
  # Prefer `export TEPLOY_WEBHOOK_SECRET=...` over setting it here: teploy.yml
  # is committed, and a signing secret in version control is not a secret. The
  # config field below wins if both are set; neither means unsigned, as before.
  # secret: ""
  # Or multiple channels with per-event filters. type: ntfy posts
  # push-notification-shaped messages to an ntfy topic — pair it with the
  # self-hosted ntfy accessory above (http://<app>-ntfy/<topic> from other
  # containers, or your public ntfy.sh topic) for deploy alerts on your phone.
  # A channel without its own `secret` inherits the one above.
  # channels:
  #   - type: ntfy
  #     url: https://ntfy.sh/my-deploys
  #     events: [deploy, rollback, health_failure]

# GitOps secrets: local env files merged into the container env at deploy.
# Encrypted files decrypt on YOUR machine (never plaintext in the repo or
# on the server): *.age via age (identity from TEPLOY_AGE_IDENTITY,
# SOPS_AGE_KEY_FILE, or ~/.config/teploy/age.txt), *.sops.* / *.enc.* via
# `sops -d` (any backend in your .sops.yaml — age, KMS, PGP). Later files
# and explicit env: keys win.
env_files:
  - secrets.env.age

TOML is also supported (teploy.toml).

Commands

Core

teploy init                               # generate config
teploy setup <server> [--yes] [--no-harden]  # provision server (Docker + Caddy + firewall)
teploy deploy [server]                    # deploy app (reads teploy.yml)
teploy deploy --app X --image Y --domain Z # ad-hoc deploy without teploy.yml
teploy deploy -d staging                  # deploy with destination overlay
teploy rollback                           # revert to previous version
teploy stop / start / restart             # container lifecycle
teploy logs [--tail N] [--process web]    # stream container logs
teploy status                             # show running containers
teploy stats                              # CPU/RAM per container
teploy health                             # run configured readiness check on live app
teploy log                                # deploy history
teploy exec <server> <cmd>                # run a command on the server (SSH)
teploy app exec -- <cmd>                  # run a command in the app container (migrations, etc.)
teploy validate                           # check config and server readiness
teploy doctor [--server <name>]           # read-only diagnostics: toolchain, SSH, Docker, registry, Caddy, disk, compatibility, repair debt (--json for machines; exit 1 if any check fails, never 2)
teploy plan                               # preview what a deploy would change (containers, routing, env, storage, resources; read-only)
teploy plan --out plan.json               # write a bound plan record (config digest + target identity + generation)
teploy apply plan.json                    # execute a reviewed plan; refuses naming what drifted since it was made
teploy scale <count>                      # multi-server deploy + LB update
teploy version / update                   # version info and self-update

Environment variables

Server resolution precedence is flags → env vars → servers.yml.

Variable Description
TEPLOY_HOST server host used when no server argument or --host is given
TEPLOY_USER SSH user; defaults to root
TEPLOY_SSH_KEY path to the SSH private key

App lifecycle

teploy lock [--message "..."]   # freeze deploys (incident/maintenance)
teploy lock status [--json]     # report the current lock, read-only (deploy running? frozen? stale?)
teploy unlock                   # release deploy lock
teploy maintenance on / off     # toggle 503 maintenance page
teploy pin [version]            # protect a version from keep_versions pruning (default: current)
teploy unpin <version>          # remove a pin
teploy pins                     # list pinned versions

History

Every client (this CLI on any machine, teploy-dash, CI, the autodeploy listener) records what it did in one history on the app's server, so a deploy from a laptop shows up next to one from the dashboard:

teploy events                                   # the app in ./teploy.yml
teploy events --host prod                       # every app on prod
teploy events --host prod --app blog --since-seq 41 --json
teploy deploy --actor ci:github-actions         # name the client in the history

Env and secret events record key names only.

Settings home (folder or Teploy)

An app's settings live in one place. By default that is teploy.yml in your folder. Move them to the server and any client (another laptop, teploy-dash, CI) can edit them, with history and no silent overwrites:

teploy config home teploy                       # in the app's folder: store teploy.yml on its server
teploy deploy --app blog --host prod            # deploy from the server config (no folder needed)
teploy deploy --app blog --host prod --image ghcr.io/me/blog:1.4   # commit image: then deploy (CI one-liner)
teploy config edit --app blog --host prod       # $EDITOR; concurrent edits merge key by key
teploy config set --app blog --host prod image=nginx:1.27 env.LOG_LEVEL=debug
teploy config get | diff [--applied] | history | restore <commit>
teploy config pull / push                       # edit a linked local copy, push as compare-and-swap
teploy config home folder                       # back to the folder (server copy archived)
teploy config home teploy --from-server --app blog --host prod   # no folder: rebuilt from server records, digest-verified
teploy init --from-server blog --host prod      # bootstrap a folder on a new machine
teploy template install wiki --home teploy --server prod --domain wiki.example.com

Generated template passwords go to teploy secret, never into the config. --wait 2m on any mutating command waits for another client's lock.

A Teploy-home config is never expanded from anyone's environment: $VAR in env: is refused (write $$ for a literal $), and secret values go in teploy secret set, not the file. server:/servers:, env_files: and local TLS/static-build paths are refused too: the app deploys to the server that stores its config. Folder deploys of a Teploy-home app are refused, and so are deploys by teploy CLIs older than this feature.

Code from a git repository

A Teploy-home app can build from a git repository instead of a pre-built image — GitHub, GitLab, Forgejo/Gitea, Cursor Origin, or any git host over SSH or HTTPS:

app: blog
domain: blog.example.com
git:
  repository: git@github.com:me/blog.git   # or https://github.com/me/blog.git
  branch: main
  paths: ["web/**"]                        # optional monorepo filter
  poll: 5m                                 # or "off": webhook wakes only
# context: ./web                           # optional, relative to the repo root
# dockerfile: Dockerfile                   # optional, relative to the context
build_secrets: [NPM_TOKEN]                 # age-store keys -> docker build --secret

Connect it once per app:

teploy config source key --app blog --host prod            # ed25519 deploy key:
                                                           # prints the public half to register
teploy config source key --app blog --host prod --type token < token-file
teploy config source host-key --app blog --host prod       # pin the forge's SSH host key
teploy config source show --app blog --host prod --json    # public state, no secrets
teploy config source remove --app blog --host prod

The credential never leaves the server: keys and tokens are age-encrypted outside /deployments (a compromised Caddy cannot read them), SSH fetches use StrictHostKeyChecking=yes against the pinned host key, and HTTPS tokens travel via GIT_ASKPASS — never in a URL, .git/config or a command line.

teploy trigger poll is the single admitter: it checks the branch with the app's credential, dedups by (app, ref, commit), requires new heads to descend from the deployed watermark (a force-push is refused, once, until teploy trigger reset --app blog --ref refs/heads/main), filters changed paths by git.paths (defaulting to context:), retries a failed commit at most twice, and always deploys the newest admitted head. teploy deploy --app blog --host prod builds the head from the server-side mirror under the same rules. A systemd timer runs the poll for every app:

teploy trigger install-timer --host prod    # teploy-trigger-poll.timer, every minute
teploy trigger poll --app blog --host prod --now   # manual / webhook wake
teploy trigger retry --app blog --host prod        # re-arm a failed commit

Static apps cannot use git: yet (static deploys still need a code source), and submodules are refused pending implementation.

Secrets and env

teploy env set KEY=value           # set env var
teploy env set KEY --stdin         # value verbatim from stdin (never argv)
teploy env get KEY                 # read env var
teploy env list [--reveal]         # list env vars (masked by default)
teploy env unset KEY               # remove env var
teploy secret set KEY <value>      # encrypted secret
teploy secret set KEY --stdin      # value verbatim from stdin (single key)
teploy secret rm KEY               # delete an encrypted secret (local store)
teploy secret get / list / rotate  # secret management

Every env and secret subcommand also works without a folder — pass --app X --host S (the secrets provider then comes from the app's Teploy config when it has one):

teploy secret set API_KEY --stdin --app blog --host prod
teploy secret list --json --app blog --host prod

At deploy, secrets (and secret: references in env:) override teploy.yml env:, which overrides the server .env that env set writes. env set therefore refuses a key that is a secret (use secret set) and warns when teploy.yml env: shadows the key. When the app's settings are edited in Teploy, env set/env unset also refuse keys the config declares under env:/env_literal: (env-config-guard-v1) — change those with teploy config set --app X --host S env.KEY=... (or dash Settings); a value written to .env would be shadowed at the next deploy.

Releases, rollback, redeploy

teploy releases [--app X --host S] [--json]   # the app's releases, newest first
teploy rollback [--to <version>] [--json]     # health-checked rollback to the previous (or a specific) release
teploy redeploy [--app X --host S] [--json]   # redeploy the CURRENT release with fresh env and secrets

releases lists retained release directories for type:static apps and release records for container apps — release id, when it deployed, the image (and digest), config commit and actor, each marked current or rollback available with the reason when not (containers for that version pruned by keep_versions retention). --json emits the machine envelope for --app invocations.

redeploy runs a fresh deploy of the current release's recorded image at the same version, re-resolving env from the server .env, teploy secret and the recorded config through the normal fenced deploy engine — it is how a secret or env edit reaches containers (restart only stops and starts them). For a Teploy-home app it is the same as deploying the server config. Folderless, the config is rebuilt from the server's records and only used when it provably reproduces the running release; anything unrecorded is refused with the remedy. Static apps are refused.

The server's age identity (the key that decrypts teploy secret values) lives at /etc/teploy/age-key (directory 0700, key 0600) — outside /deployments, which the Caddy container mounts read-only. Existing installs keep working from the old location; teploy setup moves the key after verifying the copy decrypts, leaving a public-key marker at the old path so an older teploy on another machine keeps encrypting to the same identity. teploy doctor reports the state (age-identity check). A non-root deploy user that cannot write /etc/teploy keeps using the old path unchanged.

Release metadata (/deployments/<app>/meta/<release>.json) never stores a secret value. Plain teploy.yml env: values and image defaults are kept; every other key (secrets, vault references, .env, env_files, ${VAR} interpolations, credential-shaped names and URLs, anything of unknown origin) is recorded by name and source only. Rollback is unaffected: it recreates the target release's containers from docker's own record of them. That also means a rollback uses the values the release was deployed with, not secrets rotated since. Records written by older versions can be cleaned in place:

teploy meta scrub --host <server> [--app <name>]          # dry run: lists records and key names, changes nothing
teploy meta scrub --host <server> [--app <name>] --apply  # rewrite records, remove leftover attempt env files

Fleet

teploy server add <name> <host>    # add server to ~/.teploy/servers.yml
teploy server list / remove        # fleet management
teploy network setup <provider>    # VPN mesh (tailscale, headscale, netbird)
teploy network status / join       # VPN management

Accessories

teploy accessory list              # list running accessories
teploy accessory start <name>      # start accessory (Postgres, Redis, etc.)
teploy accessory stop / logs / upgrade / backup / restore <name>

Shared KV (config / feature flags)

teploy kv set flags/beta on        # set a key (--ttl 300 for expiring keys)
teploy kv get flags/beta           # print a value (exit 1 if unset)
teploy kv list 'flags/*'           # list keys by glob pattern
teploy kv incr deploys/count       # atomic counter, prints new value
teploy kv del / exists <key>

Operates on a Nucleus accessory (--accessory, default nucleus) — a shared config/flag/counter store your apps can also read directly over their DATABASE_URL (Consul-KV-style, but riding the database you already run). One global keyspace: key prefixes are convention, not isolation — share a store only between apps that trust each other, and give untrusted apps their own instance.

Previews

teploy build                       # build the image, deploy nothing
teploy preview deploy <branch>     # route subdomain to a pre-built image
teploy preview list                # list active previews
teploy preview destroy <branch>    # tear down a preview
teploy preview prune               # remove expired previews

preview deploy runs an image that already exists; it does not build one. teploy build is how you get that image without touching production — it syncs the working directory, builds on the server, prints the tag, and stops. Pass the tag through so the two do not have to re-derive it independently:

git checkout fix/login-500
teploy build --json                                    # {"image":"app-build-abc1234",...}
teploy preview deploy fix/login-500 --image app-build-abc1234 --ttl 24h

Requires Teploy-managed Caddy: previews provision a preview-<branch>.<domain> route on demand, which an external ingress cannot do.

Tailnet-only previews skip the domain and the certificate: the hostname sits under the target's Tailscale IP via sslip.io, Caddy serves it on plain HTTP, and only tailnet addresses get through.

teploy preview deploy fix/login-500 --image app-build-abc1234 \
  --base-domain 100-64-1-2.sslip.io --http-only --allow-ip 100.64.0.0/10
# http://preview-fix-login-500-<id>.100-64-1-2.sslip.io

The mode is recorded with the preview, so redeploying the branch keeps it. preview list --json rows carry a url with the scheme actually served.

Backups

One backup target per server, stored on the server and read at run time — no bucket or credentials in crontabs, scripts or laptops:

# Store the target (credentials on stdin, two lines; age-encrypted on the
# server at /etc/teploy/backup-target.json, 0600):
printf 'ACCESS\nSECRET\n' | teploy server backup-target set --host prod \
    --bucket b [--region r] [--endpoint https://s3.example.com] [--prefix p]
teploy server backup-target show --host prod [--json]   # never secrets
teploy server backup-target unset --host prod

teploy backup create   --app myapp --host prod [--json]  # volumes (+ server data)
teploy backup create   --app myapp --host prod --accessory db --json
teploy backup list     --app myapp --host prod --json    # parsed rows + schedules
teploy backup restore  <id> --app myapp --host prod --yes --json
teploy backup schedule "0 3 * * *" --app myapp --host prod --keep-last 7
teploy backup prune    --app myapp --host prod --keep-last 3 --max-age-days 30

Every command also works from an app directory without --app/--host, and the legacy --bucket/--region/--endpoint flags still work (they override the stored target). teploy dr and teploy accessory verify-backup default to the stored target too.

Stored credentials are used whether or not an endpoint is set; without them the server's ambient AWS credential chain applies. A non-root deploy user who cannot write /etc/teploy gets ~/.config/teploy/ instead, and the command says so.

Scheduled jobs are a single argument-free line — /usr/local/bin/teploy backup run --app myapp — with the retention half of the schedule stored per app and read at run time, so changing either never rewrites cron. teploy server backup-target set rewrites existing teploy-generated backup cron lines to that form (hand-written commands under a teploy marker are left alone and reported), and teploy doctor flags any cron line that still embeds credentials or bucket flags.

Restore quiesces: the app's containers (and a generic accessory's) are stopped under the app's fenced lock, the backup is promoted with the two-phase recovery discipline, the containers are started again and health-checked. On failure — or an unhealthy app afterwards — the pre-restore recovery copy is kept and named. Non-interactive restores require --yes.

--keep-last N is a floor (the newest N are always kept, even if old); --max-age-days D deletes older backups but never below that floor. A failed backup never triggers a prune. Both backup create and scheduled cron backups alert on failure via the app's notifications config — create uses all channels; the headless backup run posts to the first webhook channel through the alert script backup schedule uploads to /deployments/<app>/backup-alert.sh (mode 0700). That is what lets the alert be signed: the signature covers a timestamp, date +%s contains a %, and crontab treats % as a newline escape. If a signing secret is configured (see notifications.secret / TEPLOY_WEBHOOK_SECRET) the alert carries the same X-Teploy-Signature headers as every other delivery; otherwise it goes unsigned, and backup schedule says so on its output so a verifying receiver rejecting it doesn't look like a wrong secret.

Managed secrets (OpenBao provider)

teploy secret is provider-abstracted (like teploy network): the default local provider is the built-in age store; openbao is a managed OpenBao secrets manager — provisioned, auto-unsealed, and wired for least-privilege access in one command. Select it with --provider openbao or secret.provider: openbao in teploy.yml.

teploy secret set KEY=value                 # local (age) — the default provider
teploy secret set KEY=value --provider openbao
teploy secret get / list --provider openbao # same verbs, managed backend
teploy secret rm KEY                        # local store only; openbao is refused

teploy secret setup                          # provision + auto-unseal OpenBao (idempotent)
teploy secret setup --replicas 3             # HA: a 3-node Raft quorum
teploy secret setup --seal transit --transit-address ... --transit-token ...  # off-box key
teploy secret put db password=s3cr3t host=pg # multi-field secret (openbao)
teploy secret status                         # seal/init status (+ Raft cluster in HA)

# Reference a secret from teploy.yml — fetched + injected at deploy:
#   env: { DB_PASSWORD: "secret:db#password" }   # multi-field
#   env: { API_KEY: "secret:API_KEY" }           # flat (defaults to the value field)

# Dynamic, short-lived, auto-revoked database credentials:
teploy secret db setup --db-accessory postgres --admin-pass <pw>
teploy secret db creds                       # on-demand ephemeral credentials

# Rotate an EXISTING db user's password on a schedule (static role):
teploy secret db static-role --username appuser --rotation-period 24h --admin-pass <pw>
teploy secret db static-creds --username appuser

# Ship secret-access events into the observe tamper-evident audit trail:
teploy secret audit ship                     # one-shot forward
teploy secret audit enable --interval 300    # timer: stream continuously
  • Auto-unseal: a static-env-key seal (key held in the app's encrypted local store) means OpenBao unseals itself on every restart — no manual ceremony. --seal awskms|transit hold the key off-box (KMS / a second OpenBao) for disk-theft/root-compromise protection; the credentials ride a 0600 env-file, never the config.
  • High availability: --replicas 3 (or 5) runs a Raft quorum. Nodes auto-unseal + auto-join; the cluster tolerates losing a node (reads and writes keep working), and a recovered node auto-rejoins.
  • Least privilege: each app gets a scoped AppRole (read-only on its own secrets); cross-app reads and writes are denied.
  • Dynamic + static rotation: dynamic creds mint a new short-lived DB user per request; static roles rotate an existing account's password on a schedule. Set secret: { provider: openbao, agent: true } to run an OpenBao Agent sidecar that renders auto-rotating creds to /vault/secrets/db.env in the app.
  • Tamper-evident audit: secret audit ship/enable forwards every secret access into observe's hash-chained audit trail — access is cryptographically verifiable, and can't be silently altered.

Auto-deploy

teploy autodeploy setup            # webhook-triggered auto-deploys
teploy autodeploy status / remove

For a monorepo where one repo holds several apps, restrict which pushes redeploy this app with an autodeploy: block in teploy.yml:

autodeploy:
  paths:
    - "apps/web/**"          # everything under apps/web/
    - "packages/common/**"   # a shared package this app depends on

A push redeploys only if it touched a matching file. Patterns support a trailing /** and path.Match globs (* within a segment, ?, exact paths); shared code must be listed explicitly. When a push payload doesn't carry a reliable file list (a large/truncated push, or a provider whose shape we can't read), teploy deploys anyway rather than risk skipping a real change.

Mesh access (JIT grants)

teploy network grant --ttl 2h --tag tag:contractor   # time-boxed key, auto-revokes
teploy network grants                                # list active keys
teploy network revoke <key-id>                       # cut a grant early

Mints ephemeral, tagged pre-auth keys via the Tailscale or Headscale API — "give the contractor two hours" without permanent credentials. What a tag can reach is your ACL policy's call; teploy never edits ACLs.

Registry and templates

teploy registry login / list / remove        # manage container registry credentials
teploy template list / info / deploy         # community app templates
teploy template install <name> --server S    # fetch a template and deploy it in one step

Templates that route by domain take --domain; ingress: host templates (like teploy-ship) publish on bind:port instead and take --port.

Multi-server deploys

app: myapp
domain: myapp.com
servers:
  - app1
  - app2
  - app3
parallel: 2
teploy deploy          # deploys to all servers in parallel
teploy scale 3         # deploy to 3 app-role servers + update LB
teploy deploy --role worker --tag region=eu   # target a subset by role/tags

Staged rollouts (canary + failure budget)

rollout:
  canary: 1            # or "10%" — first wave, deployed serially
  max_failures: 0      # tolerated failures after the canary passes

With a rollout: block, multi-server deploys stage in two waves. The canary wave deploys first and must come up healthy — any canary failure halts the rollout, rolls the canary back, and leaves the rest of the fleet untouched. Then the main wave deploys with your parallel setting. max_failures: 0 (default) keeps today's all-or-nothing behavior: any failure rolls the whole fleet back to the old version. With max_failures: N, every server is attempted, up to N failures are tolerated — servers that succeeded keep the new version and only they enter the load balancer — and the command still exits non-zero with a named straggler list and the exact converge command. A mixed-version fleet is never left silent.

Destinations

Manage multiple environments with config overlays:

# teploy.yml         — base config
# teploy.staging.yml — staging overrides (domain, server, etc.)
teploy deploy -d staging

The overlay merges on top of base config — override only what differs per environment.

CI/CD

Teploy doesn't bundle a CI runner — it integrates with the one you run. To wire push-to-deploy into Forgejo or GitHub Actions (build → test → teploy deploy), or the no-secrets webhook alternative, see docs/ci-deploy.md.

For secrets scanning (catching a committed API key before it's in history forever), drop the Gitleaks recipe into your Actions or a pre-commit hook — see docs/secrets-scanning.md. For runtime secrets, use teploy secret set or SOPS/age env_files:, never plaintext in the repo.

Surviving server loss

Teploy has no scheduler by design — resilience comes from topology: N+1 active servers for the stateless tier, state that outlives any one box (managed/replicated DB, or scheduled + verify-backup-proven backups off the server), and a five-command human-confirmed rebuild runbook. Read docs/resilience.md before you need it.

Requirements

  • A Linux server with SSH key access (any VPS — Hetzner, DigitalOcean, Linode, etc.)
  • That's it for the standard path. teploy setup installs Docker, Caddy, and rsync. Already-provisioned Docker hosts work too (an ingress: host deploy needs nothing else).

One app runs one image; multi-image stacks are refused, not approximated — see the full matrix and declared limits in docs/supported-workloads.md.

Docs

  • First success — install to verified deploy and rollback, with failure modes and remedies
  • Supported workloads — what deploys today, what is refused, ingress guarantees, operational limits
  • Failure and recovery — error envelope and exit codes, teploy doctor, interrupted deploys and repair debt, DR bundles (teploy dr)
  • Migration — Dokploy/Coolify/Compose import: what converts, what refuses, concept mapping
  • CI/CD — push-to-deploy with Forgejo/GitHub Actions
  • Secrets scanning — Gitleaks recipe
  • Resilience — surviving server loss (topology + runbook)

Comparison

teploy Kamal Coolify Dokploy
Management server required No No Yes Yes
Config lines to deploy 3 ~20 GUI GUI
Single binary Yes No (Ruby) No No
Auto HTTPS Caddy Kamal Proxy Traefik Traefik
Build from source Dockerfile + Nixpacks Dockerfile only Nixpacks Nixpacks
Preview environments Yes No Yes Yes
Maintenance mode Yes No No No
Templates Yes No Yes Yes

License

MIT

About

Zero-downtime Docker deploys to any server via SSH. Single binary, no management server.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages