npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 help

softure-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 as ADMIN_EMAILS reaches 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_SECRETS reads the variable APP_SECRETS as 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's PATH or HOME never 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_VARS puts 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 of AUTH_SECRET has 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 not NAME=<1-9999>, is a usage error (exit 2). An optional name left unset is not checked. deploy-app.yml passes them from its secret-min-lengths input.
  • 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: --from defaults to the nearest tag before --to that matches --match (a git glob, default *; v* or app@* when the repository has other tags). With no earlier tag the report starts at the first commit.
  • Source: git log --first-parent only, 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.
  • Links: --repo-url, else GITHUB_SERVER_URL/GITHUB_REPOSITORY inside GitHub Actions, else none (plain #N and short SHAs).
  • Copy: the en dictionary (deployMessages, English is the only built-in language), exported for apps that format the report themselves.
  • Roadmap items: --roadmap=<file> adds a ### Roadmap items table after the summary: the rows of the SOFTURE roadmap's ## At a glance table whose status is done_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.md while it exists, else the roadmaps archived to context/foundation/archive/<YYYY-MM-DD>[-N]-roadmap.md since 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 --out or stdout, ready for gh 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: --summary is the deploy-report.json the summary job of deploy-app.yml uploads (version 1: tag, environment, image, digest, run URL, end time in UTC, each job's result and the server's step|…/result|… lines). Another shape fails with the field's name. --body is 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, or failed at <step> from the server's result|failed|<step>|…), environment, image and digest, the backup file and the row counts before → after (? when the server stopped before counting again, not counted on 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 edit then 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.json

softure-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 with pg_restore) into <dir>/<prefix>-<UTC yyyymmddThhmmssZ>.dump.
  • The password stays out of the process list: the URL is split into libpq variables (PGHOST, PGPASSWORD, …) for pg_dump, and other PG* 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 0600 through a temporary file and a rename; a failed pg_dump leaves no file.
  • Integrity: the new file must start with the custom format's PGDMP header; otherwise it is removed and the step fails, before any retention.
  • Retention runs only after a dump succeeded: the newest --keep dumps of --prefix stay, older ones of that prefix are removed and named in the output. With --max-age-days=<n> a dump whose name stamp is older than n days is removed too (one exactly n days 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_log keeps 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_dump must 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 dump pg_dump wrote 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 that pg_dump, so --url-env, --pg-dump and --exclude-table-data with --stdin are 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/db for 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 (table or schema.table, lower snake case; the app's key tables, like users, orders, invoices) and prints one line per table.
  • Which tables: --tables, or else database.rowCountTables of deploy.json (--config names another file), so the list lives with the app's other deploy settings. With --tables the file is not read; passing both flags is a usage error, and so is having neither the flag nor the list (exit 2). An invalid file is exit 1 with its issues.
  • A listed table the database lacks prints absent and is not an error, so a table can join the list in the release whose migration creates it.
  • --out saves the counts as JSON ({ "takenAt", "counts" }, an absent table as null); --compare reads such a file and prints before -> after (delta), or absent -> <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, also absent -> absent for 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 line psql -At printed for the statement of row-counts --print-query with 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 expected status (default 200), contains and excludes body markers, a redirect target (a path or an absolute URL; needs a 3xx status) and headers.
  • The request: method (default GET; also HEAD, POST, PUT, PATCH, DELETE, OPTIONS), body (not with GET or HEAD) and requestHeaders, sent on top of verify's own: an unauthenticated POST that must answer 401, a bot's user-agent that must get the page and not a CDN challenge, accept: text/markdown. host, content-length, connection and transfer-encoding are 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-type header 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 as WARN and does not fail the run (a probe to report, not to block a release); verify.originSeverity: "warn" does the same for the origin row. The summary adds , N warned.
  • Statuses that only warn: "warnOn": { "status": [404] } on a route reads a listed status as a WARN row 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 a 200 text/html login page or the app answers 500. A listed status on a forEach source warns the same way. A warnOn status cannot be the route's own status.
{
  "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" checks contains, excludes and count only between <head> and </head> (with a bot's user-agent in requestHeaders: "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 forEach instead of path. { "sitemap": "/sitemap.xml", "match": "/blog/" } checks it for every <loc> whose path contains match (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's digest with the served bytes; items, url and digest name other keys (defaults skills, url, digest; "digest": null checks 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 (a forEach source and every entry included) the way @softure-ai/agent-ready signs the app's own: RFC 9421, Ed25519, @authority and signature-agent covered, tag web-bot-auth, five minutes of validity. keyEnv names the variable holding the seed (default WEB_BOT_AUTH_PRIVATE_KEY, base64url, what agent-ready web-bot-auth key prints) and agent the Signature-Agent origin whose key directory lists it (default the verified URL's origin). The key never goes in deploy.json or the report: an unset or malformed variable fails the route with the variable's name and sends nothing. requestHeaders cannot set signature, signature-input or signature-agent next to it. Run verify with the variable in its environment; deploy-app.yml and the deploy action pass it through verify-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) runs verify once 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; null means 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.headers applies to every route; a route's entry for the same name wins, the whole list with it.
  • Requests: GET with cache-control: no-cache, redirects not followed, at most --concurrency at once (default 4), each within verify.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), verify opens one TLS connection to the URL's host, reads the certificate without sending a request and adds a tls row: 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 an http:// 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]:port for IPv6), verify opens one TCP connection to it, sends nothing, and adds an origin row. 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 in deploy.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/deploy after changing src/verify/schema.ts). The route list is the app's; the package holds only the engine.
  • The same file holds database.rowCountTables, the tables row-counts compares (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 passed

Deploy 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:

  1. 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);
  2. builds the image from the tag with build-args and pushes <image>:<tag> to GHCR (the only job with packages: write); with prebuilt-image, builds nothing and adds the tag to that image instead (below);
  3. renders .env.prod with softure-deploy env render from the app-secrets JSON and the app-vars JSON over it, and stops when a build argument's name is in .env.prod with 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.prod with the tag's server files (the compose file's folder, server-script as deploy.sh, deploy-config as deploy.json) and the job's own GITHUB_TOKEN as .registry-token into one gzip tar and sends that on stdin to the server's forced SSH command as <remote-command> <tag>, checking the host key against ssh-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 line result|ok (below), so a session cut halfway never reads as a release; the output stays in $RUNNER_TEMP/deploy-output.txt for the job;
  4. waits until <app-url><health-path> answers 200, then runs softure-deploy verify <app-url> with the app's deploy-config read 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 the origin-address secret or origin-address-var, verify gets --origin too. With verify-env, verify gets those variables (below), and only verify does.
  5. whatever happened, uploads the run's facts as the artifact deploy-report (summary job, no permissions): each job's result, the image and its digest, and the server's step|…/result|… lines (init's deploy.sh puts 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:

  1. the build job (deploy: false) checks the inputs, the tag's branch and the image, builds or tags it, and sets the workflow output release: 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). With deploy: false the 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;
  2. each environment gets a job with environment: <name>, permissions: { contents: read, packages: read } and one step, the action with release: ${{ needs.build.outputs.release }}. That job's secrets and vars hold the environment's values over the repository's. The step's env: maps each secret the compose file uses by name (DATABASE_URL: ${{ secrets.DATABASE_URL }}) and secret-names lists those names, so with app-vars: ${{ toJSON(vars) }} they render that environment's .env.prod (switches go in the environment's variables), and the DEPLOY_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:

  1. refuses any other ref, and invalid inputs, with one ::error:: line each;
  2. picks the next free tag <tag-prefix><YYYY.MM.DD> in timezone, then -2, -3, … (existing tags from the API, no checkout);
  3. creates the GitHub Release on the dispatched commit (github.sha), the description above GitHub's generated notes;
  4. runs gh workflow run <deploy-workflow> --ref <tag> -f tag=<tag>. A release created with GITHUB_TOKEN starts no other workflow, so release: published in deploy.yml does not fire and this step is what deploys. The deploy workflow needs a workflow_dispatch input tag, as examples/deploy.yml has.

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

  1. prepare checks the inputs (one ::error:: line each) and resolves the commit the other jobs check out. With a tag, the tag must match tag-pattern (the vYYYY.MM.DD tags deploy-cut-release.yml cuts, -2, -3 on 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.
  2. static installs (install-command) and runs static-command: the type check and lint. A Next app runs next typegen first, since a fresh checkout has no generated route types; init's caller does.
  3. unit installs and runs test-command once per shard, shards jobs in parallel (1 to 20, 60 minutes each), with SHARD_INDEX (from 1) and SHARD_COUNT set; the default npm 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 before

Edit 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:

  • build builds the example app's image (examples/next-app/Dockerfile) without the GHCR login and without a push, and hands it to deploy as the artifact deploy-e2e-image;
  • deploy builds this CLI from a full checkout of the tag instead of running the npm version, and e2e/start-server.sh sets up its own runner (Ubuntu with Docker, cron and flock) as the server: the image goes into a registry on localhost:5000 (the e2e app's image name), the compose file's Docker Hub images come from mirror.gcr.io, a certificate for deploy-e2e.example.com signed by a CA of the run waits in Traefik's ACME store (so Traefik never asks Let's Encrypt) and the name points at 127.0.0.1, and sshd binds fresh keys to the forced command e2e/server/forced-command.sh. The send step runs the production ssh command (host key checked) against it. The forced command first records the command line, the archive's files, their SHA-256, the mode of .env.prod and its names (never a value) with e2e/server/record.sh, uploaded as the artifact deploy-e2e-received, then runs the tag's deploy.sh from /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. Its npx @softure-ai/[email protected] (the e2e app's pinned version, never published) is answered by e2e/server/bin/npx with the CLI built from the tag;
  • verify would run on another runner, away from the stack, so deploy runs its two steps itself: the wait for /api/health and softure-deploy verify https://deploy-e2e.example.com with the e2e app's deploy.json, trusting the run's CA through NODE_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: --domain and --image; --paths, --www, --acme-email, --env (the app's own secrets, added to the app service in the required form so env render renders them), --tables (what row-counts compares on the server, written into deploy.json; with a deploy.json that init keeps, add database.rowCountTables to it by hand), --name (compose project, server folder /srv/<name>, database name; default from package.json) and --workflows-ref (the commit SHA every caller pins, see Which ref callers pin; without it they call master and a warning prints the command that gives the release commit) and --cdn=cloudflare (the origin lock below; cloudflare is the only value) and --report-role (with a database, the Postgres role deploy.sh's report reads 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 --cdn or --acme-email, which belong to that stack).
  • Read from the app: @softure-ai/db in package.json turns on the database part, @softure-ai/ops the /api/health route (else /), @softure-ai/jobs (with @softure-ai/db) the job worker, a public/ folder its COPY; a next.config.* without standalone is a warning, and so is, with a database, one whose serverExternalPackages does not name @softure-ai/db (without it next build cannot resolve the driver the app does not install; see the @softure-ai/db README, §2). An existing docker/prod/docker-compose.yml (kept, or replaced with --force) gives deploy.sh the Postgres major of the postgres service's image (postgres:17-alpine → a tools image with pg_dump 17; otherwise 16), and a kept one also the role of the app service's DATABASE_URL, which report reads as (otherwise softure_app). With a database, a value the file has but init cannot read (a tag such as latest, 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 renders planInitFiles with the same answers (reportRole) gets the same deploy.sh. Nothing is read from softure.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_PASSWORD with a database, plus --env. The caller's deploy step passes each by name (an env: line and its name in secret-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 the production environment. 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.sh and its units cloudflare-only.service and cloudflare-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 chain SOFTURE-CLOUDFLARE with a RETURN per range above a final DROP (the DROP goes in first, so a refill never opens the origin), and jumps to it from DOCKER-USER for TCP whose original destination port is 80 or 443 on the external interface (the default route's; CLOUDFLARE_ONLY_INTERFACE overrides it). Published ports never pass INPUT, so DOCKER-USER is the chain that sees them. IPv6 uses ip6tables' DOCKER-USER when Docker manages ip6tables, else INPUT (where docker-proxy takes IPv6 connections). The .service runs whenever Docker starts, the .path whenever the ranges file changes.
  • docker/prod/hooks/cloudflare-ranges.sh, run as the post-up hook cloudflare-ranges and the maintain hook cloudflare-ranges-daily: downloads https://www.cloudflare.com/ips-v4 and /ips-v6 and rewrites cloudflare-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.trustedIPs on both Traefik entry points, from the ranges this version ships, so X-Forwarded-For is kept from Cloudflare only. The hook warns when Cloudflare publishes a range missing there; copy the new list into the compose file. proxyProtocol stays off: Cloudflare's HTTP proxy does not send it (only Spectrum does).
  • A next line in init's output for the two steps init cannot do: root's install, and the DEPLOY_ORIGIN_IP secret that makes the deploy workflow's verify --origin check 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 traefik service, no letsencrypt volume, and a top-level network edge that is the external <network>; the app service joins edge and default (Postgres and migrate stay on default);
  • 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 rule traefik.yml would hold, entry point websecure, certificate resolver letsencrypt, service port 3000 and the security headers as the middleware <name>-security-headers; with --www a router <name>-www and 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 starts app (the migrate service runs through depends_on) and the worker when there is one; there is no traefik step.

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 …> and status|health|<the app container's health>, each none when there is nothing yet. For the owner: ssh -i <deploy key> <user>@<host> status.
  • deploy <tag> with the release archive on stdin:
    1. 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 into releases/<tag>/ (the newest 5 are kept) and checks the compose file with docker compose config;
    2. saves every installed file the release is about to replace, then moves .env.prod into place (0600) with TAG=<tag> in it (so a docker compose by 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's deploy.json through softure-deploy server-settings (the settings step; an invalid file stops the release before anything restarts) and pulls the image. A .registry-token in the archive (non-empty, or the release is refused) is never installed: the pull logs in with it under a DOCKER_CONFIG in the run's temporary folder, removed with it, so the host's own Docker login is neither used nor changed;
    3. with a database: starts Postgres, runs backup (--keep=7 --max-age-days=30), copies the migrations out of the new image for schema-guard, and saves row-counts (all through npx @softure-ai/deploy@<this version> on the host, or through the helper image on a host without Node, below; against 127.0.0.1) for database.rowCountTables of the deploy.json this 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