Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
/teploy
/cmd/teploy/teploy

# Release-verify local build matrix (scripts/release-verify.sh)
/dist-verify/

# Go
*.exe
*.test
Expand Down
13 changes: 13 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Smoke-test vehicle for the R01 release-verify built-image check
# (scripts/release-verify.sh smoke). NOT a published release artifact —
# goreleaser ships binaries/archives; this image exists so the exact
# verified binary proves it runs in a minimal (scratch) container.
# The binary is COPY'd from the release-verify dist directory, so the
# image provenance is always the checksummed matrix build.
#
# Build (as the smoke script does):
# docker build --build-arg BINARY=dist-verify/teploy_linux_<arch> -t teploy:release-smoke .
FROM scratch
ARG BINARY=dist-verify/teploy_linux_amd64
COPY ${BINARY} /teploy
ENTRYPOINT ["/teploy"]
21 changes: 20 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test lint vet clean
.PHONY: build test lint vet clean quickstart release-verify release-record release-smoke

build:
go build -o teploy ./cmd/teploy
Expand All @@ -14,3 +14,22 @@ vet:

clean:
rm -f teploy

# Executable quickstart (C09): deploy the maintained fixture app to the
# local colima VM and verify it answers. Skips honestly (exit 0) when no
# local docker target exists.
quickstart:
./examples/quickstart/run.sh

# R01 release receipts: build the goreleaser matrix locally, checksum,
# and diff against recorded expectations (see release/RELEASE_RECEIPT.md).
release-verify:
./scripts/release-verify.sh verify

release-record:
./scripts/release-verify.sh record

# R01 built-image smoke: build the container image from the verified
# matrix binary and run version + doctor in it. Skips without docker.
release-smoke:
./scripts/release-verify.sh smoke
34 changes: 25 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<p align="center">
<h1 align="center">teploy</h1>
<p align="center">Zero-downtime Docker deploys to any server via SSH.<br>Single binary. No management server. No dependencies.</p>
<p align="center">Docker deploys to any Linux server you can SSH into, with blue/green zero-downtime under Caddy.<br>Single binary. No management server.</p>
</p>

<p align="center">
Expand All @@ -13,7 +13,7 @@

## Why teploy?

Most deploy tools require either a management server (Coolify, Dokploy) or complex 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.
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.

```yaml
# teploy.yml
Expand All @@ -26,7 +26,11 @@ server: 1.2.3.4
teploy deploy
```

Your app is live with HTTPS, zero-downtime deploys, and automatic rollback on failure.
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](docs/supported-workloads.md).)

## Install

Expand Down Expand Up @@ -81,16 +85,16 @@ standalone.
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 — zero downtime
6. **Rolls back** automatically if anything fails
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 |
| **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 instantly |
| **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 |
Expand Down Expand Up @@ -631,8 +635,20 @@ the server), and a five-command human-confirmed rebuild runbook. Read

## Requirements

- A server with SSH access (any Linux VPS — Hetzner, DigitalOcean, Linode, etc.)
- That's it. `teploy setup` handles the rest.
- 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/supported-workloads.md).

## Docs

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

## Comparison

Expand Down
3 changes: 2 additions & 1 deletion contracts/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Neutron/Nucleus dependency and a public mirror.

| Corpus rev | Emitting CLI | Machine Interface | Notes |
|---|---|---|---|
| 5 | main (X02 S2 tail: server-status fixtures + schema correction) | 2 | server-status-envelope fixtures landed (was "pending live capture"): valid x2 (full healthy observation, partial-caddy-unavailable — the class a target without a caddy container produces) + legacy pre-MI (machine_interface absent, the 42243e2-era shape). Encoder-derived: generated from the REAL `collectServerStatus` via a mock SSH executor (`contracts_golden_test.go`, TEPLOY_UPDATE_CONTRACTS) — synthetic values, real encoder and parse stages; the wire shape was verified against a live `server status --json` run before pinning. Defect fixed in the same commit: the schema had copied the appStatus root since its S2 draft (its own defect-fix commit 08cfb1b said so) and never described the actual serverStatusDTO wire format (server/host/uptime/load/memory/disks/docker/caddy) — rewritten to the real root with strict required-key coverage of the DTO's no-omitempty fields. Additive to consumers (a schema that matched nothing before now matches the wire); no MI bump. |
| 4 | main (X02 S2 tail: server-list reshape) | 2 | **The MI 2 bump** (D8 non-additive): `server list --json` now emits the envelope `{machine_interface, servers[], observed_at}` carrying the per-server fields unchanged (name + id/host/user/role/tags/vpn_ip); the pre-reshape bare map-of-servers root is GONE on the wire and is pinned as the artifact's legacy class. New artifact server-list-envelope (schema + valid + legacy fixtures); version-handshake schema maximum 1→2 and its valid fixture renamed mi1→mi2 (app-list valid likewise — both envelopes now report MI 2). Capability tokens unchanged. Coordinated consumer: teploy-dash decodes both shapes during the transition (MaxSupportedMachineInterface 2). |
| 3 (amended) | main (C05 plan-record corpus + defect fix) | 1 | C05 added the plan-record artifact + plan-apply token (see git history); amendment: server-status schema now carries its own $defs (its $refs never resolved), and app-list fixtures emit [] where the encoder emits [] (null fixtures failed schema + the real dash decode - found by dash's new contracts CI job, fixed here). |
| 1 | post-v0.1.37 main (S2 skeleton) | 1 | First goldens: version handshake, app-list envelope (MI + pre-MI legacy), error envelope (config-invalid, internal, invalid-code), release-record, attempt-name grammar, preview-state eras. |
Expand All @@ -23,7 +24,7 @@ Neutron/Nucleus dependency and a public mirror.
| version-handshake | yes | valid (real `writeVersion` encoder) | teploy-cli |
| app-list-envelope | yes | valid (real DTO tags) + legacy pre-MI | teploy-cli |
| server-list-envelope | yes (MI 2 reshape) | valid (real `writeServerList` encoder) + legacy bare-map | teploy-cli |
| server-status-envelope | yes (appStatus root) | pending S2 tail (live `server status` capture) | teploy-cli |
| server-status-envelope | yes (serverStatusDTO root, corrected rev 5) | valid x2 (full, partial-caddy-unavailable; real `collectServerStatus` encoder over mock executor) + legacy pre-MI | teploy-cli |
| error-envelope | yes | valid x2 + invalid code | teploy-cli |
| release-record | yes | valid container | teploy-cli |
| attempt-name | yes (pattern) | valid + invalid examples | teploy-cli |
Expand Down
93 changes: 93 additions & 0 deletions contracts/fixtures/server-status-envelope/legacy/pre-mi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
{
"caddy": {
"available": true,
"routes": [
{
"handlers": [
"reverse_proxy"
],
"hosts": [
"myapp.example.com"
],
"id": "",
"server": "srv0",
"status_code": "",
"upstreams": [
"myapp-web-3:3000"
]
},
{
"handlers": [
"subroute"
],
"hosts": [
"myapp.example.com"
],
"id": "myapp",
"server": "srv0",
"status_code": "",
"upstreams": []
}
]
},
"disks": [
{
"available_bytes": 750,
"filesystem": "/dev/vda1",
"mountpoint": "/",
"total_bytes": 1000,
"used_bytes": 250,
"used_percent": "25%"
},
{
"available_bytes": 1500,
"filesystem": "/dev/vdb1",
"mountpoint": "/srv",
"total_bytes": 2000,
"used_bytes": 500,
"used_percent": "26%"
}
],
"docker": {
"containers": [
{
"created_at": "2026-09-23 11:55:00 +0000 UTC",
"id": "9f31c02",
"image": "example/myapp:3",
"name": "myapp-web-3",
"process": "web",
"state": "running",
"status": "Up 4 minutes",
"version": "3"
}
],
"images": [
{
"created_at": "2026-09-23 11:50:00 +0000 UTC",
"id": "sha256:1a2b3c4d5e6f",
"repository": "example/myapp",
"size": "25MB",
"tag": "3"
}
],
"installed": true,
"version": "29.0.0"
},
"errors": [],
"host": "192.0.2.10",
"load": {
"fifteen": 0.3,
"five": 0.2,
"one": 0.1
},
"memory": {
"available_bytes": 409600,
"total_bytes": 1024000,
"used_bytes": 614400
},
"observed_at": "2026-09-23T12:00:00Z",
"server": "prod",
"uptime": {
"seconds": 3600.5
}
}
94 changes: 94 additions & 0 deletions contracts/fixtures/server-status-envelope/valid/full.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
{
"machine_interface": 2,
"server": "prod",
"host": "192.0.2.10",
"uptime": {
"seconds": 3600.5
},
"load": {
"one": 0.1,
"five": 0.2,
"fifteen": 0.3
},
"memory": {
"total_bytes": 1024000,
"used_bytes": 614400,
"available_bytes": 409600
},
"disks": [
{
"filesystem": "/dev/vda1",
"mountpoint": "/",
"total_bytes": 1000,
"used_bytes": 250,
"available_bytes": 750,
"used_percent": "25%"
},
{
"filesystem": "/dev/vdb1",
"mountpoint": "/srv",
"total_bytes": 2000,
"used_bytes": 500,
"available_bytes": 1500,
"used_percent": "26%"
}
],
"docker": {
"installed": true,
"version": "29.0.0",
"containers": [
{
"id": "9f31c02",
"name": "myapp-web-3",
"image": "example/myapp:3",
"state": "running",
"status": "Up 4 minutes",
"created_at": "2026-09-23 11:55:00 +0000 UTC",
"process": "web",
"version": "3"
}
],
"images": [
{
"id": "sha256:1a2b3c4d5e6f",
"repository": "example/myapp",
"tag": "3",
"size": "25MB",
"created_at": "2026-09-23 11:50:00 +0000 UTC"
}
]
},
"caddy": {
"available": true,
"routes": [
{
"server": "srv0",
"id": "",
"hosts": [
"myapp.example.com"
],
"handlers": [
"reverse_proxy"
],
"upstreams": [
"myapp-web-3:3000"
],
"status_code": ""
},
{
"server": "srv0",
"id": "myapp",
"hosts": [
"myapp.example.com"
],
"handlers": [
"subroute"
],
"upstreams": [],
"status_code": ""
}
]
},
"observed_at": "2026-09-23T12:00:00Z",
"errors": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"machine_interface": 2,
"server": "staging",
"host": "192.0.2.20",
"uptime": {
"seconds": 86400
},
"load": {
"one": 0,
"five": 0.01,
"fifteen": 0.05
},
"memory": {
"total_bytes": 512000,
"used_bytes": 256000,
"available_bytes": 256000
},
"disks": [
{
"filesystem": "/dev/vda1",
"mountpoint": "/",
"total_bytes": 500,
"used_bytes": 100,
"available_bytes": 400,
"used_percent": "20%"
}
],
"docker": {
"installed": true,
"version": "29.0.0",
"containers": [],
"images": []
},
"caddy": {
"available": false,
"routes": []
},
"observed_at": "2026-09-23T12:00:00Z",
"errors": [
{
"scope": "caddy.routes",
"message": "Error response from daemon: No such container: caddy"
}
]
}
Loading
Loading