Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@
Versions are git tags (`vX.Y.Z`); projects install one with
`uv tool install git+ssh://git@github.com/hybridinteract/deployctl@vX.Y.Z`.

## 0.12.0

Every command the docs and the tool show you now runs, and the guides describe CI/CD as the
way releases happen.

**Fixed**
- `deployctl doctor` — which has never existed; the command is `deploy doctor` — was in
the "Next steps" `deployctl init` prints and in seven guides. `init`'s next steps also
ran `setup` without `--tag`, which fails on a new project since 0.10, and pointed at a
`deployctl/docs/` path from the copied-in layout. They now work as printed, and point at
the panel's Setup and CI/CD tabs.
- `validate` advised "port 22 from your IP only" for clusters, which locks CI out. It now
says what CI needs: key-only and open, or a tailnet or jump host.
- The bootstrap script's messages referred to steps of a Tutorial tab that no longer
exists.

**Guarded**
- A test reads every `deployctl …` in the README, the guides and the tool's own hints, and
fails on a command or option the CLI does not have.
- A test resolves every link between the docs, section anchors included.
- A test holds every install command in the docs to the current version (several still
installed v0.9.0).

**Docs**
- `10-SINGLE-SERVER`: configure first, then the bootstrap script instead of a manual root
session; a firewall that lets CI in; the first deploy with its tag; a new *Deploy on
every merge* step; day two as merges, the Operate tab and `ci deploy`.
- `00-CONCEPTS`: the control machine is CI's deploy job, or your laptop; a failed release
reverts across the fleet; the host-side state files (`.deployctl-state`, the history,
`.previous/`, the lock).
- `30-OPERATIONS`: rollback through GitHub first; nothing is written into `config/`.
- `35-CONTINUOUS-DEPLOYMENT`: the panel button beside each command.
- `40-ADOPTING`: after `adopt`, `migrate-config` and `ci init --force`; after an upgrade,
regenerate the workflows — CI runs the version its workflow pins.

## 0.11.0

The control panel, rebuilt around continuous deployment.
Expand Down
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ GitHub Actions, or from a control panel in your browser.**

Point it at your servers and it runs the whole release: render the configuration, ship it
over SSH, pull the image, migrate the database, then restart each host one at a time behind
a health check — stopping the moment a host fails to come back.
a health check and a watch on every service — and if a host fails, put it and every host
already moved back on the release before.

It handles **one server** or **N servers behind a load balancer** from the same
configuration. The **control panel is the main way in**: a local web UI where you fill in
the settings, pick an image and press Deploy. Everything it does is also a CLI command, so
you can script or automate later without learning a second tool.
configuration. **Once set up, a release is a merge**: GitHub Actions runs deployctl on every
push to your deploy branch, with a CI-only key and your configuration as a secret — the
same engine you can run from your laptop, sharing one lock. The **control panel** walks a
new project there (prepare the server, first deploy, CI/CD) and is where you see what runs
and roll back. Everything it does is also a CLI command.

What a server needs: **Docker, an SSH user, a writable directory.** That is all — no source
code, no build toolchain, no git checkout. Images are built by CI (or your machine) and
Expand All @@ -27,7 +30,7 @@ every detail spelled out, plus a troubleshooting table:
### 1 · Install the tool

```bash
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.9.0
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.12.0
deployctl --version
```

Expand Down Expand Up @@ -128,18 +131,19 @@ Everything above is a command. Use these for scripting, CI, or when you want the
invocation in your shell history:

```bash
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.9.0 # once per machine
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.12.0 # once per machine
deployctl init --mode single # or --mode cluster; creates deploy/
$EDITOR deploy/config/common.env # project name, domain, image repo
$EDITOR deploy/config/production.env # hosts, database, TLS
$EDITOR deploy/project/project.env # how to run YOUR app

deployctl ci init # GitHub Actions → registry (or: image push)
deployctl setup --env production # render generated/
deployctl validate --env production # lint config + artifacts
deployctl doctor --env production # can we reach every host?
deployctl deploy init --env production # first bring-up
deployctl ssl setup --env production # single-server TLS only
deployctl ci init --env production # GitHub Actions builds the image; push → note its tag
deployctl server bootstrap-script --env production | ssh root@<server-ip> 'bash -s' # once per new server
deployctl setup --env production --tag <tag> # render generated/
deployctl validate --env production # lint config + artifacts
deployctl deploy doctor --env production --tag <tag> # can every host run it?
deployctl deploy init --env production --tag <tag> # first bring-up
deployctl ssl setup --env production # single-server TLS only
```

Then day to day:
Expand Down
49 changes: 30 additions & 19 deletions docs/00-CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,24 @@ once; the per-shape guides then make sense without re-explaining themselves.

```
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ build machine │ ─push─▶│ registry │◀─pull──│ host(s) │
│ CI, or yours │ │ ghcr.io │ │ Docker only │
└─────────────────┘ └──────────────┘ └────────▲────────┘
│ ssh + rsync
┌─────────┴────────┐
│ control machine │
│ your laptop │
│ deployctl │
└──────────────────┘
│ build job │ ─push─▶│ registry │◀─pull──│ host(s) │
│ GitHub Actions │ │ ghcr.io │ │ Docker only │
└────────┬────────┘ └──────────────┘ └────────▲────────┘
│ the tag it pushed │ ssh + rsync
▼ ┌─────────┴────────┐
┌─────────────────┐ │ control machine │
│ deploy job │ ─ runs deployctl, which is ─▶ │ CI's deploy job │
│ GitHub Actions │ │ — or your laptop │
└─────────────────┘ └──────────────────┘
```

- **The registry is a neutral handoff point.** CI never touches your servers; servers
never see your source. Neither needs credentials for the other.
- **The control machine orchestrates.** No agent runs on a host. Deploying is your
laptop (or a bastion) driving ssh with your own keys.
- **The registry is a neutral handoff point.** The build never touches your servers;
servers never see your source. What passes from build to deploy is one immutable tag,
the commit's short SHA.
- **The control machine orchestrates, and it is usually CI.** No agent runs on a host. A
deploy is deployctl driving ssh: on every merge, in GitHub Actions' deploy job with a
CI-only key; by hand, from your laptop with your own keys. Same engine, and one lock on
the primary, so the two never interleave.
- **Hosts are boring.** Docker, an unprivileged user in the `docker` group, and a
writable directory. That is the entire requirement.

Expand Down Expand Up @@ -140,16 +143,20 @@ Everything else — nginx, api, workers — runs on every host.
rsync artifacts → docker login → docker compose pull
docker compose up -d → reload nginx → wait for health
→ watch EVERY service for DEPLOY_SETTLE_SECONDS: no restarts, never unhealthy
↑ any failure: put the previous release back on this host, stop the roll
5. record the deployed tag on each host
↑ any failure: put the previous release back on this host AND on every host
already rolled, newest first; stop
5. record the deployed tag and who deployed it on each host, and the release in the
history on the primary
```

Three things make it safe. The **health gate** stops a bad release after the first host,
and with a load balancer the others keep serving the previous one. The **service watch**
catches what the gate cannot see — it probes the api only, so a worker that crash-loops
would otherwise pass. And the **automatic revert** means a single server is not left on a
release that failed: its previous compose file, `.env` and nginx config are restored and
started again, from the image already on the host. Migrations are not reverted — the older
would otherwise pass. And the **automatic revert** means no server is left on a
release that failed, and the fleet is never left split between two: the host that failed
and every host the run already moved get their previous compose file, `.env` and nginx
config back, started again from the image already on the host (`REVERT_SCOPE=fleet`, the
default; `host` reverts only the one that failed). Migrations are not reverted — the older
code runs on the newer schema, the same contract as a rollback. `DEPLOYCTL_NO_REVERT=1`
leaves a failed release in place for inspection.

Expand Down Expand Up @@ -205,6 +212,10 @@ artifact syncs, so `rsync --delete` can never remove them:
| `$REMOTE_DIR/certbot/` | Let's Encrypt certificates and the ACME webroot |
| `$REMOTE_DIR/nginx/auth/.htpasswd` | who may see `/docs` (operator-managed) |
| `$REMOTE_DIR/backups/` | database dumps |
| `$REMOTE_DIR/.deployctl-state` | the tag this host runs, when, and who deployed it — what `deploy update` without `--tag` redeploys |
| `$REMOTE_DIR/.deployctl-history` | on the primary: every release, rollback and first bring-up — what `deploy rollback` reads |
| `$REMOTE_DIR/.previous/` | the release before the running one, set aside for the automatic revert |
| `$REMOTE_DIR/.deployctl.lock` | while a deploy runs: who holds the environment (a laptop, or an Actions run) |
| Docker volumes | container Postgres/Redis data, logs |

Everything else under `$REMOTE_DIR` is disposable and rebuilt by `setup`.
Expand All @@ -224,7 +235,7 @@ whichever rendered last quietly overwrite the other's.
|---|---|
| A secondary host dies | The LB routes around it; capacity drops until you replace it. |
| The primary dies | Web traffic is fine; **scheduled jobs pause** until it returns. Workers elsewhere keep draining the queue. |
| A bad release | The health gate or the service watch fails it; that host is reverted to its previous release and the roll stops. Hosts rolled before it stay on the new tag — `deploy rollback` returns them. |
| A bad release | The health gate or the service watch fails it; that host and every host already rolled are reverted to the previous release, and the run fails. The fleet stays on one release. |
| Managed database blip | The provider's problem; the app reconnects. |
| Single-server host dies | Everything is down, including the database. Your backups are the recovery plan — which is why `backup run` should be scheduled, not remembered. |

Expand Down
23 changes: 12 additions & 11 deletions docs/05-QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ optional and nothing here is ambiguous — run the blocks in order, top to botto
## Step 1 · Install the tool

```bash
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.9.0
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.12.0
deployctl --version
```

Expand Down Expand Up @@ -127,18 +127,19 @@ Deploy the change). Deploying from this machine is still there, folded under
Every button above is a command. Use these for scripting, CI, or an audit trail:

```bash
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.9.0 # once per machine
uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.12.0 # once per machine
deployctl init --mode single --env production
$EDITOR deploy/config/common.env # project name, domain, image repo
$EDITOR deploy/config/production.env # hosts, database, TLS
$EDITOR project/project.env # how to run YOUR app

deployctl ci init # GitHub Actions → registry
deployctl setup --env production # render generated/
deployctl validate --env production # lint config + artifacts
deployctl doctor --env production # can we reach every host?
deployctl deploy init --env production # first bring-up
deployctl ssl setup --env production # single-server TLS only
$EDITOR deploy/project/project.env # how to run YOUR app

deployctl ci init --env production # GitHub Actions builds the image; push → its tag
deployctl server bootstrap-script --env production | ssh root@<server-ip> 'bash -s' # new server only
deployctl setup --env production --tag <tag> # render generated/
deployctl validate --env production # lint config + artifacts
deployctl deploy doctor --env production --tag <tag> # can every host run it?
deployctl deploy init --env production --tag <tag> # first bring-up
deployctl ssl setup --env production # single-server TLS only
```

Then for each release:
Expand All @@ -157,7 +158,7 @@ run, without touching a host.

| Symptom | Do this |
|---|---|
| `deployctl: command not found` | Step 1 — `uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.9.0`, and make sure uv's tool directory is on your PATH (`uv tool update-shell`). |
| `deployctl: command not found` | Step 1 — `uv tool install git+ssh://git@github.com/hybridinteract/deployctl@v0.12.0`, and make sure uv's tool directory is on your PATH (`uv tool update-shell`). |
| Commands act on the wrong project | They use the nearest `deploy/` above the current directory. Check with `deployctl envs`, or pass `--project-dir`. |
| `no environments configured` | Step 2 has not been run. |
| Panel says `port 8765 is already in use` | `deployctl webui --restart` |
Expand Down
Loading
Loading