@softure-ai/deploy
v0.4.1
Published
SOFTURE AI deploy CLI for a one-VPS app: renders .env.prod from CI secrets (names from the compose file), writes the release report between two tags, backs up the database, guards the migration ledger, compares row counts, verifies production against depl
Readme
@softure-ai/deploy
Deploy CLI for an app that runs on one VPS with Docker Compose. It holds the steps that do not change from app to app; what describes one app (compose file, Traefik rules, Dockerfile) stays in the app (docs/06, "Deploy: package or template").
npm install --save-dev @softure-ai/deploy
npx softure-deploy helpsofture-deploy env render
Writes the production env file from the environment, for every variable the compose file requires.
softure-deploy env render [--compose=docker/prod/docker-compose.yml] [--out=.env.prod] [--from-json-env=<NAME>]... \
[--min-length=<NAME>=<n>]...- Names come from the compose file: every
${NAME:?…}(refuses an unset or empty value) and${NAME?…}(refuses only an unset one) is required.${NAME:-default}and${NAME-default}are optional: written when the environment sets them to a non-empty value, left out otherwise (the compose default applies), so a runtime switch such asADMIN_EMAILSreaches the server without blocking a release that does not set it. A name required anywhere in the file is required. Bare${NAME}(it also names what the server script sets, like${TAG}) and escaped$${…}are skipped, so the compose file stays the one list of what production reads. - Values come from the environment (in CI:
env:from the repository secrets). A missing name stops the command with every missing name listed; nothing is written. - Or from JSON objects:
--from-json-env=APP_SECRETSreads the variableAPP_SECRETSas a JSON object of names and values (toJSON(secrets)in a workflow) and takes the values from it alone, not from the rest of the environment, so a host'sPATHorHOMEnever lands in.env.prod. The flag repeats; a later object wins for a name both hold (--from-json-env=APP_SECRETS --from-json-env=APP_VARSputs variables over secrets). A value that is not a string is skipped like an unset one. An unset variable or one that is not a JSON object stops the command by its name; the missing names are listed with the objects they were looked up in. - Short secrets are refused:
--min-length=AUTH_SECRET=32(repeatable) stops the command when the value ofAUTH_SECREThas fewer than 32 characters, naming the variable and the length only; nothing is written. A name the compose file does not use, or a spec that is notNAME=<1-9999>, is a usage error (exit 2). An optional name left unset is not checked.deploy-app.ymlpasses them from itssecret-min-lengthsinput. - Values are never printed, on success or on failure: the output names variables and counts only
(
wrote 3 names (1 of 2 optional set) …). - The file starts with a comment (
# Written by softure-deploy env render …; do not edit on the server.): it is rewritten on every release. - The file is written with mode
0600, through a temporary file and a rename. A value is written bare when compose reads it literally, else single-quoted (compose does not interpolate inside single quotes). A value with a newline or a single quote has no literal one-line form and is refused by name.
# In a deploy job
- run: npx softure-deploy env render
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
# Or every secret and variable of the repository, variables over secrets (CodeQL flags toJSON(secrets) as
# excessive-secrets-exposure: the step sees every secret, so naming them, as above, is preferred)
- run: npx softure-deploy env render --from-json-env=APP_SECRETS --from-json-env=APP_VARS
env:
APP_SECRETS: ${{ toJSON(secrets) }}
APP_VARS: ${{ toJSON(vars) }}softure-deploy release-notes
The release report between two tags, in Markdown, ready for a GitHub Release body.
softure-deploy release-notes [--to=HEAD] [--from=<tag>] [--match=<glob>] [--repo-url=<url>] [--locale=en] [--out=<file>]
[--roadmap=auto|<roadmap file>] [--body=<release body file>]- Range:
--fromdefaults to the nearest tag before--tothat matches--match(a git glob, default*;v*orapp@*when the repository has other tags). With no earlier tag the report starts at the first commit. - Source:
git log --first-parentonly, no GitHub token. Each first-parent commit is one line:- a pull request when it is a GitHub merge (
Merge pull request #N from …, title from the body), a squash merge or a merge commit ending in(#N)(Merge <branch>: <title> (#N)keeps only the title); - an "other commit" otherwise; base syncs (
Merge branch '…') are left out.
- a pull request when it is a GitHub merge (
- Links:
--repo-url, elseGITHUB_SERVER_URL/GITHUB_REPOSITORYinside GitHub Actions, else none (plain#Nand short SHAs). - Copy: the
endictionary (deployMessages, English is the only built-in language), exported for apps that format the report themselves. - Roadmap items:
--roadmap=<file>adds a### Roadmap itemstable after the summary: the rows of the SOFTURE roadmap's## At a glancetable whose status isdone_code(on the main branch, waiting for this release), with their ID, change and outcome. A missing file fails; a release with no such row has no table.--roadmap=auto(what a release workflow should pass) finds the roadmap itself:context/foundation/roadmap.mdwhile it exists, else the roadmaps archived tocontext/foundation/archive/<YYYY-MM-DD>[-N]-roadmap.mdsince the previous tag (--from), oldest first and read as they are at--to. A roadmap closed before the release is found, a re-run or redeploy of that release finds it again, and a later fix-only release does not repeat its table. Finding none is no table, not a failure.
- Inside a release body:
--body=<file>reads the release's current body (a missing file is an empty one) and writes the report between<!-- softure-deploy:release-notes -->and<!-- /softure-deploy:release-notes -->: replaced in place on a rerun, appended after the owner's text on the first run, the text around it kept. The result goes to--outor stdout, ready forgh release edit --notes-file.
## v1.2.0 (2026-10-05)
Changes since v1.1.0: 2 pull requests, 1 other commits.
### Pull requests
- Trial ends at midnight ([#12](https://github.com/acme/app/pull/12))
- Export to CSV ([#11](https://github.com/acme/app/pull/11))
### Other commits
- fix: typo in the footer ([`0123456`](https://github.com/acme/app/commit/0123456…))
[Full diff](https://github.com/acme/app/compare/v1.1.0...v1.2.0)softure-deploy release-report
The deploy run's report in the same release body (DF-10): the pipeline status of the latest run and the history of every deployment of the tag.
softure-deploy release-report --body=<release body file> --summary=deploy-report.json [--locale=en] [--out=<file>]- Input:
--summaryis thedeploy-report.jsonthesummaryjob ofdeploy-app.ymluploads (version 1: tag, environment, image, digest, run URL, end time in UTC, each job's result and the server'sstep|…/result|…lines). Another shape fails with the field's name.--bodyis the release's current body; a missing file is empty. - Status: between
<!-- softure-deploy:status -->markers,## Pipeline status: one row per job with its result (✅ success, ❌ failure, ⛔ cancelled, ⏭️ skipped, ⏳ anything else) and the link to the run. Every run replaces it. - Deployments: between
<!-- softure-deploy:deployments -->markers,## Deployments: one row per run, the newest on top, the earlier rows kept byte for byte, so a rerun or a rollback adds a row. Columns: time (UTC), result (deployed, orfailed at <step>from the server'sresult|failed|<step>|…), environment, image and digest, the backup file and the row counts before → after (?when the server stopped before counting again,not countedon a first release), the verify result and the run. A run whose deploy job was skipped writes the status only. - Order: the sections stand in a fixed order whatever wrote them first:
release-notes, status, deployments; the owner's text above them stays. Values from the summary are reduced to letters, digits and plain punctuation, so a|, a backtick or<cannot break the table. - A GitHub Release body holds at most 125 000 characters; one row is about 300, so a tag reaches the limit after some
300 runs, and the report's
gh release editthen fails without touching the deploy.
Database steps around a deploy
backup, schema-guard and row-counts read the database URL from DATABASE_URL (--url-env=<NAME> names
another variable). The URL is never printed; a driver error prints its message only. A server deploy.sh runs them
in this order:
softure-deploy backup --dir=/srv/app/backups --keep=7 # 1. dump, then retention
docker compose run --rm --no-deps app \
npx softure-deploy schema-guard --migrations-dir=/app/softure-migrations # 2. in the NEW image, before the switch
softure-deploy row-counts --tables=users,billing.subscriptions --out=counts-before.json # 3.
docker compose up -d app && docker compose run --rm app node migrate.js # 4. switch and migrate (the app's own)
softure-deploy row-counts --tables=users,billing.subscriptions --compare=counts-before.json # 5.Without a connection (--stdin). When the database publishes no port (Postgres reachable only on the compose
network), the CLI never connects: pg_dump and psql run in the database's own container and the CLI reads their
output. schema-guard and row-counts print the one statement they need with --print-query; psql -At runs it and
the JSON line it prints goes back on stdin. init's deploy.sh does this with database.access: "compose-exec".
exec_db() { docker compose exec -T postgres "$@"; }
exec_db pg_dump --format=custom -U postgres app | softure-deploy backup --stdin --dir=backups --keep=7
exec_db psql -At -U postgres app -c "$(softure-deploy schema-guard --print-query --app-ledger=drizzle.__drizzle_migrations)" \
| softure-deploy schema-guard --stdin --migrations-dir=migrations --app-journal=journal.json
exec_db psql -At -U postgres app -c "$(softure-deploy row-counts --print-query --tables=users)" \
| softure-deploy row-counts --stdin --tables=users --out=counts-before.jsonsofture-deploy backup
softure-deploy backup [--dir=backups] [--prefix=db] [--keep=7] [--max-age-days=<n>] [--exclude-table-data=<a,b.c>]
[--url-env=DATABASE_URL] [--pg-dump=pg_dump] | [--stdin]- Runs
pg_dump --format=custom(restore withpg_restore) into<dir>/<prefix>-<UTC yyyymmddThhmmssZ>.dump. - The password stays out of the process list: the URL is split into libpq variables (
PGHOST,PGPASSWORD, …) forpg_dump, and otherPG*variables of the shell are not passed on. A URL query parameter without a libpq variable is refused by name. - The dump is written with mode
0600through a temporary file and a rename; a failedpg_dumpleaves no file. - Integrity: the new file must start with the custom format's
PGDMPheader; otherwise it is removed and the step fails, before any retention. - Retention runs only after a dump succeeded: the newest
--keepdumps of--prefixstay, older ones of that prefix are removed and named in the output. With--max-age-days=<n>a dump whose name stamp is older thanndays is removed too (one exactlyndays old stays): a limit a privacy policy can promise ("deleted data leaves the backups within 30 days"). The newest dump is never removed. Other files in the folder are never touched. - Excluded rows:
--exclude-table-data=auth_attempts,audit.ip_logkeeps those tables' definitions and drops their rows from the dump, for data that must not outlive its own retention (IP addresses of login attempts). pg_dumpmust be at least the server's major version;--pg-dump=<path>picks another binary (/usr/lib/postgresql/16/bin/pg_dump).--stdin: keeps a custom-format dumppg_dumpwrote elsewhere (in the database's container) instead of running one: the same file name, mode, header check and retention. The dump's own flags belong to thatpg_dump, so--url-env,--pg-dumpand--exclude-table-datawith--stdinare a usage error.
softure-deploy schema-guard
softure-deploy schema-guard --migrations-dir=<dir> [--app-journal=<file> [--app-ledger=drizzle.__drizzle_migrations]]
[--url-env=DATABASE_URL | --stdin]
softure-deploy schema-guard --print-query [--app-ledger=<schema.table>]Compares the new image's migrations (the folder softure migrate --export-migrations <dir> wrote in the build
stage) with the database's @softure-ai/db ledger softure.migrations, using the migrator's own rules
(checkExportedMigrations). It refuses, with one line per problem:
- an applied migration whose file in the image has another checksum (edited after it ran);
- an applied migration the image does not have (an older image, or an older
@softure-ai/dbfor the ledger's own migrations): deploying it would run code against a schema it does not know; - a new file numbered below one already applied (it would never run).
Otherwise it prints the files the deploy will apply, and the ledger modules the image does not hold (not enabled there; the migrator leaves them alone). It takes no lock and writes nothing.
The app's own migrations. An app that also migrates with its own tool (drizzle) passes the image's journal:
--app-journal=<file> (drizzle's meta/_journal.json, copied out of the new image) is compared with the app ledger
(--app-ledger, drizzle's drizzle.__drizzle_migrations by default). When the database ran more migrations than the
journal lists, the image is older than the schema and the guard refuses it
(drizzle.__drizzle_migrations: the image knows 41 migration(s), the database ran 42; …); otherwise it prints both
numbers. A ledger table that does not exist yet counts as no migration run. --app-ledger without --app-journal is
a usage error.
--stdin: reads the ledgers from the JSON line psql -At printed for the statement of
schema-guard --print-query (with the same --app-ledger when the app journal is checked) instead of connecting.
The statement reads both ledgers in one query and copes with a database where either table is still absent.
Where it runs: wherever the folder and the database are both reachable. In the new image before the switch is the
simplest (docker compose run --rm --no-deps app npx softure-deploy schema-guard …; the image holds the folder and
joins the compose network). On the host it works after docker cp of the folder out of the new image.
softure-deploy row-counts
softure-deploy row-counts [--tables=<a,b.c> | --config=deploy.json] [--out=<file>] [--compare=<file>]
[--url-env=DATABASE_URL | --stdin]
softure-deploy row-counts --print-query [--tables=<a,b.c> | --config=deploy.json]{ "database": { "rowCountTables": ["users", "orders", "invoices"] } }- Counts the rows of each table (
tableorschema.table, lower snake case; the app's key tables, likeusers,orders,invoices) and prints one line per table. - Which tables:
--tables, or elsedatabase.rowCountTablesofdeploy.json(--confignames another file), so the list lives with the app's other deploy settings. With--tablesthe file is not read; passing both flags is a usage error, and so is having neither the flag nor the list (exit2). An invalid file is exit1with its issues. - A listed table the database lacks prints
absentand is not an error, so a table can join the list in the release whose migration creates it. --outsaves the counts as JSON ({ "takenAt", "counts" }, an absent table asnull);--comparereads such a file and printsbefore -> after (delta), orabsent -> <n> (created by this release)for a table absent before. A table with fewer rows than before, not counted before, or absent now (<n> -> absent, alsoabsent -> absentfor a misspelled name) fails the step (exit 1); the deploy script decides whether that rolls the deploy back.count(*)reads every row: keep the list to the tables whose loss would matter.--stdin: reads the counts from the JSON linepsql -Atprinted for the statement ofrow-counts --print-querywith the same tables, instead of connecting; a snapshot that counts other tables is refused.
softure-deploy verify
Checks a deployed app against the routes in its deploy.json, prints a table and exits 1 when a check fails.
softure-deploy verify <url> [--config=deploy.json] [--timeout=<ms>] [--concurrency=4] [--origin=<host>[:<port>]]{
"$schema": "https://unpkg.com/@softure-ai/deploy/schema/deploy.schema.json",
"verify": {
"headers": { "strict-transport-security": "max-age=", "x-powered-by": null },
"tlsMinDays": 14,
"routes": [
{ "path": "/", "contains": ["<h1"], "excludes": ["Application error"] },
{ "path": "/pricing", "headers": { "content-type": "text/html" } },
{ "path": "/old-pricing", "status": 308, "redirect": "/pricing" },
{ "path": "/robots.txt", "contains": ["Sitemap:"] }
]
}
}- A route is a
path(on<url>, which may carry a path prefix) with an expectedstatus(default 200),containsandexcludesbody markers, aredirecttarget (a path or an absolute URL; needs a 3xx status) andheaders. - The request:
method(defaultGET; alsoHEAD,POST,PUT,PATCH,DELETE,OPTIONS),body(not withGETorHEAD) andrequestHeaders, sent on top of verify's own: an unauthenticatedPOSTthat must answer 401, a bot'suser-agentthat must get the page and not a CDN challenge,accept: text/markdown.host,content-length,connectionandtransfer-encodingare refused. A non-GET route runs on every verify, so make it one the app treats as a no-op or a check. The report names its method (POST /api/mcp). - Type, not only status: a
content-typeheader check ("headers": { "content-type": "application/rss+xml" }) catches a login page served with 200 where a feed or an image belongs. - Severity:
"severity": "warn"on a route prints a failed row asWARNand does not fail the run (a probe to report, not to block a release);verify.originSeverity: "warn"does the same for theoriginrow. The summary adds, N warned. - Statuses that only warn:
"warnOn": { "status": [404] }on a route reads a listed status as aWARNrow and skips the route's other checks; every other mismatch keeps the route's severity. It fits a resource that exists only once a secret is configured: the Web Bot Auth key directory warns while it is a 404 and fails when a catch-all serves a200 text/htmllogin page or the app answers 500. A listed status on aforEachsource warns the same way. AwarnOnstatus cannot be the route's ownstatus.
{
"path": "/.well-known/http-message-signatures-directory",
"warnOn": { "status": [404] },
"headers": { "content-type": "application/http-message-signatures-directory+json" },
"contains": ["Ed25519"]
}- Digest:
"sha256": "sha256:<hex>"(or the bare hex) on a route: the SHA-256 of the response's exact bytes must match. - Inside
<head>and counts:"within": "head"checkscontains,excludesandcountonly between<head>and</head>(with a bot'suser-agentinrequestHeaders: "title and canonical are in the head an AI crawler gets");"count": { "Content-Signal:": 1 }needs exactly that many occurrences. - Every sitemap entry, every index entry: a route takes
forEachinstead ofpath.{ "sitemap": "/sitemap.xml", "match": "/blog/" }checks it for every<loc>whose path containsmatch(a urlset; point at the child sitemap, not a sitemap index).{ "index": "/.well-known/agent-skills/index.json" }checks it for every entry of a JSON index and compares each entry'sdigestwith the served bytes;items,urlanddigestname other keys (defaultsskills,url,digest;"digest": nullchecks no digest). The source is fetched with the route's request headers; each entry's path is requested on the verified URL's origin and gets its own row. An unreachable source, a status other than 200, an unreadable document or no entry is one failed row (sitemap /sitemap.xml).
{ "forEach": { "sitemap": "/sitemap.xml", "match": "/blog/" }, "requestHeaders": { "user-agent": "GPTBot/1.3" }, "within": "head", "contains": ["<title>", "rel=\"canonical\""] }- Signed with Web Bot Auth:
"webBotAuth": {}on a route signs each of its requests (aforEachsource and every entry included) the way@softure-ai/agent-readysigns the app's own: RFC 9421, Ed25519,@authorityandsignature-agentcovered, tagweb-bot-auth, five minutes of validity.keyEnvnames the variable holding the seed (defaultWEB_BOT_AUTH_PRIVATE_KEY, base64url, whatagent-ready web-bot-auth keyprints) andagenttheSignature-Agentorigin whose key directory lists it (default the verified URL's origin). The key never goes indeploy.jsonor the report: an unset or malformed variable fails the route with the variable's name and sends nothing.requestHeaderscannot setsignature,signature-inputorsignature-agentnext to it. Runverifywith the variable in its environment;deploy-app.ymland the deploy action pass it throughverify-env(below).
{ "path": "/", "webBotAuth": { "keyEnv": "WEB_BOT_AUTH_PRIVATE_KEY" }, "requestHeaders": { "user-agent": "ExampleBot/1.0" }, "excludes": ["Just a moment"] }- One host per run: an app on two hosts (apex and
app.subdomain) runsverifyonce per host, each with its own config. - Headers: a value is text the header must contain, case-insensitive; a list of texts the value must all
contain (
"link": ["rel=\"api-catalog\"", "rel=\"service-desc\""],"cache-control": ["private", "no-store"]), each reported as its own check in the route's row;nullmeans the header must be absent. A value that only means something at a fixed position takes{ "startsWith": "<text>" }, anchored at the start of the value and case-insensitive too ("signature-input": { "startsWith": "binding0=(\"@authority\";req)" }); it may also be an item of a list.verify.headersapplies to every route; a route's entry for the same name wins, the whole list with it. - Requests:
GETwithcache-control: no-cache, redirects not followed, at most--concurrencyat once (default 4), each withinverify.timeoutMs(default 10000) or--timeout. A TLS, connection or timeout error fails that route with the reason; other routes still run. - Certificate: with
verify.tlsMinDays(1 to 365),verifyopens one TLS connection to the URL's host, reads the certificate without sending a request and adds atlsrow: days left, the expiry date (UTC) and the issuer. Fewer days than the minimum, a certificate not trusted for the host, a handshake error or anhttp://URL fail the row and the run. Behind Cloudflare the certificate seen is Cloudflare's edge one, which it renews itself; the check matters for an origin served directly (Traefik with ACME). - Origin behind a CDN: with
--origin=<address>(the server's own IP, port 443 unless given;[v6]:portfor IPv6),verifyopens one TCP connection to it, sends nothing, and adds anoriginrow. The row passes only when nothing answers: the connection times out (a firewall dropping it), is refused or cannot be routed. An accepted connection fails the row and the run, because direct HTTPS reaches the server around the CDN. An address that does not resolve fails too (nothing was checked). It is a TCP handshake, not an HTTPS request: an open origin whose certificate does not cover the IP still fails. The address is a command-line value, never indeploy.json, so it stays out of the repository. Pass the server's IP, not a name that resolves through the CDN. - The schema is in
schema/deploy.schema.json(npm run schema -w @softure-ai/deployafter changingsrc/verify/schema.ts). The route list is the app's; the package holds only the engine. - The same file holds
database.rowCountTables, the tablesrow-countscompares (see above).
Result Status Route Detail
PASS 200 / 5 checks passed
FAIL 404 /pricing status 404, expected 200; missing "Pricing"
PASS - tls 41 days left (until 2026-11-16), issuer Let's Encrypt
PASS - origin 203.0.113.7:443 no answer within 10000 ms (dropped)
verify: 2 routes at https://example.com, 1 passed, 1 failed; certificate passed; origin passedDeploy workflow
SOFTURE/AI/.github/workflows/deploy-app.yml is a reusable workflow that releases one app to its VPS. The app's
caller, examples/deploy.yml (the file init writes), calls it with deploy: false to check
and build, then runs the deploy action in a job of the production environment, which takes the app's secrets by name
(One job per environment, below); a single uses: line with app-secrets still works, but CodeQL
flags the expression that builds it. For the release tag, between them:
- checks every input (tag, URL, paths, image, command word, port, timeout, release branch, build arguments) before
anything runs, then refuses a tag whose commit is not on the release branch (
release-branch, else the caller's default branch; commits only are fetched for it); - builds the image from the tag with
build-argsand pushes<image>:<tag>to GHCR (the only job withpackages: write); withprebuilt-image, builds nothing and adds the tag to that image instead (below); - renders
.env.prodwithsofture-deploy env renderfrom theapp-secretsJSON and theapp-varsJSON over it, and stops when a build argument's name is in.env.prodwith another value (the image and the runtime would disagree, and every write broke in an adopting app; only the name is printed). It packs.env.prodwith the tag's server files (the compose file's folder,server-scriptasdeploy.sh,deploy-configasdeploy.json) and the job's ownGITHUB_TOKENas.registry-tokeninto one gzip tar and sends that on stdin to the server's forced SSH command as<remote-command> <tag>, checking the host key againstssh-known-hosts. A symlink in the compose folder, or a file there named like one the server keeps (.env.prod,.env.prod.prev,.registry-token,deploy.sh,deploy.json,.deployed-tag,.deploy.lock,backups,releases), stops the run. The step fails unless the server's output holds the lineresult|ok(below), so a session cut halfway never reads as a release; the output stays in$RUNNER_TEMP/deploy-output.txtfor the job; - waits until
<app-url><health-path>answers 200, then runssofture-deploy verify <app-url>with the app'sdeploy-configread from the tag (only that file is checked out). A missing or invalid file fails the run;deploy-config: ""keeps the health route only. With theorigin-addresssecret ororigin-address-var, verify gets--origintoo. Withverify-env, verify gets those variables (below), and only verify does. - whatever happened, uploads the run's facts as the artifact
deploy-report(summaryjob, no permissions): each job's result, the image and its digest, and the server'sstep|…/result|…lines (init'sdeploy.shputs the backup's file name and the row counts on them).
| Input | Default | |
| --- | --- | --- |
| tag | required | release tag: the git ref built and the image tag |
| app-url | required | public base URL, https://<host>[:port] |
| image | ghcr.io/<owner>/<repository> | image name without a tag |
| prebuilt-image | none | an image already built and tested, <image>@sha256:<64 hex> in the image repository: not rebuilt, only tagged |
| context, dockerfile | ., Dockerfile | the build |
| compose-file | docker/prod/docker-compose.yml | names the secrets to render; its folder ships to the server |
| server-script | docker/server/deploy.sh | the forced command, installed on the server as deploy.sh with each release |
| environment | none | GitHub environment of the deploy job |
| secrets-from-environment | false | removed in 0.4.0: true is refused (below) |
| origin-address-var | none | the app-vars entry holding the origin address, instead of the origin-address secret (below) |
| remote-command | deploy | first word for the forced command |
| ssh-port | 22 | |
| health-path, verify-timeout-seconds | /api/health, 300 | the health wait before verify |
| deploy-config | deploy.json | the routes verify checks, also shipped to the server; empty for the health route only |
| deploy-cli-version | this package's version | the CLI run from npm (env render, verify) |
| release-branch | the caller's default branch | the branch the tag's commit must be on |
| build-args | none | NAME=value lines baked into the image; public values only (they stay in the image's history) |
| app-vars | {} | JSON object of non-secret values, e.g. toJSON(vars); rendered like secrets and over a secret of the same name, and not masked in logs (a secret 1 masks every 1) |
| secret-min-lengths | none | NAME=N entries separated by spaces or newlines: env render --min-length for each, so a secret shorter than N stops the release before anything is sent |
| registry-token | true | send the deploy job's GITHUB_TOKEN (packages: read, valid until the job ends) for the server's pull |
| e2e | false | this repository's own end-to-end test (below); refused in any other repository |
| deploy | true | false stops after the build: no deploy, verify or summary job; the caller's own jobs run the deploy action with the release output (below) |
Secrets, passed by name: ssh-host, ssh-user, ssh-private-key, ssh-known-hosts and app-secrets (a JSON
object such as toJSON(secrets); names like PATH, HOME, NODE_* and NPM_CONFIG_* are refused, in app-vars
too) are required unless deploy is false; the check job names a missing one before anything is built (none is
required: true in the workflow, since a caller with deploy: false passes none). origin-address
is optional: the server's own address behind the CDN (the example passes secrets.DEPLOY_ORIGIN_IP), refused when
deploy-config is empty, and verify fails when it accepts a direct connection. A secret, not an input, so the address
is masked in the logs. origin-address-var names an app-vars entry with the address instead (e.g.
DEPLOY_ORIGIN_IP from toJSON(vars), not masked); the check job refuses it next to the secret, or when app-vars
holds no text under that name. verify-env is optional too: a JSON object of text values by name (a secret such as
{"WEB_BOT_AUTH_PRIVATE_KEY": "<base64 seed>"}, for a route with webBotAuth) that the verify step exports for
softure-deploy verify alone, masking each value line by line. The check job refuses it when it is not such an object,
when deploy-config is empty, and any name the verify step or the runner sets itself (APP_URL, DEPLOY_CONFIG,
DEPLOY_CLI_VERSION, ORIGIN_ADDRESS, ORIGIN_ADDRESS_VAR, APP_VARS, VERIFY_ENV, VERIFY_ENV_NAMES, PATH,
HOME, NODE_*, NPM_CONFIG_*, in any case), naming the variable, never its value. The registry token pulls a package
the build job of the same repository pushed (its org.opencontainers.image.source label links it); for an image
elsewhere, set registry-token: false and log the server in.
No whole secrets context (0.4.0, issue #476). Neither this workflow nor the deploy action reads toJSON(secrets)
or a secret by a computed name (secrets[inputs.name]): either hands the runner every secret of the job, github_token
and the deploy key included, and CodeQL flags it (actions/excessive-secrets-exposure). secrets-from-environment,
which rendered from the deploy job's whole secrets context, is removed and true is refused with this hint; its
inputs ssh-host-secret, ssh-user-secret, ssh-private-key-secret, ssh-known-hosts-secret and verify-env-secret
are gone. Secrets kept in an environment reach the release through the deploy action, each by name (below). A caller
that builds app-secrets with toJSON(secrets) keeps working, but its own repository's scan flags that line; the
action's secret-names is the form without it, and the one examples/deploy.yml and init's
caller use (issue #486).
One job per environment (the deploy action). secrets: inherit passes nothing to a workflow of another owner,
and a job with uses: cannot name an environment, so an app outside the SOFTURE organization cannot hand its
environment secrets to deploy-app.yml at all. Such an app, and any app with more than one environment (production
and dev, each with its own server and secrets), calls deploy-app.yml with deploy: false and deploys in its own jobs
with the composite action SOFTURE/AI/.github/actions/deploy, as in
examples/deploy-environments.yml:
- the
buildjob (deploy: false) checks the inputs, the tag's branch and the image, builds or tags it, and sets the workflow outputrelease: one JSON object with the checked values (tag,app-url,environment, paths, port, timeout,deploy-cli-version,build-args,secret-min-lengths,registry-token, server files). Withdeploy: falsethe check job refuses every secret and the inputs only the deploy and verify jobs read (origin-address,origin-address-var,e2e): they belong to the action now; - each environment gets a job with
environment: <name>,permissions: { contents: read, packages: read }and one step, the action withrelease: ${{ needs.build.outputs.release }}. That job'ssecretsandvarshold the environment's values over the repository's. The step'senv:maps each secret the compose file uses by name (DATABASE_URL: ${{ secrets.DATABASE_URL }}) andsecret-nameslists those names, so withapp-vars: ${{ toJSON(vars) }}they render that environment's.env.prod(switches go in the environment's variables), and theDEPLOY_SSH_*secrets reach that environment's server. No step sees a secret the app does not name. The environment's deployment policy and reviewers apply to this job.
The action runs the same steps as the workflow's deploy and verify jobs (the workflow itself runs this action from its
own commit): render and pack, send over SSH with the host key checked, wait for the health route, softure-deploy
verify with the tag's deploy-config, then the deploy-report artifact and the cleanup of every key and .env.prod.
| Action input | Default | |
| --- | --- | --- |
| release | required | the release output of the deploy: false call; refused unless it holds every checked value |
| ssh-host, ssh-user, ssh-private-key, ssh-known-hosts | none | the environment's SSH values, e.g. secrets.DEPLOY_SSH_HOST |
| secret-names | none | names of the app's secrets the step's env: holds, separated by spaces or newlines; only these are read, and only the compose file's names land in .env.prod. An empty value counts as unset. Refused: a name the runner owns (PATH, HOME, CI, NODE_*, NPM_CONFIG_*, GITHUB_*, RUNNER_*, ACTIONS_*, INPUT_*), one the render step sets itself (COMPOSE_FILE, APP_VARS, …), a repeat, or secret-names next to a non-empty app-secrets |
| app-secrets | {} | a JSON object of secrets by name, the form before secret-names; toJSON(secrets) still works (a runner name such as NODE_AUTH_TOKEN is left out and named), but hands every secret to the runner and is flagged by CodeQL |
| app-vars | {} | toJSON(vars), rendered over a secret of the same name |
| app-url, environment | the release values | a second environment's URL (the verify target) and name (the report) |
| origin-address | none | the server's own address behind the CDN, e.g. vars.DEPLOY_ORIGIN_IP |
| verify-env | none | the JSON object for verify only, e.g. secrets.DEPLOY_VERIFY_ENV, checked by the rules above |
| health-status | 200 | the status the health wait counts as up; the first answer counts (a redirect is not followed), so 302 passes a host whose access proxy sends every request to its sign-in |
| health-headers | none | request headers of the health wait only, one Name: value line each (e.g. CF-Access-Client-Id and CF-Access-Client-Secret of a Cloudflare Access service token from secrets); each value is masked and curl reads them from a file only the runner's user can read. A line that is not Name: value is refused by its number |
| send, verify, report | true | each part on its own; report: false for every environment but one, since a run holds one deploy-report artifact |
| image, digest | none | the build job's outputs, for the report |
deploy-report.yml then runs after the environment that reports, as in the example. Pin the workflow and the action
to the same commit (below): the action reads the release object that commit's workflow writes.
A tested image (prebuilt-image). An app whose own pipeline builds the image and runs its tests against it
passes that image by digest, so production runs the bytes the tests saw rather than a second build of the same tag.
The check job accepts only <image>@sha256:<digest> with the repository of image (a tag could move between the
tests and the release; another repository would make the server pull something else); the build job skips the
checkout and the build, adds <image>:<tag> to exactly that manifest with docker buildx imagetools create and stops
when the tag then names another digest. The server pulls the tag as before, and the report's digest is that one.
build-args do not apply (the image is built already; their comparison with .env.prod still runs), and e2e with
it is refused. The workflow runs once this package is on npm.
Which ref callers pin. A caller's uses: names SOFTURE/AI's workflows at a ref. Pin the commit SHA of the
package's release tag deploy@<version>, the version the app's deploy.sh runs: the workflows at that commit default
deploy-cli-version to the same version, and a SHA never moves. Print it with
git ls-remote https://github.com/SOFTURE/AI 'refs/tags/[email protected]^{}' (the ^{} peels the annotated tag to its
commit) and write it in place of master in each uses: line, the deploy action's included; init --workflows-ref=<sha>
writes it for you. deploy-app.yml checks out its own commit (job.workflow_repository, job.workflow_sha) to run the
action, so a caller needs no extra checkout. The
examples call master, which works but follows every merge. There is no moving deploy-workflows-v1 tag.
Release report
deploy-report.yml is the caller's last job (needs: [build, deploy],
if: always() && needs.build.result == 'success', see examples/deploy.yml). It reads the
deploy-report artifact of the same run and the tag's GitHub Release body (gh release view), runs softure-deploy release-report and writes the body
back (gh release edit): the pipeline status replaced, a deployment row added on top. It is a workflow of its own
because editing a release needs contents: write, which the caller grants to this job only; inside deploy-app.yml
every job would have needed it. One report per tag runs at a time, so two runs never drop each other's row. A tag
without a release gets no report (a notice), and a failed report never turns the run red (continue-on-error).
Inputs: tag (required), locale (en, the only built-in language), deploy-cli-version, node-version, e2e (below).
Cut a release
SOFTURE/AI/.github/workflows/deploy-cut-release.yml turns one Run workflow into a release and its deploy, so an
agent that cannot push tags (a cloud session) can start one as a workflow_dispatch, and the owner can from the
Actions tab. The app's second caller is .github/workflows/release.yml, which init writes (the same file as
examples/release.yml, for an app that ran init before it did: workflow_dispatch with an
optional description, permissions: contents: write, actions: write). Started on the default branch, it:
- refuses any other ref, and invalid inputs, with one
::error::line each; - picks the next free tag
<tag-prefix><YYYY.MM.DD>intimezone, then-2,-3, … (existing tags from the API, no checkout); - creates the GitHub Release on the dispatched commit (
github.sha), thedescriptionabove GitHub's generated notes; - runs
gh workflow run <deploy-workflow> --ref <tag> -f tag=<tag>. A release created withGITHUB_TOKENstarts no other workflow, sorelease: publishedindeploy.ymldoes not fire and this step is what deploys. The deploy workflow needs aworkflow_dispatchinputtag, asexamples/deploy.ymlhas.
| Input | Default | |
| --- | --- | --- |
| description | empty | markdown above the generated release notes |
| tag-prefix | v | text before the date |
| timezone | UTC | IANA zone whose date names the tag (Europe/Warsaw for a Polish day) |
| deploy-workflow | deploy.yml | the deploy caller started on the tag; empty cuts the release only |
Outputs: tag, sha. One run per repository at a time (concurrency, not cancelled). Semver tags are out of its
scope: packages keep their own release workflow.
Release checks
SOFTURE/AI/.github/workflows/deploy-release-checks.yml holds the checks every app ran in its own YAML before a
release, and on its pull requests. Its jobs:
preparechecks the inputs (one::error::line each) and resolves the commit the other jobs check out. With atag, the tag must matchtag-pattern(thevYYYY.MM.DDtagsdeploy-cut-release.ymlcuts,-2,-3on a busy day) and its commit must be on the release branch (release-branch, else the caller's default branch; commits only are fetched). Without a tag it checks the caller's own commit, which is how a pull request runs it.staticinstalls (install-command) and runsstatic-command: the type check and lint. A Next app runsnext typegenfirst, since a fresh checkout has no generated route types;init's caller does.unitinstalls and runstest-commandonce per shard,shardsjobs in parallel (1 to 20, 60 minutes each), withSHARD_INDEX(from 1) andSHARD_COUNTset; the defaultnpm test -- --shard="$SHARD_INDEX/$SHARD_COUNT"suits Vitest.
Every job reads the repository only (contents: read), so a pull request's caller grants nothing more. The image is
built by SOFTURE/AI/.github/workflows/deploy-release-image.yml, a separate workflow: one job build (the only one
with packages: write) checks out ref (a full commit SHA, the checks' sha output), pushes <image>:sha-<sha> to
GHCR and outputs prebuilt-image, <image>@sha256:<digest>, the form deploy-app.yml's prebuilt-image takes. The
release tag is then added to exactly the bytes the checks passed. Its inputs besides ref are image, context,
dockerfile, build-args and sparse-checkout, as in deploy-app.yml.
| Input | Default | |
| --- | --- | --- |
| tag | empty | the release tag; empty checks the caller's commit |
| tag-pattern | ^v[0-9]{4}\.[0-9]{2}\.[0-9]{2}(-[0-9]+)?$ | extended regular expression the tag must match |
| release-branch | the default branch | the branch the tag's commit must be on |
| node-version, cache | 22, npm | actions/setup-node; cache: "" for none |
| install-command | npm ci | before static and each shard |
| static, static-command | true, npx tsc --noEmit && npm run lint --if-present | |
| unit, test-command, shards | true, npm test -- --shard="$SHARD_INDEX/$SHARD_COUNT", 1 | |
| sparse-checkout | the whole commit | non-cone checkout patterns for every job |
Output: sha. The commands are the app's own and run with bash -e -c from env:, never interpolated.
On pull requests. init writes .github/workflows/checks.yml (the same file as
examples/checks.yml): static on every pull request; the unit tests, which cost the most
Actions minutes, on Run workflow or with the label run-tests.
Before a deploy. In the deploy caller, checks and image jobs ahead of deploy make the release deploy only
the image the checks passed (the caller's top-level permissions already grant packages: write):
jobs:
checks:
uses: SOFTURE/AI/.github/workflows/deploy-release-checks.yml@<sha>
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
static-command: npx next typegen && npx tsc --noEmit && npm run lint --if-present
shards: 4
image:
needs: checks
uses: SOFTURE/AI/.github/workflows/deploy-release-image.yml@<sha>
with:
ref: ${{ needs.checks.outputs.sha }}
deploy:
needs: image
uses: SOFTURE/AI/.github/workflows/deploy-app.yml@<sha>
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
prebuilt-image: ${{ needs.image.outputs.prebuilt-image }}
# ...the rest as beforeEdit release notes
SOFTURE/AI/.github/workflows/deploy-release-notes.yml replaces the body of one release: an agent's cloud session
cannot edit a release through the API, but it can start a workflow_dispatch, and the owner can from the Actions tab.
init writes the caller .github/workflows/release-notes.yml (the same file as
examples/release-notes.yml: workflow_dispatch with the required inputs tag and
body, permissions: contents: write). The job refuses an invalid tag and an empty body, then runs
gh release edit <tag> --notes-file <file> with the body written to the file byte for byte. With keep-report: true
(the caller's default; the reusable workflow defaults to false) body is only the owner's text: the job appends the
current body from its first <!-- softure-deploy: marker on, unchanged, after a blank line, so the sections
release-notes and report wrote survive. A body that itself carries such a marker is refused, and a release without
one is replaced whole. With keep-report: false it replaces the whole body; edit the release's current text and pass
all of it.
End-to-end test
.github/workflows/e2e-deploy.yml calls deploy-app.yml from the same
commit with e2e: true for the example app, on every pull request that touches the workflow or tools/deploy/, on
master and on demand. On that path:
buildbuilds the example app's image (examples/next-app/Dockerfile) without the GHCR login and without a push, and hands it todeployas the artifactdeploy-e2e-image;deploybuilds this CLI from a full checkout of the tag instead of running the npm version, ande2e/start-server.shsets up its own runner (Ubuntu with Docker, cron and flock) as the server: the image goes into a registry onlocalhost:5000(the e2e app's image name), the compose file's Docker Hub images come frommirror.gcr.io, a certificate fordeploy-e2e.example.comsigned by a CA of the run waits in Traefik's ACME store (so Traefik never asks Let's Encrypt) and the name points at127.0.0.1, andsshdbinds fresh keys to the forced commande2e/server/forced-command.sh. The send step runs the productionsshcommand (host key checked) against it. The forced command first records the command line, the archive's files, their SHA-256, the mode of.env.prodand its names (never a value) withe2e/server/record.sh, uploaded as the artifactdeploy-e2e-received, then runs the tag'sdeploy.shfrom/srv/softure-example/unchanged: pull (logged in with the release's.registry-token, which the local registry accepts unchecked), backup, schema guard, switch, cron,result|ok. Itsnpx @softure-ai/[email protected](the e2e app's pinned version, never published) is answered bye2e/server/bin/npxwith the CLI built from the tag;verifywould run on another runner, away from the stack, sodeployruns its two steps itself: the wait for/api/healthandsofture-deploy verify https://deploy-e2e.example.comwith the e2e app'sdeploy.json, trusting the run's CA throughNODE_EXTRA_CA_CERTS.
The caller's report job calls deploy-report.yml with e2e: true (refused outside SOFTURE/AI as well): the CLI
from the tag writes the report into a fixture body, uploaded as deploy-report-body instead of edited into a
release; e2e/check-report.sh checks the owner's line, a status row per job and one
deployed row with the built image.
The caller's assert job runs e2e/check-received.sh: the image is
localhost:5000/softure/ai-deploy-e2e:<sha>, the command line deploy <sha>, the files are byte for byte the tag's,
and .env.prod (0600) holds exactly the compose file's required names, not the extra secret the caller also passes.
The server files are init's output for the example app, committed under e2e/app/; after a template
change, npm run e2e-app -w @softure-ai/deploy rewrites them (tests/e2e-scripts.test.ts fails until then). check
refuses e2e unless the caller is SOFTURE/AI, so an app can never take this path.
softure-deploy init
Writes the files that describe one app's deploy, once. The app owns them afterwards: an existing file is kept and
named in the output, and only --force overwrites it.
softure-deploy init --domain=example.com --image=ghcr.io/acme/app [--dir=.] [--name=<slug>] [--paths=/] [--www] \
[--acme-email=<email>] [--env=AUTH_SECRET,...] [--tables=users,billing.subscriptions] [--workflows-ref=<sha>] \
[--cdn=cloudflare] [--report-role=<role>] [--edge=<network>] [--force]| File | What it holds |
| --- | --- |
| Dockerfile | the @softure-ai/ops container recipe: a Next standalone build, a non-root user, HEALTHCHECK in the image and, with a database, migrate.mjs, the ops scripts and the exported migrations (bundled for plain Node, below) |
| .dockerignore | local state and secrets out of the build context |
| docker/prod/docker-compose.yml | Traefik (not with --edge) and the app; with a database also Postgres (port on 127.0.0.1 only) and the one-off migrate service the app waits for |
| docker/prod/traefik.yml | without --edge: the apex router (Host and, unless --paths=/, /, /_next/, the health route and the given prefixes), security headers, the optional www redirect |
| docker/prod/initdb/01-roles.sql | with a database: the migrator and app roles of the ops recipe |
| docker/prod/hooks/lib.sh | helpers for the app's own hooks (below), shipped with every release |
| docker/server/deploy.sh | the server's forced command for deploy-app.yml (below) |
| docker/server/cloudflare-only.sh, .service, .path, docker/prod/hooks/cloudflare-ranges.sh | with --cdn=cloudflare: the origin firewall and its range refresh (below) |
| scripts/migrate.ts | with a database: the migrate step the Dockerfile bundles |
| scripts/worker.ts | with a database and @softure-ai/jobs: the job worker the Dockerfile bundles into worker.mjs and the compose file's worker service runs (below) |
| .github/workflows/deploy.yml | the caller: deploy-app.yml (deploy: false) with the domain, the image and the health path, then the deploy action in a production job with each secret the compose file requires by name, as examples/deploy.yml |
| .github/workflows/release.yml | the caller of deploy-cut-release.yml (below): Run workflow cuts the next date tag in UTC and starts deploy.yml on it |
| .github/workflows/release-notes.yml | the caller of deploy-release-notes.yml (below): Run workflow with tag and body replaces that release's body above its softure-deploy sections (keep-report) |
| .github/workflows/checks.yml | the caller of deploy-release-checks.yml (below): static on every pull request, the unit tests on demand or with the run-tests label |
| deploy.json | a verify starter: / without an error page, /api/health when the app has one, HSTS present, x-powered-by absent; with a database and --tables, database.rowCountTables; with --cdn=cloudflare, the range refresh hooks |
- Asked:
--domainand--image;--paths,--www,--acme-email,--env(the app's own secrets, added to the app service in the required form soenv renderrenders them),--tables(whatrow-countscompares on the server, written intodeploy.json; with adeploy.jsonthatinitkeeps, adddatabase.rowCountTablesto it by hand),--name(compose project, server folder/srv/<name>, database name; default frompackage.json) and--workflows-ref(the commit SHA every caller pins, see Which ref callers pin; without it they callmasterand a warning prints the command that gives the release commit) and--cdn=cloudflare(the origin lock below;cloudflareis the only value) and--report-role(with a database, the Postgres roledeploy.sh'sreportreads as, ahead of the one read from a kept compose file; see below) and--edge=<network>(an app that joins another stack's Traefik, below; not with--cdnor--acme-email, which belong to that stack). - Read from the app:
@softure-ai/dbinpackage.jsonturns on the database part,@softure-ai/opsthe/api/healthroute (else/),@softure-ai/jobs(with@softure-ai/db) the job worker, apublic/folder itsCOPY; anext.config.*withoutstandaloneis a warning, and so is, with a database, one whoseserverExternalPackagesdoes not name@softure-ai/db(without itnext buildcannot resolve the driver the app does not install; see the@softure-ai/dbREADME, §2). An existingdocker/prod/docker-compose.yml(kept, or replaced with--force) givesdeploy.shthe Postgres major of thepostgresservice's image (postgres:17-alpine→ a tools image withpg_dump17; otherwise 16), and a kept one also the role of theappservice'sDATABASE_URL, whichreportreads as (otherwisesofture_app). With a database, a value the file has but init cannot read (a tag such aslatest, a role from a variable) is a warning naming the default it used. When the app takes the whole URL from a variable (DATABASE_URL: ${DATABASE_URL:?}) or creates its app role under another name, name that role with--report-role=<role>: it wins over the compose file's role and the default, also with--force, and a drift test that rendersplanInitFileswith the same answers (reportRole) gets the samedeploy.sh. Nothing is read fromsofture.config. - Values are narrow: the domain, image, name, paths, e-mail, env names, tables and report role are checked against patterns before anything is written, so no value can break out of YAML, bash or a Traefik rule.
- Secrets: the compose file's required variables are the list the workflow renders:
POSTGRES_PASSWORD,SOFTURE_MIGRATOR_PASSWORD,SOFTURE_APP_PASSWORDwith a database, plus--env. The caller's deploy step passes each by name (anenv:line and its name insecret-names), never the whole secrets context; a name added to the compose file later gets a line in both. Set them in the repository or theproductionenvironment. Use URL-safe passwords (openssl rand -hex 32): they go into connection URLs as they are.
Job worker (@softure-ai/jobs). The worker service runs the same image as the app with node worker.mjs and the
app's environment (APP_ORIGIN, DATABASE_URL as the app role, --env), so it needs no secret of its own. The image's
HEALTHCHECK asks the app's port, so the service turns it off; docker compose up --wait then waits for it to run.
It starts after migrate, and a deploy stops it with SIGTERM: running jobs get the module's shutdownGraceMs (20 s by
default) and the rest are released for the next worker; stop_grace_period: 30s leaves that time before Docker kills
it. scripts/worker.ts imports handlers from src/jobs/handlers; point it at the app's own module. An app that ran
init before it depended on @softure-ai/jobs re-runs init --force or copies the three parts (the file, the
worker.mjs bundle and COPY lines of the Dockerfile, the worker service) from a fresh init into a scratch folder.
Bundled for plain Node. migrate.mjs and ops/<name>.mjs are ESM bundles that node runs outside Next, from
code the app shares with its server build. Two esbuild flags make that work: --alias:server-only=./.esbuild/empty.mjs
(the server-only package throws outside React's server build, so it becomes an empty module) and a banner that
defines require through createRequire, so a CJS dependency inside the ESM bundle can require Node's modules. An
app generated before 0.1.8 adds both flags to its two esbuild commands, and the mkdir -p .esbuild && printf … line.
Cloudflare origin lock (--cdn=cloudflare). Behind Cloudflare, anyone who finds the server's address can talk
to Traefik directly and send any cf-connecting-ip, so rate limits keyed on it (@softure-ai/security's
cloudflareIp) count nothing. init adds:
docker/server/cloudflare-only.shand its unitscloudflare-only.serviceandcloudflare-only.path, which root installs once (the steps are in the script's header:/usr/local/sbin/<name>-cloudflare-only, units in/etc/systemd/system/). The script never lives in the app folder, which the deploy user rewrites. It reads/srv/<name>/cloudflare-ips.txt(a line that is not a CIDR, a/0, or no IPv4 range changes nothing), fills a chainSOFTURE-CLOUDFLAREwith aRETURNper range above a finalDROP(theDROPgoes in first, so a refill never opens the origin), and jumps to it fromDOCKER-USERfor TCP whose original destination port is 80 or 443 on the external interface (the default route's;CLOUDFLARE_ONLY_INTERFACEoverrides it). Published ports never passINPUT, soDOCKER-USERis the chain that sees them. IPv6 uses ip6tables'DOCKER-USERwhen Docker manages ip6tables, elseINPUT(wheredocker-proxytakes IPv6 connections). The.serviceruns whenever Docker starts, the.pathwhenever the ranges file changes.docker/prod/hooks/cloudflare-ranges.sh, run as thepost-uphookcloudflare-rangesand themaintainhookcloudflare-ranges-daily: downloadshttps://www.cloudflare.com/ips-v4and/ips-v6and rewritescloudflare-ips.txt(by a rename) only when they changed. A malformed list is refused; a failed download keeps the ranges already there with a warning and fails only when there are none yet (the first release needs Cloudflare reachable).forwardedHeaders.trustedIPson both Traefik entry points, from the ranges this version ships, soX-Forwarded-Foris kept from Cloudflare only. The hook warns when Cloudflare publishes a range missing there; copy the new list into the compose file.proxyProtocolstays off: Cloudflare's HTTP proxy does not send it (only Spectrum does).- A
nextline in init's output for the two steps init cannot do: root's install, and theDEPLOY_ORIGIN_IPsecret that makes the deploy workflow'sverify --origincheck that a direct connection gets no answer.
Edge network (--edge=<network>, issue #516). For an app that runs as a second compose project on a server whose
Traefik belongs to another app's stack. A second Traefik would clash on ports 80 and 443, so init writes none:
- the compose file has no
traefikservice, noletsencryptvolume, and a top-level networkedgethat is the external<network>; theappservice joinsedgeanddefault(Postgres andmigratestay ondefault); - the app is published by labels instead of
traefik.yml, which init does not write:traefik.enable,traefik.docker.network=<network>, a router<name>with the same ruletraefik.ymlwould hold, entry pointwebsecure, certificate resolverletsencrypt, service port 3000 and the security headers as the middleware<name>-security-headers; with--wwwa router<name>-wwwand the redirect<name>-www-to-apex. The names carry the app's<name>, so they never meet the other stack's routers; deploy.sh's switch startsapp(the migrate service runs throughdepends_on) and the worker when there is one; there is notraefikstep.
The edge Traefik must run the Docker provider (--providers.docker=true, --providers.docker.exposedByDefault=false)
with the entry point websecure and the certificate resolver letsencrypt; with other names, edit the labels. The
network must exist before the first release (docker network create <network>, joined by the edge Traefik). With a
database, init warns that Postgres is published on 127.0.0.1:5432, which the first app's Postgres may hold: change
the host port in the compose file, or drop it and set database.access to compose-exec in deploy.json. The
Cloudflare origin lock is host-wide and stays with the stack that installed it.
An app whose host sits behind an outer sign-in (Cloudflare Access on every path) passes health-status or
health-headers to the deploy action, so the health wait gets through. The routes in deploy.json are requested
without those headers and would see the sign-in: list only routes the proxy lets through, or set verify: false on the
action and verify from a job of the app's own.
Let's Encrypt keeps working through Cloudflare's proxy: Cloudflare forwards /.well-known/acme-challenge/ over
HTTP even with "Always Use HTTPS".
deploy.sh on the server. Once, by hand: copy docker/server/deploy.sh to /srv/<name>/ (a folder the SSH
user owns) and bind the deploy key to it in authorized_keys (command="/srv/<name>/deploy.sh",restrict …). Every
release then brings the rest. It answers three commands in SSH_ORIGINAL_COMMAND, five with a database (anything
else, or more than one line, exits 2; maintain also takes a hook's name, below):
status, read only (no lock, nothing written):status|tag|<tag in .deployed-tag>,status|env-tag|<TAG in .env.prod>,status|containers|<service:status …>andstatus|health|<the app container's health>, eachnonewhen there is nothing yet. For the owner:ssh -i <deploy key> <user>@<host> status.deploy <tag>with the release archive on stdin:- refuses anything but a Docker tag; reads at most 16 MiB, refuses an archive with anything but files and folders,
a path outside it or a name the server keeps (
.env.prod.prev,.deployed-tag,.deploy.lock,backups,releases), unpacks it intoreleases/<tag>/(the newest 5 are kept) and checks the compose file withdocker compose config; - saves every installed file the release is about to replace, then moves
.env.prodinto place (0600) withTAG=<tag>in it (so adocker composeby hand or from the cron runs the live release), copies the other files next to itself (in place, so Traefik's bind-mounted rules keep their inode; files 0644, folders 0755), replaces itself by a rename (the new copy runs from the next release), reads the release'sdeploy.jsonthroughsofture-deploy server-settings(thesettingsstep; an invalid file stops the release before anything restarts) and pulls the image. A.registry-tokenin the archive (non-empty, or the release is refused) is never installed: the pull logs in with it under aDOCKER_CONFIGin the run's temporary folder, removed with it, so the host's own Docker login is neither used nor changed; - with a database: starts Postgres, runs
backup(--keep=7 --max-age-days=30), copies the migrations out of the new image forschema-guard, and savesrow-counts(all throughnpx @softure-ai/deploy@<this version>on the host, or through the helper image on a host without Node, below; against127.0.0.1) fordatabase.rowCountTablesof thedeploy.jsonthis release shipped (releases/<tag>/deploy.json); without that file or key, and on the first release, the counts are skipped. The count before the switch runs against the old schema, so a tabl
- refuses anything but a Docker tag; reads at most 16 MiB, refuses an archive with anything but files and folders,
a path outside it or a name the server keeps (
