Hop3 0.8 release notes¶
This page holds the full notes for each 0.8 release, newest first. The changelog gives each release's date and a summary.
0.8.1¶
Fixed¶
- A deploy over HTTPS accepts an archive up to 200 MB. The admin domain's nginx vhost set no
client_max_body_size, so nginx refused any archive over the 1 MB default with a 413, before it reached hop3-server, which accepts 200 MB. The CLI then reported a limit of 200 MB. The installer now sets 200 MB in/etc/nginx/conf.d/hop3-http.conf, a file each update reconciles. When nginx refuses an archive, the CLI now names nginx as the cause. Deploys through an SSH tunnel never passed through nginx. hop3 app restartapplies a changed environment. It touched the app's uWSGI configuration, so the app came back with the values of the last deploy, whilehop3 env setpromised that a restart would apply the new ones. A restart is now a stop and a fresh start from the current settings. A Docker app picks up a changed environment only at a deploy;env setnow tells you so for one.- Upgrading with the install one-liner migrates the database. Only
hop3-deploy-serverranhop3-server db:upgrade. The one-liner restarted hop3-server on the new code against the old schema. The installer now migrates before it restarts the server and aborts when the migration fails;--skip-migrationsskips the step. A server upgraded to 0.8.0 with the one-liner is migrated by the next install. hop3 backup restorepoints at the restored app's status. It told you to start the app withhop3 restart <app>, a spelling 0.8 retired, although the restore had already redeployed it.- A failed deploy under
--jsonprints the error. The CLI exited before it wrote the JSON it had collected, so stdout was empty. - The installer's setup step aborts the install when it fails. The installer ignored the setup command's exit code.
- The deployer and the server backup and restore scripts no longer call sudo. They run commands as
hop3orpostgreswithsuorrunuser, as the installer does. - The dashboard's live logs stream. The stream opened the app's log directory as a file and failed on every connection, while the page showed "Live". It now tails each worker's log file. Streamed lines are shown as text: they were inserted as HTML, so a log line an app wrote from a client's request could run script in the operator's signed-in session. The log search and level filter now work. Pause keeps new lines back until you resume.
- The dashboard fixes the WCAG 2.2 AA failures an accessibility audit found. The menu opens on a phone or a zoomed screen, where the navigation and Sign out were unreachable. Every field and icon button has a name; text, field borders and app icons have enough contrast; status changes and copy results are announced; certificate expiry is no longer shown by colour only; breadcrumbs use the navigation's names; the mobile category picker no longer navigates on selection. The help text shows the current commands (
hop3 env set --app,hop3 deploy --app), and the resource-tier legend shows the tiers' actual names and limits. HOP3_UNSAFEinhop3-server.tomlmeets the same safety check as the environment variable. The check that requiresHOP3_UNSAFE_ACKand forces the bypass off in production read only the environment, while the authentication guards also read the file:HOP3_UNSAFE = truethere disabled authentication with neither safeguard. A value that is not a valid boolean (yes,on) is now refused at startup, where the check accepted it and the guards rejected it.- The installer and the deployer no longer act as root on files the hop3 user controls. The installer read and rewrote hop3's
.bashrcand~/.config/nix/nix.confas root, following symlinks, so an app (which runs as hop3) could point that file at a system file and have root rewrite it and give it to hop3 on the next install. Those files are now read and written as hop3.hop3-deploy-serverran hop3's own venv Python as root to reload nginx and read versions. It also ran the venv'spip, andgitin hop3's checkout, as root on every update. All of them now run as hop3, which hop3-rootd admits for the reload. The server backup and restore scripts no longer run the venv's Python as root. - hop3-rootd runs from a venv root owns. The daemon runs as root but ran from hop3's venv, which hop3, and so any app, can write: code put there ran as root at the next restart. It now lives in
/opt/hop3-rootd, owned by root and made from a root-owned interpreter, and is installed there as root from PyPI, git, or an uploaded tree the installer first checks only root can change. The next install orhop3-deploy-serverupdate moves an existing server over: it creates the venv, installs the daemon, points the systemd unit (or the supervisor program) at it and restarts it, and removes the copy left in hop3's venv. The installer also editshop3-server.toml, the pip configuration and the build record as hop3. - A dashboard change that was not saved says so. A handler that answered without committing reported success while its database changes were rolled back after the response. The server now returns an error naming the problem. A handler that fails after writing rolls back cleanly. The Test Lab does the same.
- Errors keep their meaning across the RPC boundary. The server re-wrapped every command failure as an invalid-parameter error (-32602); a full disk or a failed subprocess now arrives as an internal error (-32603) with its Diagnosis. The CLI exits 1 for these, refined by the message (an unknown app exits 3), where it used to exit 2 or 7.
hop3 addon destroyno longer reports success when the database server is down. It reported that the addon did not exist and left the database. It now aborts and destroys nothing.addon detachis refused when the addon's credentials cannot be read, instead of deleting the credential and leaving the variables behind, and a deploy aborts when an attached addon's credentials cannot be decrypted, instead of starting the app without them.- Two PostgreSQL addons can be created at the same time. Hop3's admin connections used
template1, the databaseCREATE DATABASEcopies, which PostgreSQL refuses to copy while any other session is connected to it; two apps provisioning a database at once (parallel builds) failed at random withsource database "template1" is being accessed by other users. Admin connections now use thepostgresmaintenance database. - Two addons can no longer share a database or a user. PostgreSQL maps
-to_and MySQL cuts user names at 32 characters, soa-banda_b, or two long MySQL names, landed on one database or one user, and creating the second took over the first. A new name that would collide is refused. PostgreSQL also refuses to adopt a leftover database that contains data, as MySQL does. Existing addons are not renamed. - Destroy reports what it could not remove. A Docker daemon that did not answer, a firewall rule, WAF rules or a cgroup left behind were reported as a clean teardown.
hop3 app destroynow lists each leftover and exits non-zero. - Destroying an app no longer removes another app's containers. The orphan cleanup that runs on every destroy matched containers by name, a filter Docker applies to any part of a name: destroying
appremovedmyapp-web-1. It now matches the app's Compose project exactly. - Destroy removes read-only directories. Go makes the module cache, which a build puts in the app's
HOME, read-only, and destroy left it behind withPermission denied. It now makes each directory writable before emptying it. - A refused or unanswered deploy no longer marks a running app failed. A deploy refused because the build queue was full, or a status check that could not read the app's artifact, set the app to FAILED while it kept serving. The reason is now recorded and the state kept; a start that times out still fails, with the reason.
- An open log page no longer occupies a database connection. Each live log stream kept one for as long as the page stayed open, so about fifteen open pages blocked every other request, sign-in included.
- An unreadable build artifact is an error. A damaged
BUILD_ARTIFACT.jsonwas read as "never built". The app then ran with the platform's defaults instead of what the build declared. It is now refused with the file's name and a hint to redeploy. hop3-testfails a test whose teardown left anything behind. The leftover was printed and the test reported PASS, though it blocks the next deploy of the name.- A backup restore deploys the whole app. Restore deployed without a database session, so it silently skipped addon provisioning, the generated admin account and the smoke test.
- Apps with more than one language build. A Django, Rails or Laravel app with a
package.jsonbuilt fully and then found no deployer. The toolchains now produce one artifact: the toolchain that serves the app runs it, and the others contribute their build steps and environment. - An app with both a
Dockerfileand ahop3.nixmust name its builder. It built with Docker without saying so. The deploy now aborts and asks for[build].builder; the local builder still yields to Docker or Nix when they apply. An unknown[build].toolchainis an error listing the valid names. A build withuv.lockaborts instead of falling back to pip when uv is unavailable. - The installer provisions uv 0.13.4 for the hop3 user in every install mode, so a build with
uv.lockno longer downloads uv at build time. - The install one-liner runs on Python 3.10 again. It used Python 3.11 features and crashed on Ubuntu 22.04's
python3. - The installer aborts on a command that times out. A hung command was treated as a success. A redeploy reuses the server's secrets unless their files are absent; a permission error now aborts instead of generating new ones. Nix's configuration and daemon are reconciled on every install, the deployer reloads nginx through hop3-rootd's verified reload, and
hop3-rootd's health report shows which startup reconciles were skipped or failed. - The dashboard no longer freezes the server. Opening a backup checksummed the archive on the server's only event loop. Every sign-in ran bcrypt there too. Database, file and subprocess work now runs in a thread. The application status poll no longer writes to the database, the dashboard refuses app names the CLI refuses, a catalog install that is refused shows the reason on the app's page, links can no longer put text of their choice on a dashboard page, and the backups page pages through every backup instead of showing the first 100.
- A busy server says so. A long command (a backup, a restore) used to hold every other RPC, sign-in included, without a timeout. Waiting callers now get "server busy" naming the running command after 30 seconds; the CLI exits 6.
- The CLI refuses what it cannot parse. A malformed
hop3.tomlis an error naming the file, where it used to be read as empty (dropping[build].ignore). Unknown flags tologinandinitare usage errors, and-dand-yare now passed to them.hop3 usetakes--appand exits 2 on a usage error. - Nginx response caching works. The cache configuration failed to render on every server and the failure was logged and ignored, so caching never took effect. An invalid cache setting now fails the deploy with the value it rejected.
- External plugins load as documented. The plugin guide named the entry-point group
hop3.pluginswhile the server loadshop3; a plugin following it never loaded. The guide is fixed, a distribution that declares the old group stops the server with its name, and the guide no longer recommends an import that registered a plugin twice. hop3 logintrusts a certificate you pinned withhop3 settings set ssl_cert. Its check of the server's HTTPS address used only the system certificates, so a server with its own certificate kept the SSH tunnel at every login, and a login with--urlrefused it while telling you to pin the certificate and retry. Every later command already honoured the pin. A--urlthe CLI cannot connect to now fails the login instead of being saved with a warning.- Destroying a Docker Compose app removes its volumes and networks, or says it could not. When
compose downfailed or timed out, the app's volumes stayed on disk while destroy reported a clean teardown. addon destroyfinishes a create that failed halfway. A PostgreSQL role or MySQL user left without its database counted as no addon, so destroy reported nothing to do and left it. MySQL's destroy also reports a user it could not drop instead of ignoring the error.- An upgrade that fails partway leaves hop3-rootd able to restart. The installer removed the old daemon from hop3's venv before pointing its unit at
/opt/hop3-rootd, so a failure in between left the unit naming a deleted binary. The old copy now goes once the new daemon is up. A daemon that cannot create its initial state now aborts the install, where the installer used to create one itself. - The installer checks that nginx took a reload on hosts without systemd, where a rejected configuration used to be reported as reloaded.
- A deploy aborts when its hostname would serve nothing. An app with a hostname but no web or static worker was warned about, then published a 502.
- The backup script reports hop3-rootd's version and gives a rollback that works with the daemon in
/opt/hop3-rootd, and accesses hop3's database as hop3, not as root. hop3 loginrefuses a token in the URL wherever it appears on the line, never printing it back, and the local commands refuse a flag given twice or a flag taken as another's value.- An oversized deploy refused by nginx gets advice that fits: update the server when the archive is under its 200 MB limit, shrink it otherwise.
- The dashboard's log view reads at most 256 KiB of a new or rotated log file, and says how much it skipped.
- The server install aborts when it can't install the
hop3client. A missing client source was only a warning: the server came up without the client thathop3 loginrelies on. A pre-release install now takes the pre-release client, as it does the server and hop3-rootd. - The CLI installer checks for
venvandensurepipbefore creating its environment. The bundled one-liner always passed the check, so a Debian system withoutpython3-venvfailed later, with Python's own error.
Changed¶
hop3 login --tokenreads the token from stdin (--token -), a file (--token @path) or a hidden prompt (--token). A token on the command line lands in shell history and inps. The literal form still works, with a warning, and is no longer documented.hop3-tui --token, the admin commands andhop3-deploy-serveraccept the same forms.- The JSON-RPC response includes a
Hop3-RPC-Protocolheader. A client that sendsparams.protocolwith another version is refused with an error saying which side to upgrade. - Apps may be named
admin,api,app,apps,dashboard,hop3,staticorwww. The catalog install and the dashboard refused these names whilehop3 app create,hop3 deployandgit pushaccepted them. Nothing in the platform collides with an app of one of these names (vhosts, files, hostnames and routes are all per app), so the list is gone and every path applies the same name rule.
0.8.0¶
Highlights¶
- Accounts have roles.
[access]puts an app behind a Hop3 sign-in (ADR 061, ADR 062). - An app keeps its state across a redeploy. Volumes are linked after the build and keep what the app wrote before declaring them; a
scratchvolume is for what must not be kept; each app has aHOMEandTMPDIRof its own. All recipes in the published catalog pass a deploy and a redeploy, installed from the published catalog. - App code can no longer reach root through hop3-rootd. An app's secrets are also off the command line, where other users could read them.
- Caddy and Traefik serve apps, PHP apps included where the proxy can.
- A Nix-built PHP app runs under php-fpm, as a native one does since 0.7.5. A generated Nix app runs its setup once per deploy, before it starts.
- The catalog has a versioned address. Each entry names the server it needs.
- The installer aborts on a failure with the command's output, where it used to print a warning and report success.
- Each checkout names the server it deploys to. Commands print where they go. A context is a server, and
hop3 context usebinds a checkout to one; a production server accepts changes only when named on the command line (ADR 042).
Upgrading from 0.7¶
- Redeploy each native app once. Two fixes take effect only at the next deploy: an app's environment moves off the command line; a PHP app's hidden files are no longer served.
hop3 app restartreuses the old configuration. - Accounts get roles. Admins stay admins and the other accounts become deployers, which is what they could already do. A new account is a
memberuntil an admin grants it more. A command above an account's role is refused with exit code 5. - The catalog moves to
https://apps.hop3.cloud/catalog/v1/catalog.tar.gz. Move aCATALOG_SOURCE_URLset to the old path. Servers before 0.8 keep fetching the old path, which serves the same catalog, and are refused the entries that need 0.8, by name, before anything is created. - On a Docker install, the uWSGI emperor is
uwsgi-hop3under supervisor:supervisorctl restart uwsgi-hop3. - Generated Nix recipes build against nixos-26.05. Dependency hashes recorded against the old pin may need recording again; all
node-deps-hashvalues do. Ago-frontend-source-rootthat includes the archive's top-level directory is refused. - Some commands take different arguments:
hop3 cert renew --app <app> --force-renewal,hop3 nix eject --app <app> > hop3.nix, and a command missing an argument now exits 2. - Take a new backup of each app with an S3 addon. An S3 backup taken before 0.8 contains no objects. Restoring one is now refused, where it used to report success and restore nothing.
- Remove the
hop3-harnessandtutorial-runneraccounts from any serverhop3-testran against: earlier versions created them, with their usernames as passwords. - Bind each project checkout to a context once:
hop3 context use <name>. Inside a directory with ahop3.toml, a command that contacts a server now uses the checkout's binding,-corHOP3_CONTEXT, and is refused without one; the global current context applies only outside a project. After a plainhop3 login, the context is nameddefault. Move anyserverorbootstrapkey out of ahop3.toml[contexts.*]block intohop3 context add <name> --server <address>: the block now contains onlyapp,domainsandenv, and both the CLI and the server refuse the old keys. On a production server, setPROTECTED = trueinhop3-server.toml. - Apps still share one Unix user. One app can read another's data, and the control plane's, until per-app users land in 0.9.
Security¶
- An app can no longer call hop3-rootd. Native apps all run as
hop3, the uid rootd admits, so any app's code could ask the privileged helper for anything: root, with no exploit. rootd now also refuses a caller running inside the app runtime: theuwsgi-hop3service's cgroup orhop3.sliceunder systemd, and descendants of the uWSGI Emperor on a host without systemd (weaker: a process that double-forks escapes it). Apps still share one user, so one app can read another's data until per-app users land (planned for 0.9). - An app's environment is no longer visible to other users on the server. Background workers, php-fpm pools and apps started with a start command received their
[env]asexportstatements on a command line that stayed visible topsfor as long as the app ran. Any local account could read the secrets of all apps that way, including the web server's and the database's. The environment now goes to a file readable only by the app's owner. The fix applies from the app's next deploy;hop3 app restartreuses the old configuration, so redeploy each native app, or stop and start it. - A PHP app's hidden files are no longer served. nginx served any file under a PHP app's document root with a name starting with a dot, such as
.envor.htaccess, apart from.git. nginx and Caddy now answer them with 404, except/.well-known/. The fix applies from the app's next deploy. - Database passwords no longer appear on a command line. The installer set the PostgreSQL superuser password with
psql -c "ALTER USER ... PASSWORD '...'"and created the MySQLhop3user withmysql -e, both readable by any local user in/proc/<pid>/cmdlinewhile they ran; the SQL now goes on stdin. - Scaling a process type down removes that worker's copy of the app's secrets. Each worker's environment is a
0600file; a removed worker's file stayed on disk, stale after any later rotation. - A gated app's nginx include file cannot forward around the gate. The gate replaces the client's
X-Hop3-User,X-Hop3-RoleandCookiein the locations Hop3 generates; a location inNGINX_INCLUDE_FILEwith aproxy_passof its own inherited none of that and forwarded whatever identity the client sent. Such an include is now refused on an app with[access]. - A server installs no dependency released in the last seven days. A compromised package is usually caught and yanked within days. An install resolving the latest version would take it the hour it appears. The installer puts the cutoff in the server venv's
pip.conf, so upgrades and developer deploys resolve under it too. Hop3's packages are exempt, so a release can be installed the day it ships. Builds of apps are unaffected. hop3andhop3-tuirefuse a malformed API token without printing it. A token containing a line break (two tokens read from one file) made the HTTP library reject theAuthorizationheader with an error that quoted it, and the client printed that error, token included.- The test harness no longer creates admin accounts. It created
hop3-harnessandtutorial-runnerwith their usernames as passwords, published in the repository, and on a--hostserver they outlived the run. It now obtains a token fromadmin:ssh-token, ashop3 login --sshdoes. Servers the harness ran against should have those two accounts removed. - The test harness no longer replaces a server's mail setup. It switched any server to the
catchbackend for apps that declare email and left it there; it now does so only when no backend, orcatch, is configured, and refuses otherwise. - The tutorial runner no longer puts the admin token on a command line. On a remote target it passed
HOP3_API_TOKEN=<token>in the shell line, readable by any local user while validoc ran; the token now stays in a0600file on the server, shared with the harness's other admin steps.
Accounts and access¶
- Accounts have roles:
admin,deployer,viewerandmember.hop3 user set-role alice viewergrants one;hop3 user add --rolesets it at creation. A viewer reads apps, addons, backups and certificates but no secret. A deployer deploys and operates apps and addons. An admin also manages accounts and the server. A member signs in to the web interface and sees a profile page. A deployer's builds run with the control plane's privileges, so treat that role as an administrator credential. (ADR 061) - The server checks the account's role on each command and dashboard page. Until now any account could run everything but user management. A command above the account's role is refused with the role it needs and the
hop3 user set-rolethat would grant it, andhop3exits with code 5. The dashboard refuses a page the same way and sends a member to the profile page. - A new account is a
memberuntil an admin grants it more. Upgrading keeps admins as admins and makes the other accounts deployers, which is what they could already do. A member gets no API token. [access]puts an app behind a Hop3 sign-in. Withrequire = "auth", a visitor without a session is sent to the sign-in on the admin domain and back to the page they asked for.rolesets the lowest role admitted;public-pathsare open to anyone, for webhooks and health checks. The app learns who is calling fromX-Hop3-UserandX-Hop3-Role, which a client cannot forge, and never receives the gate's cookie. A sign-in link works only in the browser that started it, so no one can sign a visitor in to their own account. One sign-in covers all gated apps, and signing out of Hop3 signs out of all of them. The gate works on nginx, Caddy and Traefik, but the installer routes the admin domain, where the sign-in lives, on nginx only, so a server installed with Caddy or Traefik cannot use it yet. (ADR 062)- A token for a deleted or disabled account is rejected on its next call.
hop3andhop3-tuinow show the server's reason, where they used to print only "authentication required" or an HTTP status. hop3 login --tokenchecks the token it was given. The check sent whatever the CLI already had stored for that server, orHOP3_API_TOKEN, so a new token for a server with an expired old one was refused with "Authentication required" without ever being sent. Allhop3-deploy-server --docker --cleanruns hit this, since the new container signs with a new key.- A 401 names the case in the error data (
error.data.kind:unauthenticated,account_unusable). The CLI and TUI read that, where they used to read the message's wording.
Apps and deploys¶
[build].buildruns once. The toolchain runs it in place of the toolchain's install. The deploy then ran it a second time. A Docker or Nix app declaring it is refused, since those builders run no toolchain;before-buildandafter-buildrun for all builders.[[volumes]]has ascratchtype, for directories an app writes that must not be kept: a framework's compiled templates or bootstrap cache. It lives outsidesrc/, is reset to the build's output whenever a deploy replacessrc/, and is excluded from backups.- The deploy that first declares a volume keeps what the app had already written there. Declaring
[[volumes]]for data an app kept insrc/used to delete that data: the deploy replacedsrc/before linking the new, empty volume. The old tree is now set aside until the volumes are linked. An empty volume is filled from it first, then from what the build produced. - Volumes are linked after the build, before the app starts. They used to be linked before the build, where a
before-buildthat unpacks the application and moves directories into place collided with them. - Each app runs with a
HOMEandTMPDIRof its own,<app>/home/and<app>/tmp/, where they were the server's/home/hop3and/tmp. All apps shared one~/.ssh, so Gitea and Forgejo could rewrite theauthorized_keyscontaining Hop3's git-push keys, and app caches landed among the server's files. This covers the workers,before-run, the[admin]and[probe]createcommands,hop3 runandhop3 app shell. Thecreatecommands also stop inheriting the server's whole environment: they get the workers' environment, including its precedence ([env]does not override a toolchain-owned variable). Build steps keep the server'sHOMEfor now, where the toolchains and their caches live. [proxy] denylists paths the proxy blocks with a 404.deny = ["/data"]blocks/dataand everything beneath it before the app or the disk sees the request. A PHP script there is refused without running. It is for data an app keeps inside the served tree, which upstream protects with an.htaccessthat nginx and Caddy ignore. Traefik cannot block a path, so a deploy that declares one is refused there.- A
git pushshuts down the running app before replacing the source, ashop3 deployalready did. A process still holding files insrc/could race the rebuild. - A redeploy without new source builds from the source the app was delivered with.
hop3 app upgrade, a backup restore andhop3 app scaleran the build again on the previous build's output, so a recipe that downloads and unpacks an application met the leftovers: LimeSurvey's upgrade failed withmv: cannot overwrite './vendor', and so did the rollback. Each deploy keeps the tree it delivered (src.delivered). Builds start from a copy of it, and backups include it. An app deployed by an earlier version keeps building in place, with a notice, until the next deploy. - A redeploy after a failed deploy works the first time. After a deploy failed while setting up the proxy, the next deploy of the same app failed once with
Cannot transition app from STOPPED to RUNNING, then succeeded. A failed deploy now marks the appFAILEDwith the error behind it, where the app stayedSTARTING. The request no longer races the build over the app's state. - A build command waiting for input fails at once, where it used to hang the deploy. Build steps inherited the server's stdin, which under supervisor is a pipe with no writer:
unzipasking whether to replace a file held a Vikunja upgrade until the client gave up. Build, before-run, admin and probe commands now read from/dev/null. - Two deploy steps no longer go on past a failure. Both failures were logged while the deploy reported success. One was a served tree the web server could not be granted read on, so all assets answered 403. The other was a volume that could not be given to the app's user, so the app could not write to it. Both now fail the deploy, naming the path and the error.
- An
[env]value that is only$NAMEis refused, like${NAME}already was:[env]is exported literally, soNITRO_PORT = "$PORT"handed Nuxt the text$PORTand it died on startup. The message names the section that works ([run].startfor the port,[env.computed]with${NAME}otherwise). A$inside a longer value stays legal. - The
hop3.tomlJSON schema lists the[access] rolevalues, so an editor completes and checks them. - Ruby apps with a lockfile written by Bundler 4 build again. Hop3 installed gems with
bundle install --frozen; Bundler switches to the version that wrote the lockfile. Bundler 4 removed--frozen, so all such apps failed to build. Frozen mode is now set throughBUNDLE_FROZEN, which both read. An app without aGemfile.lockis refused with the command that makes one. hop3-fetchresumes a download cut short. A retry used to start again from the first byte, so a server that drops long transfers failed all attempts: releases.wikimedia.org drops a transfer after about 75 seconds, and cut mediawiki's 116 MB tarball six times running, each time between 47 and 80 MB. A retry now requests the rest with aRangeheader and checks the reassembled length against the total the server declares. Only an attempt that brings no new bytes counts against the six allowed, so a slow link that advances a few MB per attempt finishes. The resume names the version it continues (If-Range), so a file replaced upstream between attempts is downloaded again from zero. A connection reset mid-body is retried like a timeout, where it used to end the build with a traceback.hop3 addon exposeand an app's[[ports]]work on an upgraded server. The migrations that created the port, network and ban tables gave them aBIGINTid, which SQLite does not assign on insert, so inserts into them failed on a server that had run those migrations.addon exposereported each attempt as a port taken by a concurrent operation, and a deploy with[[ports]]blamed another app. A migration rebuilds the three tables and keeps their rows. Other database errors are now reported as they are.- An app with a runtime that has no deployer is reported as an error. Its status read as stopped:
hop3 statuscalled the running app CRASHED. An app being stopped was marked stopped while it still ran. The state sync now marks that app FAILED with the reason and keeps syncing the others.
PHP apps¶
- A Nix-built PHP app is served by php-fpm, as a native one is. The
php-apptemplate served it through PHP's single-threaded built-in server, where an app that requests its own URL deadlocks against the only worker;serve-mode = "fpm"is now the default, andbuiltinandartisanremain for a recipe that requests them. A hand-writtenhop3.nixasks for the pool with aphp_fpmentry inruntime.json: Hop3 generates the pool, runs it, and has the proxy speak FastCGI to it, refusing hidden files and the recipe's[proxy] denypaths. - A PHP document root must lie inside the application.
[build].document-root = "..", or a Nix app'sweb-root, served the directory above it (or crashed the Nix build); both are refused with the key that set them.
Nix builds¶
- A generated Nix recipe can fetch source from GitHub by repository:
github = "OWNER/REPO",revandhashin[nix]. The hash is of the unpacked tree, which GitHub keeps stable, where the bytes of GitHub's generated archives change without notice; aurlpointing at a GitHub/archive/link is now refused with the command that prints the hash. ruby-bundlerbuilds gems with a Rust extension.gem-cargo-hashes = { commonmarker = "sha256-…" }in[nix]vendors the gem's crates before the offline build, which Redmine 6 needs.- A generated Nix app writes config files and runs
pre-execonce per deploy, before it starts. They used to run in the wrapper on each start: inside the start timeout, which BookStack's migrations outlasted, and as files the running app wrote intosrc/. They now run as the build's before-run step, ahead of[run] before-run, and a failure fails the deploy. Ahop3.nixwritten by the recipe author gets the same with abefore_runlist inruntime.json. Likewise,writable-home-at-runtimenow copies the package intosrc/at deploy time, as the writable tree, where the wrapper used to copy it intosrc/.<name>-homeon first start;$PWD, not$HOME_DIR, is where it lives. - Generated Nix recipes build against nixos-26.05, from nixos-24.11, with pnpm 10 as the default pnpm. Dependency hashes from the old pin may need recording again, from the
got:line of a failed build; a recipe that pins anixpkgs-revkeeps that pin. go-frontend-source-rootnames a directory in the repository (frontend), found under whatever directory the source unpacks to. It used to include the release archive's top-level directory, which changes with the fetcher; that form is now refused with the fix in the message.hop3 app upgradeworks for an app built from a[nix]template. The generated expression was written into the source tree ashop3.nix, so a second deploy from that tree (an upgrade, and the restore that rolls a failed upgrade back) refused it as "both hop3.nix and a [nix].template". It is now.hop3-generated.nix; ahop3.nixan earlier deploy generated, identical to the template's output, is replaced.- A nix-gen app built from its own directory now builds. The local-source mode of three templates had only ever been parsed. python-venv emitted a wrapper Nix could not parse, node-pnpm-install wrote into a read-only copy of the app, and the pnpm dependency store hashed differently on each fetch because pnpm 10 records the build directory's path in it. Each template now has a fixture under
apps/test-apps-nix-gen/that builds and serves. The pnpm fix changes allnode-deps-hashvalues: directus-nixgen's must be re-recorded. - A Nix recipe built with
node-pnpm-installreports why the install failed, and installs under pnpm 10. The install ran with--reporter=silent, so a failure read "builder failed with exit code 243" and nothing else. It also installed from the read-only fetched store, which pnpm 10 modifies; it now copies the store first, as nixpkgs does. The rebuild of the native packages a recipe declares (node-native-packages) also runs under pnpm 10 now, which skipped them without a word, so the build succeeded and the app failed at start. - A Nix node-gyp addon that never included
<cstdint>builds again. The libstdc++ in the nixos-26.05 pin no longer pulls<cstdint>in through<memory>or<functional>, soisolated-vm5.0.3, which all Directus releases still pin, failed with "'uint32_t' has not been declared". Thenode-pnpm-installtemplate now compiles eachnode-native-packagesaddon with-include cstdint. - A Nix app on a server without Nix names the cause.
[build].builder = "nix"on a server installed without--with nixended withbash: nix-build: command not found; it now names the missing Nix and the install command. - A Nix app on a server without Nix is refused however the builder is chosen. Auto-selection declined it and moved on, so an app with a Procfile next to its
hop3.nixwas built by another builder without a word. - A Nix app's
runtime.jsoncannot declare awebworker alongside a php-fpm pool. The pool replaced it without a word; both together are refused, naming the keys. - nix-gen checks
serve-mode, andstrip-components/executablewithgithub, whenhop3.tomlis read. A typo surfaced deep inside a deploy; the file-only keys were dropped silently. - A nix-gen template that installs one file refuses a GitHub source.
java-war,prebuilt-binaryandnode-prebuiltfailed deep inside the Nix build on agithub = ...tree; they now ask for the release asset by name. - A generated
hop3.nixno longer names the wrong app. The eject hint used[metadata].id, which is not the name an app is deployed under; it now tells you to runhop3 nix ejectin the app's directory.
Proxies¶
- Caddy serves apps. Each deploy on a Caddy server failed validation: Hop3 named each app's config
<app>.caddy, which Caddy parses as JSON. With that fixed, an app that allowed plain HTTP still answered only on port 443, and the client address reached the app asip:port. Caddy now also serves PHP apps through their php-fpm pool. - Traefik serves apps. Its routes named a certificate resolver nothing defined, sent plain HTTP nowhere, routed only an app's first hostname, and told the app all requests were HTTPS. They now use the certificate Hop3 issues, route all hostnames on both ports, and refuse
/.gitas the other proxies do. - A deploy the server's proxy cannot serve is refused before the build. Traefik proxied HTTP to php-fpm and sent static files to an address it cannot open. Caddy and Traefik also routed a
[waf]app around the firewall. Each deploy reported success. They are now refused with the reason. Traefik serves no files and speaks no FastCGI. Only nginx fronts the WAF. The certbot engine is likewise refused on Caddy and Traefik, since Hop3 obtains and renews certificates through nginx; use the self-signed engine there. - A static directory requested without the trailing slash redirects to it. nginx and Caddy served
/docsfromdocs/index.htmlin place, so the page's relative links resolved against/and pointed at the wrong files. Both now redirect/docsto/docs/, with a relativeLocationthat keeps the query string. - nginx accepts any legal hostname. The default
server_names_hash_bucket_size(64) rejected a long name (hop3-tuto-nix-gen-flask.135.181.203.156.sslip.io) depending on which other vhosts were present, so the deploy failednginx -t. The installer sets 256 in/etc/nginx/conf.d/hop3-http.conf. NGINX_INCLUDE_FILEandCADDY_INCLUDE_FILEname a file in the app's repository. They were resolved one directory above it, so a file the app shipped was reported missing unless the path started withsrc/. A missing file ended the deploy with a traceback. A path outside the repository is now refused, and so is a missing file, each with the variable's name.
Backups and restores¶
- A PostgreSQL restore gives back the dump, or fails and changes nothing. The dump was replayed over the live database: CREATE statements failed on the tables already there, while
psqlcontinued and exited 0. The restored database mixed the backup with what it was meant to replace, which is the database an upgrade's rollback left. The dump now loads into a fresh database, in one transaction that ends at the first error, and replaces the live one only once it loaded. - An app with an email addon can be backed up, and so upgraded. The email addon refused to be backed up because it contains no data. That failed all backups of such an app, along with all upgrades, since an upgrade's snapshot is a backup. The backup records the addon by name, without its settings; a restore checks it is still configured, and names the command that re-creates it otherwise.
hop3 addon <type> importloads the dump. It uploaded the dump, staged it in/tmp, and handed it to a restore that reads only from/home/hop3/backups, so it failed for PostgreSQL, MySQL and Redis on each call since 0.7.0. The dump is now staged in the backup tree and removed after the restore.- A PostgreSQL dump without owners restores into a database the app can use. A dump taken with
pg_dump --no-owner, or on another platform, loaded as the admin and left all tables, views, sequences, functions and types owned by the admin: the restore succeeded and the app got "permission denied" on its own data. Whatever the admin is left owning is now given to the app's user before the restored database replaces the live one. - An S3 addon's backup contains the bucket's objects. Restoring it puts them back. The backup was a JSON manifest of the bucket's metadata. The restore read it and discarded it, so
hop3 addon s3 restore,clone,importand an app's restore all reported success and restored no object. The backup is now a tar of the bucket's objects with their content types. The restore reads it whole before touching the bucket, writes back all objects, then deletes the objects absent from the backup. hop3 backup registerrefuses a directory outside the backup tree. A backup registered from elsewhere restored the app's source, data and environment, then halted at the first addon, since addon restores read only from/home/hop3/backups. The app was left half restored.hop3-backup-serversnapshots Garage, andhop3-restore-serverputs it back. The server snapshot left out the S3 service: the config, the metadata and all stored objects. The snapshot now includes them, with a consistent copy of the metadata Garage modifies while running.--only garagerestores them, keeping the replaced tree next to it, and succeeds only once Garage is back. A running but unresponsive Garage fails the backup.hop3-backup-servercopies a stopped Redis's data, and fails on an unresponsive running database. Any unresponsive Redis, PostgreSQL or MySQL was one line, "skip", and a snapshot without data. A stopped Redis's last save is now copied as is; a stopped PostgreSQL or MySQL is named as missing from the snapshot; a running but unresponsive one fails the backup, within a bounded wait.hop3-restore-server --only redissucceeds once Redis is back. On a host without systemd it used to leave Redis running, which then saved over the restored dump.- A MySQL backup no longer logs an access error.
mysqldumpdumps tablespaces by default, which needs the server-wide PROCESS privilege the app's user does not have; it complained on each backup, and on each upgrade's snapshot. Dumps now leave tablespaces out.
Catalog¶
- The catalog is fetched from a path that names its format:
https://apps.hop3.cloud/catalog/v1/catalog.tar.gz. The server reads theformatthe signed index declares and refuses one it does not know, naming it and keeping the catalog it already has; the publisher had written"format": 1since the catalog shipped, and no server read it. A future format is published next to this one, underv2/, so a server limited to format 1 keeps receiving updates. Servers before 0.8 keep fetching the old path, which serves the same catalog, and are refused the entries that need 0.8. ACATALOG_SOURCE_URLset to the old path still works; move it tov1/. - A catalog entry can name the server it needs.
requires_hop3 = "0.8"in an entry'scatalog.tomlmakes an older server refusehop3 catalog installby name, before it creates anything, where it used to stage the recipe and fail in the middle of the build. The publisher now works out what a recipe needs from thehop3.toml(php-fpm, scratch volumes,[proxy] deny,[access], a GitHub Nix source, and others) and from a hand-writtenhop3.nix(a setup step inbefore_run), records the feature names in the signed index, and refuses to publish a recipe with arequires_hop3lower than those features need: Paheko, WordPress, Dolibarr and Kanboard needed php-fpm without declaring it. A server refuses an entry that names a feature it does not have, whatever its version number;requires_hop3is checked only for catalogs published before feature names.
Email¶
- The
catchemail backend has a sink: Mailpit.--with emailinstalls it, pinned by version and SHA-256, and runs it on loopback under the process manager. The installer waits for the SMTP greeting, and selectingcatchchecks the sink; selectingcatchused to point Postfix at a port nothing listened on, report success, and leave all messages deferred.hop3 server email caughtlists the captured mail, andserver email statusnow names the active backend, where it reported no transport forcatchanddirect.hop3-testselectscatchfor a run that includes an app declaring email. The190-flask-emailfixture checks the path end to end: a message sent through the injected relay, found in the sink.
Command line¶
uv tool install hop3installs the client. Thehop3name on PyPI belonged to an unrelated 2024 server (0.1 to 0.2, now yanked) with nohop3command. Itshop3module overwrote hop3-server's in a shared environment. It is now a package with no code that installs hop3-cli and thehop3command.- The SSH tunnel works after
hop3 loginunder a shared ssh connection. WithControlMaster autoandControlPersistin~/.ssh/config, the login's connection stayed open, and the next command's tunnel handed the forward to it and exited. The command then failed with "ssh exited before the tunnel was ready", while the forward stayed open outside the CLI's control. The tunnel now always opens a separate connection. - A context is a server. A checkout is bound to one (ADR 042). One word used to name both a server connection (in
config.toml) and a deploy environment (inhop3.toml), sohop3 contextbehaved differently in each directory and three functions disagreed on which context was current. Now: - A context lives only in
config.toml: a name, aserverand an optionalbootstraproute.hop3 context add,removeandrenameedit nothing else, andaddrefuses--app,--domainand--env. hop3 context use <name>binds the checkout inside a project (.hop3-local.toml, gitignored) and sets the global current context outside one, or with--global. It asks first, naming the scope, the file and the address;--yesskips the question.hop3 context unsetremoves either.- A command is sent, in order, where
-c,HOP3_CONTEXT(orHOP3_API_URL), the checkout's binding or, outside a project, the global current context points. A project with no binding is refused, and so is a name that matches no context, whichever step supplied it: a removed context named byHOP3_CONTEXTused to send the command to the default server. - Commands that contact a server print the destination and the reason on stderr:
→ demo (https://demo.example.com), bound in this checkout.--whyshows the whole trace, the context's included. [contexts.<name>]inhop3.tomlsets what differs per deployment (app,domains,env), keyed by context name. A context matching none of a project's blocks is refused; a lone block is no longer selected by default.- A login records its context, and makes it the global current one only when none is set; it used to switch it on each login. Running
hop3 login --context <name>with no target signs in again over the SSH route the context records. - A protected server accepts changes only when named for them. With
PROTECTED = trueinhop3-server.toml, a command above the viewer role is refused unless-corHOP3_CONTEXTchose the server. The operator then types the app's name to confirm (--yesskips the prompt, for CI). The server enforces it from what the CLI reports, so a stale client configuration cannot get around it; a CLI older than 0.8 is refused such changes, and so is the TUI, which does not prompt for the confirmation yet. hop3 settings set serverandset tokenare refused, naminghop3 context addandhop3 login: no command read the keys they wrote.hop3 init --context <name>records the server under that name, as the help now states.- A command missing an argument fails, with the usage. It used to print the usage and exit 0, so
hop3 addon postgres export > dump.sqlwithout a name wrote "Usage: ..." into the dump and reported success. It now prints the usage from the command's help on stderr and exits 2. hop3passes each command the flags its help documents. The CLI refused--appforaddon list,addon promote,backup list,app createandcert renew, soaddon promotecould not be run at all. The CLI splitps scaletoo, so the server ranpsinstead. It also consumed--dry-runfor all commands butdeploy, with a warning, sosystem cleanup --dry-run,admin reencrypt-credentials --dry-runandapp migrate --dry-runran for real; it now hands the flag on, and a command without a preview refuses it.system info -vlists plugins again andsystem status --quietprints one line. A test now checks the flags each command reads, the examples in its help and the command lines in the guides and tutorials against what the CLI sends.hop3 addon show,status,destroy,attachanddetachread an addon's type from the name, likeaddon exposeandaddon promote. Without--typethey assumed postgres, so a Redis addon was looked up as a database that did not exist. A name two types share is refused until--typedisambiguates it.hop3 addon statusworks. It failed on each call, reading a type option it did not declare.hop3 cert renewtakes--app <app>and--force-renewal, in place of a positional app name and--force, which the CLI consumes:--forceskipped a confirmation without reaching the server, so a forced renewal renewed only the certificates already due.hop3 nix eject --app <app> > hop3.nixprints the generated expression. It wrote the file into the server's copy of the source, which the next deploy replaces. Save it in your project and remove the[nix]section fromhop3.toml: a build refuses an app that has both, which the command used to claim it would ignore.hop3 nix ejectejects. All invocations printed the command's help and exited.- A slow or failing server is no longer reported as a missing credential. Before a destructive command, the CLI checks the credential with the server, and any failure of that check, a timeout included, became "No credential for ...", exit 4, and the advice to log in again: a destroy sent while the server was still busy with an upgrade reported exactly that. Only a refused credential is now an authentication error; a timeout or a server error is reported as such. A read timeout, which no handler recognised, now reports that the server timed out. These messages are printed on stderr.
hop3 logoutsigns out after a login. A login records the server as the default context. The commands the CLI handles locally looked only for the default server older versions wrote: since 0.7.4,hop3 logoutanswered "Not logged in" and kept the token, andhop3 login --tokenor--browserwithout--urlfound no server.
Server installation and updates¶
- Updating a server reconciles the platform's settings.
hop3-deploy-serveron an existing server updated the code and ran the installer only when features were requested, so a setting the platform added missed the servers installed before it; the nginx setting above first missed the very server it was written for. A plain update now runs the installer's--reconcilemode, which applies those settings and restarts no service that runs apps. The full installer no longer restarts uwsgi-hop3 unless the unit changed: each redeploy with--withbounced all apps. - A failed setup step aborts the server install, with the command's output. The systemd, SSL, nginx, PostgreSQL, MySQL, config-file and ACME steps caught their failures, printed a warning cut to 100 characters, and the install continued to report success: a failed config write gave a server without database credentials. Verification now fails on a stopped core service, a missing certificate or a missing
hop3-server.toml, where it warned. On a host without systemd, the installer generates the environment and key files, installs no unit, and reports that, wheresystemctlfailed and was reported as a "Systemd setup issue" on all container installs. Seenotes/installer-fail-loud-audit.md. - The server installer's toolchain and service steps abort at the failure. A failed install of npm's pinned pnpm, nodeenv or Go, a pnpm shadowed on PATH, Postfix for
--with email, acme.sh, the S3 service or its token's group, thewww-dataanddockergroup memberships, an unsupported distribution, or the removal of the legacy sudoers fragment printed a warning and the install continued. The SSH key step could not fail at all: it printed "SSH keys configured" whatever happened. Rust, Leiningen and Elixir stay best-effort by default and abort when requested:--with clojureand--with elixirjoin--with rust. - The database steps of the server installer fail with the output. A failed PostgreSQL
createuserorcreatedbwas printed as a detail and followed by "role and database created"; a PostgreSQL cluster that failed to start fell back to an older one; MySQL statements that failed returned without a message and a later check reported a generic error; the Docker-network MySQL user and the bind-address restart could fail while the install continued. Each now raises where it fails. A failed MySQL statement names the operation, never the statement text, which contains the password. Redis is made a primary in the config file, where the runtime commands that did it were discarded by the restart after them. It is enabled under the Debian or Fedora name. The Redis check now authenticates: with a password set, the unauthenticatedPINGit made was refused. hop3-install serverfails when pip fails. A failedpip installof hop3-server, hop3-rootd or hop3-cli was ignored, and the installer printed "installed successfully" over it. It now fails there and shows pip's output.hop3-install serverfails when it cannot install Node 22. When NodeSource was unreachable it installed the distribution's Node 18, which is past end of life and too old for the Node apps in the catalog; the install succeeded and those apps failed later, at build. It now aborts there with the reason, and likewise when an older Node is still first onPATHafter the install.- The server installer runs on Ubuntu 26.04. It requested
libpcre3-dev, which 26.04 no longer ships, and ended at the firstapt-get install; it now useslibpcre2-devthere, as on Debian 13. It also no longer requestspostgresql-contrib, a transitional name for modules now in the server package, and a virtual package with two providers on 26.04. With--with mysqlit ended at MySQL's service: the catalogue baseline's MariaDB headers made apt removemysql-server, which on 26.04 refuses to share a host with them. The next run then mistook the leftovermysqldfor an installed server. On 26.04 the client headers now come from MySQL. A requested feature's packages are always installed. The installer'shop3MySQL account is created with the server's default authentication, as each app's account already was: MySQL 8.4 no longer loads themysql_native_passwordplugin it named. A PHP app finds Ubuntu 26.04'sphp-fpm8.5: the toolchain looked for a list of versions that ended at 8.4, and now uses the highest versioned php-fpm on PATH. - The server installer sets a umask of 022. It inherited the caller's, and
docker execcan pass 0000: files the installer created without an explicit mode were world-writable. Redis did not start: its init script created the pidfile world-writable,start-stop-daemonrefused it, and the script exited 0. - An installer step no longer removes packages without saying so. apt resolves a conflict by removing the other side and still exits 0: on each install,
--with mysqlremoved the MariaDB client headers and the catalogue baseline put them back, removing MySQL's. The package lists now install with--no-remove, so a conflict aborts the install, printing apt's list of what it would remove.--with mysqlno longer adds a second set of client headers. - The server's npm is pinned (11.17.0), like its pnpm. Node 22 ships npm 10, which crashed installing a project scaffolded by the current NestJS CLI (
Cannot read properties of null (reading 'edgesOut')). The installer reconciles npm on each install and fails if another npm shadows it on PATH. - The server installer installs the .NET SDKs 8 and 10, the long-term releases, wherever the distribution publishes them. It installed 8 and 9, but no feed publishes 9 for Ubuntu 24.04, so that install failed on all such servers and was printed as a warning. An SDK that is published and fails to install now aborts the install, as does a platform that publishes no SDK.
--with emailwaits for apt's lock. Its Postfix install was the oneapt-getcall without the bounded lock timeout the others include, so it failed at once when another apt still held the lock. A unit test now fails on anyapt-getcommand without it.- The .NET SDK step explains why an SDK is missing. A failed
apt-get updateis fatal. So is failing to add Microsoft's repository on Debian, which has no .NET packages of its own, and on Ubuntu is named as the cause of any SDK then missing (the line read "not published"). On Fedora an installed SDK counts as available, whichdnf list --availablemay not show. - The installer explains why a service would not start. Redis and MySQL failures aborted with only "did not restart"; the failing command's output and the unit's journal now come with it. Redis is given time to listen after a restart, which an init script does not wait for.
- On a host without systemd, the installer's Redis runs the configuration the installer generates. The init script finds Redis by its pidfile, but the Redis the package starts on the Docker target has none. The installer's restart therefore reported success while the new Redis failed to bind next to the old one. The old one then answered the check that followed. The installer now asks Redis to shut down, waits for it to exit, and starts it again.
- A PostgreSQL password that cannot be verified comes with the reason. The error now includes the
ALTER USERand TCP-check output, which tell a refused connection from a wrong password from anALTERthat never ran. hop3-deploy-serverfails where it used to fall back. With--acme-email, a failed Let's Encrypt issuance, a placeholder address or a missing acme.sh fails the deploy, where it installed a self-signed certificate and reported success; without--acme-email, self-signed is the default, stated once. A failed admin user creation fails the deploy, where it continued to print login instructions for a user that did not exist. The CLI installer fails when the installedhop3does not run or provides no command, and shows a failed package install's output without--verbose.hop3-deploy-serverinstalls Docker only on request (--with docker), on any target, as ADR 052 decided: Docker is a second-tier runtime. On the Docker target, where dockerd cannot run,--with allexcludes Docker. An explicit--with dockeris refused there with the reason, where it used to print "Failed to start Docker daemon" and continue.hop3-deploy-server --dockerfails when its image build fails. It used to start a bareubuntu:24.04instead, with a few packages added, and announce it in one line: a different machine from the one the tests describe.hop3-deploy-server --dockerwaits for hop3-server to come up before it reports the services started. A local CLI it could not configure now fails the deploy. The login ran while the server was still starting and failed with "Could not connect", yet the deploy still ended with "Deployment complete!".--no-cli-setupskips the step.- On a Docker install, supervisor calls the uWSGI emperor
uwsgi-hop3, the name of its systemd unit elsewhere. It wasuwsgi. Usesupervisorctl restart uwsgi-hop3. The nexthop3-deploy-serverrewrites the configuration. hop3 system statusworks on a server with services under supervisor. It shelled out tosystemctl, which a Docker install does not have, and failed outright. It now queries the process manager through hop3-rootd, and checks the proxy the server actually runs, so a Caddy or Traefik server no longer reports nginx as down.hop3 system status --jsonalso returns the structured report it was documented to: the client took--jsonas an output switch and kept it from the command, so the "JSON" was the text report in a list.- hop3-rootd reports a service systemd has no unit for as unmanaged. It used to report it stopped.
systemctl is-activeprintsinactivefor a missing unit, so an uninstalled proxy read as installed and down. - The server's log messages are no longer lost to the database migrations. Alembic's logging setup, run in-process at startup, disabled all loggers created before it: among them, the catalog loader's report of the apps it excludes.
Documentation¶
- Tutorial: your own Flask app, built hermetically by Nix (
docs/tutorials/nix/nix-gen-flask.md). No Nix code and no Nix on the reader's machine: a[nix]template, a hash-pinned lockfile, and a deploy. - Tutorials teach what the platform accepts. Django records exact versions (
pip freeze); Sinatra keeps itsGemfile.lock; Nuxt dropsNITRO_PORT = "$PORT", which[env]passes literally; the NestJS, Nuxt and Astro scaffolds are pinned to a major version. - The tutorials attach addons the way the CLI expects,
hop3 addon attach <addon> --app <app>: fifteen lines had the app first, as a positional, and nine toldhop3 app logsto--tail, an option it does not have.hop3 backup listuses--appfor the app in all guides.
For contributors: the test harness¶
- The test suites run in a checkout with no catalog next to it. The tests that read the hop3-catalog recipes are marked
catalogand run withmake test-catalog(pytest --catalog=<checkout>); elsewhere they are deselected, and pytest's summary counts them. The two tests that used a catalog recipe as example input read a frozen copy instead. - A command run in a test container keeps its output against a remote Docker daemon. Over
ssh://, docker-py reads an exec's output from the raw SSH channel, and an output that arrived with the response headers stayed in paramiko's buffer. A curl that had answered then read as "no HTTP response", at random, in the gate and proxy e2e tests. The harness's Docker client now reads that buffer first. hop3-testinstalls the runtime the selected apps are built with. A Nix-built app brings--with nixand a Docker-built one--with docker, as their addons already brought theirs, now that no runtime is installed by default. On the Docker target, which cannot run Docker, Docker-built apps are left out before the deploy, each with the reason.hop3-testfails a server deploy that continued past a warning. Each ⚠ line the installer or the deployer prints fails the run, unless an allowlist entry explains why it is safe to continue. Such lines sat unread in all run logs for weeks. Among them were two bugs and an install that failed on all Ubuntu 24.04 servers.hop3-testfails an app that writes intosrc/while running, and redeploys all apps. Each deploy replacessrc/, so whatever an app puts there is lost at the next one, an upgrade included. The test lists the directories the app wrote to after starting, grouped for a[[volumes]]entry. It then deploys the unchanged source again and runs all validations a second time.--no-redeployskips the second deploy.- An app with a sign-in only a browser can drive is no longer reported as passed without one. Ten catalog recipes build their sign-in form in JavaScript; their check verifies everything else and reports so, and
hop3-testreported them passed, so no run verified their sign-in. A run without a browser now reports them as not verified, by name and with the statusunverified, without failing them.hop3-test run --screenshotsdrives a real browser to each passed catalog recipe, signed in with its admin credential, saves the images into the recipe'sscreenshots/, and fails a browser-only app the browser cannot sign in to. It needs a server target and Playwright (uv run --with playwright). It replaces the catalog repository'scheck-catalog.pyandshoot-catalog.py. The admin credential a check receives now also names the field the app's form expects (HOP3_ADMIN_LOGIN). - A Docker deploy takes 4 minutes instead of 17. The test image contains the packages the installer installs on Ubuntu 24.04, so a new container no longer downloads the base packages, the catalogue baseline, Go and the .NET SDKs. The installer still runs all apt steps and finds them installed. The image's lists are derived from the installer's code. A unit test fails when they drift, so the image cannot contain a package the installer would not install.
- A recipe with a probe account that cannot be created fails its test. The deploy continues past a failed
[probe].create, after which the app's check signs in as the operator. Six published recipes passed a whole run that way, their probe commands broken.hop3-testnow fails such a test and quotes the deploy's reason. The check's summary no longer reads "no [probe]" when a probe exists. - A failed test with an app that still serves is headlined by the test's error. A failure the app's runtime does not explain (a check, a
contains, the probe account) was listed under "Failed tests:" as "✓ ok" with the verdict "see sections below". The headline now states that the app serves and the test failed, and quotes the test's error. - A failed tutorial names the step that failed. All tutorial failures were classified
build-failureand pointed at an empty build section, an output mismatch included: validoc'sError: Expected exit codematched the build-error pattern. A failed tutorial is now astep-failurewith a headline quoting validoc's failed step and its error, pointing at the section with the step's output. - A Nix hash mismatch's headline gives the hash the build got. Nix ends its first line at the derivation, so a failed
hop3-testapp's verdict quoted that line and stopped before the value to put in the recipe. It now names the derivation, the recipe's hash and the one the build got. The text report also no longer prints "No failures recorded." beneath a list of failed tests when the target had none. - A command that hangs on a test target fails, naming itself. The Docker exec and the SSH channel do not bound a command, so one that hung left a
hop3-testrun silent, with no error, for as long as it hung. Commands the harness runs on a target are now bounded there (300 s unless the caller sets more; a tutorial gets 900 s). One that runs past the limit is killed and raises an error naming the command, the limit and the last output. - A test run names the tests it left out, and why, at any verbosity. Tests dropped for a missing service, a missing admin domain, or a Docker build on the Docker target were reported through warnings that
--quietsilenced, absent from the report; they are now listed as they are left out and again after the summary. A failed app destroy at cleanup is shown the same way, and fails an upgrade chain, since the--hostserver keeps whatever the teardown left. - A test run no longer changes your CLI's default context. Docker deploys by the test harnesses (
hop3-test, the pytest e2e targets) signed the developer's CLI in to the throwaway container and made it the default context, so once the run removed the container, plainhop3commands went to a dead address. The harnesses now deploy with--no-cli-setup; a manualhop3-deploy-server --dockerstill configures thehop3-dockercontext. - The executable tutorials pass on Docker again. The test domain included the Docker target's SSH user (
hop3@<ip>.sslip.io), so the hostname step of all tutorials was refused; on a target named by hostname it was<name>.sslip.io, which sslip.io does not resolve; and all 33 checked their app overhttp://, which the platform redirects. Tutorials now checkhttps://; the harness accepts the test server's self-signed certificate. A tutorial also gets the server itshop3.tomlrequests (Nix, addons), as an app does. - One e2e session per machine. The Docker target has a fixed container name and ports, so a second run tore down the first's container mid-install. A run now locks
~/.hop3/docker-target.lockbefore touching Docker and refuses to start while another holds it. - The Docker tests run on the daemon the Docker context names, a remote one included:
DOCKER_CONTEXT=fedora make test-e2eruns them on another machine, through the ports the container publishes there. - The upgrade-chain check cannot pass vacuously. The seeded account's rights on the first hop were taken as found, so a probe that failed there compared equal on all later hops; the seed now must have a plain account's rights.
- Production code no longer checks whether it runs under pytest. Seven places skipped a hop3-rootd call, the Mailpit check or the port-25 probe when
PYTEST_CURRENT_TESTwas set; the test suite now replaces those dependencies with fakes it declares. 070-php-publicexpected the pre-2026-09-25 answer for/.env(the front controller). nginx now returns 404 for hidden files, which the fixture now checks.hop3-tools catalog reportschecks coverage against the catalog's maturity folders. It listed the directories directly underapps/, which have been the statuses since the catalog moved to them, so it claimed all experience reports described an app the catalog did not include, and could not report a published application without one. It now counts the applications behind the published entries, a variant counting as its application.- A test run states what chose its tests. Without apps, paths or
--status,hop3-test runuses thesmokeprofile, nine Procfile fixtures, and a server rebuilt for the full catalog run passed it twice with "All 9 tests passed". The run now prints the selection before it starts and again under the summary, for example "profile smoke (tier fast; priority P0): 9 of 256 tests", with the options that widen it.