Releases

A DevGrail release is one thing, not seven: it names the exact version of every container image and installer file it consists of, and they are published together or not at all. Older releases stay downloadable at their original bytes.

CurrentSignedAugust 25, 2026 · 2 images, 5 installer files

Fixed

  • A running script now shows its output while it runs. Until it finished, a script run reported nothing at all — a spinner, an empty pane, and no way to tell a slow step from a stuck one. Output is now recorded as it arrives, so the run history, the workspace's Execute scripts tab and the editor's test run all fill in as the script works. Injected secrets stay redacted throughout: the tail of each update is withheld until enough has arrived to match and scrub a value that lands across the boundary, so a secret can never assemble itself across successive updates.
  • The end of a long script's output is no longer the part that gets thrown away. Output over the 1 MiB cap used to keep the beginning and drop the rest, which for a chatty apt or npm step meant keeping the downloads and losing the error. It now keeps the end, where a failing script says why. Each step in a chain is still labelled with the script it came from.
  • "Run this again" in a workspace's run history no longer silently changes the run. It passed no parameters at all, so a script with parameters either ran with different values than the run it was repeating or failed outright. The control now loads that run's script and the values it was started with back into the form for you to check before running — API keys and secrets included, still as references rather than the secrets themselves.
  • Testing a script in a temporary container no longer gives up after three minutes. The editor stopped waiting long before the server does, destroyed the container out from under a script that was still working, and reported a perfectly good script as failed. It now waits out the server's own ten-minute limit, and shows elapsed time while it does.
  • Deleting a script now warns you what it will break. Scripts that include the one being deleted are named in the confirmation, an include whose script is gone is called out in the editor instead of showing a bare id, the preview shows where the chain breaks, and the error you get from running one now names the script that needs fixing rather than a bare id.
  • The workspace Scripts tab no longer re-fetches your scripts continuously while it is open.

Changed

  • The script editor highlights shell syntax and numbers its lines. Comments, strings, keywords and variables are coloured — in both light and dark — and the line numbers mean an error reported at "line 47" can be found by looking rather than by counting. Parameter and secret chips work exactly as before.

  • The dashboard now wears the same colours as devgrail.com. Navy, gold and warm off-white replace the old purple-on-white palette, and the DevGrail wordmark, logo tile and page-header icons match the ones on the website — so moving from the product page to your own install no longer feels like moving between two different products. Nothing moved and nothing was renamed; only the colours, corners and brand marks changed. The consent screen you see when connecting DevGrail to Claude.ai was restyled to match as well, and the installed-app splash colour now matches the dashboard instead of flashing black on launch. Status colours are untouched: running is still green, warnings amber, failures red.

Read the full entry →
SignedAugust 25, 2026 · 2 images, 5 installer files

Added

  • A "Re-add" tab on the Containers page for workspace containers this host still has but the dashboard is not running. Two things land there: a workspace DevGrail still knows about whose container is bound to a Docker network that no longer exists, and a workspace container the database no longer describes at all. Nothing is re-added on its own — each one comes back only when you say so, and the tab tells you what re-adding will restore before you do. Re-adding a detached workspace rebinds it and starts it with its files, volume, ports and credentials untouched. Re-adding a container DevGrail has no record of rebuilds one from the container itself; the description, documentation, project, app subdomains and API-key grants lived only in the database and do not come back, and its SSH password is reset. The tab is always there, so it can answer "is anything missing?" rather than only appearing once something is. Administrators only, since re-adding decides who owns a workspace.
  • uninstall.sh --keep-workspaces removes DevGrail without removing your work. Everything of DevGrail's own goes — the stack, the database, the certificates, the images and the config in /opt/devgrail and /etc/devgrail — while the workspace containers, their volumes, their networks and the image they run on stay where they are. It is the way to start an install over from scratch without losing the workspaces: re-run install.sh and the Re-add tab offers them back with their files, volume and ports intact. What only ever lived in the database — names, descriptions, documentation, projects, app subdomains and API-key grants — comes back by restoring the backup the uninstall takes first, not by re-adding. An uninstall run without the flag now offers the same mode as a question when you decline the full wipe, and the report says up front what keeping the containers does and does not preserve.
  • Workspaces in this state now say so. A workspace whose network binding is broken is shown as "detached" instead of appearing to be an ordinary stopped one, and starting it explains what to do rather than reporting a Docker error that names a hexadecimal id and nothing else.

Fixed

  • Uninstalling with your workspaces kept no longer leaves them unable to start. A preserved workspace is bound to its Docker network by id, not by name, so removing that network — which the uninstaller did even when it was keeping every container — stranded it: reinstalling recreated a network with the same name and a new id, and starting the workspace failed with failed to set up container networking: network <id> not found. The per-workspace networks are now kept whenever the workspaces themselves are, and only --purge (which removes the containers anyway) removes them.
Read the full entry →
SignedAugust 25, 2026 · 2 images, 5 installer files

Added

  • The uninstaller now tells you what it is about to destroy. Every run opens with an inventory of this host — workspaces and whether they are running, workspace volumes by name, the database and certificate volumes, images, the files on disk, and how many backup archives are in /opt/devgrail/backups — with each line marked as destroyed or kept by the options you passed. The confirmation questions quote those numbers too, so "delete ALL DevGrail data" names how much data that actually is.
  • uninstall.sh --dry-run prints that report and stops, having changed nothing. It combines with the other options, so --nuke --dry-run reports a --nuke without performing any of it, including whether the Docker daemon settings the installer added are still ones it recognises as its own — it leaves anything you have since changed alone, and now says so in advance. --dry-run needs no terminal, so it works piped from curl.
  • A --remove-docker run now counts the containers and volumes on the host that are not DevGrail's before asking, since that option deletes those too.

Changed

  • Uninstalling without --purge no longer deletes your workspaces. A plain uninstall.sh now stops the workspace containers and leaves them on the host, together with the workspace image they run on — so everything in a workspace's own filesystem survives, not only what is in its volume, and re-running install.sh brings back the same workspaces instead of empty replacements. Only the Compose stack and the per-workspace networks are torn down, and both are recreated automatically. --purge still deletes everything, and now names the workspaces it is about to remove before it asks.

Fixed

  • An unattended uninstall.sh could abort instead of proceeding. It decided whether it had a terminal by checking that /dev/tty exists, which is true in places it cannot actually be opened — a detached service unit, a container started without a terminal, some CI runners. There it would try to ask a question, fail to read the answer, and treat that as "no". It now tests the same way install.sh does, by opening it.
Read the full entry →
SignedAugust 24, 2026 · 2 images, 5 installer files

Fixed

  • Settings no longer offers a workspace-image upgrade that does not exist. A release that leaves the workspace image alone ships the copy you already have, but the About section compared that image against the release version anyway — so a host on the newest workspace image published was told a newer one was available, and installing it would have fetched nothing new. Each part is now compared against its own newest published build, and the row names that version rather than the release's.
Read the full entry →
SignedAugust 24, 2026 · 2 images, 5 installer files

Added

Nothing, just testing

Read the full entry →
SignedAugust 24, 2026 · 2 images, 5 installer files

Added

  • A dashboard, and it tells you how much room the box has left. The home page is now an overview rather than the workspace list: free memory, free disk, what your running workspaces have been promised, and which of them are actually using it. Disk filling up during a migration is the worst state this product can get into, and until now nothing in the interface would have warned you.

    The workspace list has moved to /containers — update any bookmark that pointed at the old home page. Everything on it is unchanged, plus live CPU and memory bars on each running workspace.

  • Live resource usage per workspace. CPU, memory, processes and network for every running workspace, on the dashboard, on each card in the list, and on the workspace's own page. Finding the workspace eating the host no longer means SSHing in and running docker stats. Readings are taken on demand and briefly cached; nothing is stored.

  • Creating a workspace that will not fit is now refused, not discovered later. Ask for more memory than the host has available and the create stops with both numbers, instead of the workspace being killed under pressure some time afterwards. The create form warns before you submit and offers to go ahead anyway — memory limits are ceilings, not reservations, so overcommitting is a legitimate choice, just one worth making deliberately. Agents get the same refusal through create_container, with confirm_overcommit to proceed.

    On a host that cannot report its own memory the check simply does not apply.

  • Agents can publish and withdraw workspace subdomains themselves. Two new MCP tools, Add container subdomain and Remove container subdomain, give an agent the same control the workspace detail page has: expose a port at <label>.<base-domain>, or stop publishing one and free the label. Grant them to a key on the API keys screen like any other tool.

    An agent that starts a dev server no longer has to stop and ask you to open the URL for it — it can publish the port, hand you the link, and take it down when the work is finished. Nothing restarts: the workspace, its processes and its terminal sessions carry on untouched. Both tools need a write grant on the workspace, both return the workspace's full subdomain list so the agent can see the resulting URLs, and where a deployment has several base domains the agent can name one (or leave it to the default).

Changed

  • Upgrades no longer re-download a workspace image that has not changed. The workspace image is ~750 MiB and most releases do not touch it, but every upgrade fetched it again anyway — a download of some minutes for bytes already on the disk. A release that leaves the image alone now ships the copy you already have, and the installer says so (devgrail-container is unchanged in v0.4.14 — keeping v0.4.13) instead of downloading it.

    Nothing about verification is relaxed: an image is skipped only when the release names the exact build already installed and that build is still what the workspace tag points at, so a host that has rolled back is never told it is up to date. Only the images that genuinely changed are fetched, and the server image — which is stamped with the release it ships in — always is.

  • The installer no longer stops on a prompt when there is nobody to answer it. Run from a container or any other environment with no terminal attached, it could fail with /dev/tty: No such device or address instead of taking the default it already had — it checked that the terminal device existed rather than that it could be opened, and in a container the first is true while the second is not. Unattended installs that supply their answers through the environment are unaffected either way; this only fixes the case that used to stop.

  • The workspace image is now built from pinned versions, and it can tell you which ones. Until now the image tracked whatever was newest on build day: the base OS, Node, opencode, Claude Code and Codex all floated, so two builds of the same DevGrail release could ship different tools. A release tag now names one exact set. Every workspace also carries /etc/devgrail-workspace.json listing what it contains — base image, Node, Python, gh, and the three AI CLIs — so "which version of the agent is in here?" is a question you can answer from inside the terminal, and the same facts are on the image's labels for anyone looking from the host.

Read the full entry →
SignedAugust 21, 2026 · 2 images, 5 installer files

Added

  • Choose which domain a workspace subdomain is published under. Settings has a Base domains section: add a suffix, mark one as the default, rename one, or remove one you no longer use. A deployment used to have exactly one suffix for app subdomains, fixed at install time in config.yaml, and changing it meant re-running the installer over SSH.

    Renaming a base domain moves every subdomain published under it at once, and no workspace restarts — app subdomains are routed from Traefik's file provider, not baked into the container. Where a deployment has more than one suffix, the create and detail pages let you pick per subdomain, so two workspaces can each publish "api" under different domains. Deployments with a single suffix see no picker and nothing changes for them.

    Your installed domain is kept: it seeds the list on first boot and is then left alone, so re-running the installer cannot revert a list you have edited in Settings. Existing subdomains keep routing exactly as before.

  • Install without the workspace image. The installer takes --workspace-image=no, which brings up the server, dashboard and API without downloading the ~750 MB workspace image — around 45 MB and a couple of minutes instead of the better part of a gigabyte and twenty. Everything except creating a workspace works, and when you want the image, re-running the installer with --workspace-image=yes downloads that image and nothing else.

    Most useful when you are standing a host up to look at it, testing an upgrade, or working on a small disk. The answer is remembered, so an upgrade will not quietly start — or stop — fetching it, and --dry-run tells you which way the next run would go. While there is no image the closing banner says workspace image: NOT INSTALLED, and trying to create a workspace names the command that fixes it rather than failing cryptically.

Fixed

  • Upgrades no longer abort claiming they introduced foreign-key violations. A migration that rebuilds a table to add a foreign key renumbers the ones already on it, and the integrity check read the pre-existing orphans it had merely copied as damage it had just done. It now keys violations on the tables involved, so an upgrade is judged on what it actually changed.
Read the full entry →
SignedAugust 21, 2026 · 2 images, 5 installer files

Added

  • Add and remove public subdomains on a running workspace. The workspace detail page now has an Add button beside Subdomains, and a bin icon on each one. Publishing a port, or withdrawing it, takes about a second and leaves the workspace running: your dev server keeps serving, your terminal sessions stay open, nothing restarts.

    Until now a subdomain could only be chosen when the workspace was created. If you decided later that port 5173 should be reachable from the outside, the only route to it was deleting the workspace and building a new one. That is a steep price for a hostname, and it is why the setting was easy to get wrong at a moment when you had not started working yet.

  • Send yourself a test notification. Settings → Notifications has a Send test button. It creates a notification and pushes it to your devices exactly the way your agents' notifications travel, then tells you what happened: how many browsers are registered, how many took it, and a reason for each one that did not. Run it on the device you are checking — every browser and phone registers separately, so a laptop that works tells you nothing about a phone that does not.

    Push notifications fail quietly by nature. A lapsed subscription, a permission revoked in your phone's settings and a preference you forgot you turned off all look identical from the outside: nothing arrives. This turns that silence into a sentence.

  • The contact sent with desktop notifications is yours to set. When DevGrail delivers a desktop notification it has to identify itself to the browser's push service — Mozilla's, Google's, Apple's — with a contact address, so those operators have somewhere to write if a deployment ever starts misbehaving. Until now that address was always your deployment's own web address, with no way to change it. Settings has a Push contact card: put an email address or a URL there and it is what gets sent from the next notification onwards, with no restart. Leave it blank and nothing changes — your deployment's own address is still used, and the card tells you which one that is.

    It is not a link anyone follows and it is never shown to your users; it is an operational contact for whoever runs the push service. Setting it to an address a person actually reads is worth the minute it takes.

Fixed

  • Notifications now arrive on phones promptly instead of eventually. DevGrail was sending notifications at normal priority, which lets Android hold them until the phone next wakes up properly — so a notification meant to interrupt you could turn up an hour later, or get folded into a batch you never noticed. Every notification is now sent at high priority, because every one of them is something you asked to be interrupted for.

  • Notifications no longer stop reaching a device and stay stopped. Browsers periodically retire a device's notification registration and issue a new one — Chrome on Android does it routinely. DevGrail did not notice, so the device silently dropped off and only came back if you happened to open DevGrail in that browser again. It now re-registers itself the moment the browser rotates it, without the app being open.

  • A notification is no longer lost when a push service is briefly busy. A single overloaded moment at Google's or Mozilla's push service used to mean the notification was gone for good. Delivery is now retried.

  • Notifications reach devices again after a deployment restore. A browser registered before a database restore was bound to a security key the server no longer had, and every notification to it failed silently and permanently. The mismatch is now detected and the device re-registers itself.

  • Desktop notifications no longer degrade over time in Chrome. When DevGrail was open and focused, the incoming notification was handled by the in-app popup and no system notification was shown. Chrome treats that as a broken promise and eventually substitutes its own "This site has been updated in the background" message, then withdraws permission altogether. The system notification is now always shown, and dismissed a moment later when you are already looking at the app.

  • Notifications now carry the DevGrail icon rather than a generic browser one.

Read the full entry →
SignedAugust 19, 2026 · 2 images, 5 installer files

Added

  • A house size for new workspaces, set once instead of per workspace. Settings has a Workspace defaults card holding a default CPU limit, a default memory limit and a process limit. Until now the first two did not exist: a workspace created without limits was created with none, so keeping a shared box from being swallowed by one runaway container meant remembering to pick limits every single time — including on the workspaces an agent creates over MCP, where nobody is there to remember. The defaults apply wherever a workspace is created from, and they are a default rather than a ceiling: a workspace that names its own limits keeps them.

    Changing the default never resizes a workspace that already exists. Each one records the limits it was actually given at creation, so what you see on a workspace is what it is running with, and a later change to the house default cannot quietly re-size it on its next restart or rebuild.

    The create form now labels its first CPU and memory option with the default your deployment will really apply — "Default (2)" rather than a flat "Unlimited" that would be untrue as soon as you set one. Picking "Unlimited" explicitly still does what it says.

  • The workspace process limit is adjustable. It caps how many processes one workspace may spawn, so a fork bomb or a runaway build cannot exhaust the host's process table and take every other workspace down with it. It has always been there at 4096 and stays there unless you change it; the reason to raise it is a genuinely build-heavy workspace hitting the ceiling, which previously needed a custom build. There is deliberately no "unlimited" setting — it is a guard on a shared host, not a sizing preference — and it applies to a workspace the next time it is created or recreated.

Read the full entry →
SignedAugust 19, 2026 · 2 images, 5 installer files

Added

  • Session, lockout, OAuth and HSTS lifetimes are settings now, not constants compiled into the binary. Every one of them was a Go value, so the only way to run a test or staging host on different numbers was to build your own image. Two cases made that hurt. A host running automated auth suites trips the login lockout constantly, and the counters are in-process — restarting the server was the only way to clear one. And a staging hostname that took the two-year Strict-Transport-Security header could never be served over plain HTTP again, because only max-age=0 undoes that and there was no way to send it. DEVGRAIL_SESSION_TTL, DEVGRAIL_SCOPED_TOKEN_TTL, DEVGRAIL_LOGIN_MAX_FAILURES, DEVGRAIL_LOGIN_LOCKOUT, the three DEVGRAIL_OAUTH_*_TTL values and DEVGRAIL_HSTS_MAX_AGE are all optional and all documented in deploy/.env.example; leaving them unset is exactly what shipped before, because the defaults still live in the packages that enforce them rather than being copied into the configuration.

    A value that does not parse is a startup error, not a skipped assignment. DEVGRAIL_SESSION_TTL=24 leaving 24h quietly in force under a name that says it changed is the failure these variables exist to prevent.

  • GET /settings/policy tells the dashboard what this binary actually validates against. Settings carried its own copy of the minimum password length and the SSH port bounds, kept in step with the server by a comment. Raise the minimum and the form went on accepting a password the API would reject; the message the user got was the API's, arriving after they had typed it twice. The page now reads the rules — password lengths, SSH port range and defaults — from the server it is talking to. If that fetch fails it offers no hint and no input bounds at all and lets the API decide, rather than falling back to a number that may be wrong.

Changed

  • The Traefik image is a setting. TRAEFIK_IMAGE in deploy/.env names the full reference, pin included, so an install can pull it from a private mirror or an air-gapped registry. Unset, it is the same pinned version the compose file has always used.

  • The Claude.ai OAuth callback is no longer unconditional. The connector's redirect URI was accepted whatever a client registered, and there was no way to say "none" — an install that will never talk to claude.ai carried it anyway. oauth_extra_redirect_uris in config.yaml (or DEVGRAIL_OAUTH_EXTRA_REDIRECT_URIS) sets the list; unset still means the documented Claude.ai callback, so nothing changes unless you change it, and an empty list means none.

  • A server with no registry configured says so instead of pointing at the vendor. registry_url had a compiled-in default, so an install that was never pointed at a registry silently asked devgrail.com about updates, and Settings → About offered an upgrade link on an origin that host had nothing to do with. It now does nothing and logs why, and the link is omitted rather than pointing at a page this server does not serve. install.sh has always written the key, so no installed host is affected.

  • The backup directory is reported from configuration rather than assumed to be /opt/devgrail/backups. install.sh records where it actually put it, and the two messages that send an operator to find a snapshot — the schema-too-new refusal and the missing-jwt_secret refusal — name that path instead of a default that may not be where the backups are.

Read the full entry →
SignedAugust 18, 2026 · 2 images, 5 installer files

Added

  • The changelog reaches the people running DevGrail. This file was the only place a release was described in human terms, and it never left the repo: publish.sh opened it twice — once to take the version from the newest heading, once to stamp today's date onto it — and read no further. The registry has carried a notes field for every release since releases existed, served in the manifest and rendered on both the dashboard and the public release page, and nothing had ever written to it. So a customer whose /healthz said v0.4.8 could learn that v0.4.9 existed and could not learn what was in it, and /registry/releases was a column of version numbers and badges. Publishing now sends the section body, /registry/releases/vX.Y.Z renders it as markdown, and Settings → About links to it as "What's new in vX.Y.Z" — a release_notes_url the server had been computing and no screen had ever shown. A heading with nothing under it is now refused before anything is built, because shipping a version nobody can find out about is the failure this is meant to prevent.

    scripts/publish.sh --sync-notes re-sends every section to the release of the same name, which is how the ten releases published before this one got their notes. It builds and uploads nothing, skips versions the registry does not have rather than inventing them, and is also the way to fix a typo in prose after a release has shipped: it goes through a new PATCH that touches one column, where re-posting the release would replace the artifact set and move the pointers customers are downloading from. The dashboard grows an editor on the same path.

Fixed

  • publish.sh read ### Added as a release named #. The heading regex accepted ## followed by no space, so every subsection heading in this file produced a phantom entry in the version list. Only the first line was ever used, which is why it stayed invisible — but a changelog whose first ### came before its first ## would have taken # as the version to publish.
Read the full entry →
SignedAugust 18, 2026 · 2 images, 5 installer files

Added

  • install.sh --acme-ca=staging orders certificates from Let's Encrypt's staging CA. Production allows 5 certificates per exact set of identifiers per 168h, and every test cycle that starts from an empty certificate store spends one: uninstall.sh --purge removes the traefik-acme volume, and the first install after enabling userns-remap moves Docker's data root to /var/lib/docker/<uid>.<gid>, orphaning the old volume just the same. Five reinstalls on one hostname exhaust the quota; Traefik then logs a 429 and serves its self-signed TRAEFIK DEFAULT CERT, which reaches the operator as an invalid-certificate warning on a host that otherwise installed cleanly. Staging certificates are untrusted, but the issuance path — DNS, :443, TLS-ALPN-01, the ACME store — is the same one, so a test install still proves it works. DEVGRAIL_ACME_CA is the env-var form; a directory URL is accepted for a private CA (pebble, an internal ACME server).

    Each CA keeps its own store file under the traefik-acme volume — acme.json for production, acme-staging.json for staging — because acme.json holds the registered ACME account and an account from one CA is unknown to another. That also means a staging test cannot overwrite production certificates that the rate limit would not let you re-issue, and switching back with --acme-ca=production picks them up again instead of re-ordering. An upgrade that names no CA keeps whichever one the prior install used, and the closing banner says plainly when certificates are coming from a non-production CA.

Read the full entry →
SignedAugust 18, 2026 · 2 images, 5 installer files

Fixed

  • Installing with userns-remap enabled left the server crash-looping on unable to open database file (14). With daemon-level userns-remap on, Docker creates new named-volume directories owned by the remapped root (100000:100000), not by 0:0. devgrail-server deliberately opts out of the remap (userns_mode: host, so it is real uid 0) and runs cap_drop: ALL — and root without CAP_DAC_OVERRIDE gets no permission bypass, so it could not write a directory it did not own. Every SQLite open returned SQLITE_CANTOPEN, the server never reached healthy, and install.sh gave up after 180s. A one-shot devgrail-data-init service now chowns the volume to 0:0 before devgrail-server starts (condition: service_completed_successfully), holding only CAP_CHOWN. It re-runs on every compose up, so the fixup is self-healing rather than install-time-only, and is a no-op on a host without remap, where the directory is already 0:0. Only volumes created while remap is active were affected — a fresh install or a reinstall after enabling it.
Read the full entry →
SignedAugust 18, 2026 · 2 images, 5 installer files

Fixed

  • A fresh interactive install died at the admin-password question. install.sh called ask_yn from the admin-credentials prompt, but the function was defined a few hundred lines further down, next to the Docker daemon preflight that was its original caller. In a sh-style top-to-bottom script that is a plain ask_yn: command not found, and it aborted the run right after the operator had typed a username. The definition now sits with the other /dev/tty prompt helpers, above every caller. Only fresh interactive installs hit this — an upgrade, --yes, or a run with DEVGRAIL_ADMIN_PASSWORD set skips the block.
Read the full entry →
SignedAugust 18, 2026 · 2 images, 5 installer files

Added

  • uninstall.sh --nuke returns the VPS to its pre-install state. --purge only ever removed DevGrail's own things; the host kept the changes install.sh made outside that namespace, so a re-install was never really a fresh install. --nuke implies --purge and additionally strips the userns-remap and default-address-pools keys the installer added to /etc/docker/daemon.json (restarting Docker), reclaims the now-orphaned per-UID data root, and removes the dockremap user and its subordinate ranges. The measurable result is that install.sh takes its fresh-host branch on the next run — it offers userns-remap again instead of refusing because the host still has Docker state.

    Keys are removed only while they still hold the values install.sh writes. A userns-remap pointing at a named user, or pools tuned to the operator's own addressing, are decisions this script reports and leaves alone. A daemon.json that does not parse is never touched, and a daemon that does not come back gets its previous config restored. Interactively, --nuke is offered as a follow-up question after the data-wipe prompt rather than requiring the flag.

  • uninstall.sh --remove-docker additionally uninstalls Docker Engine, containerd and /var/lib/docker. It implies --nuke, is asked separately and defaults to no, because it stops and deletes every container on the host — DevGrail's or not. jq and openssl are never removed.

Fixed

  • uninstall.sh --purge left things behind, despite promising it did not. Per-workspace devgrail-ws-* networks were never removed (containers and volumes with the same devgrail.managed label always were), so each uninstall leaked one address-pool slot per workspace. Only :latest was removed for the two DevGrail images, leaving :previous and the release tag docker load unpacked. And once an earlier --purge had deleted the compose file, the fallback path removed two infra containers by name — missing Traefik, which declares no container_name, along with the project's volumes and networks. The stack's leftovers are now found by their Compose project labels, confirmed against the recorded config-file path so another project cannot be caught by the same sweep. uninstall.sh also honours docker-compose.override.yml now, as install.sh already did.
Read the full entry →
SignedAugust 18, 2026 · 2 images, 5 installer files

Added

  • The admin username and password can be chosen at install time. A fresh install at a terminal asks for both, offering to generate a password (the default) or to take one you type twice, unechoed. Nothing changes for unattended runs — --yes, no terminal, cloud-init — which still create admin with a generated password, printed in the closing banner. DEVGRAIL_ADMIN_USERNAME and DEVGRAIL_ADMIN_PASSWORD set either up front; both are now honoured from the environment, which the password previously was not.

    A generated password is still printed at the end, because nothing else ever shows it. One you typed is not echoed back, and neither is the value an upgrade reads out of deploy/.env — an upgrade asks nothing at all, since the server applies these only when it creates the account.

    A typed password is restricted to letters, digits and ! % * + , - . : ; = ? @ ^ _ ~, 12–72 characters. That is a file-format constraint, not a security opinion: deploy/.env is read verbatim by docker compose, by the installer itself and by the test harnesses, none of which unquote. Passwords set later through the app carry no such restriction.

  • Change your password in the dashboard. Settings → Password takes the current password and a new one (POST /auth/password). It re-authenticates first, so a hijacked session cannot lock the owner out, then revokes every session for that user — including the caller's, which is handed a replacement token so the page it was done from keeps working. That makes it a way to evict a session you no longer control, not just a way to rotate a secret.

  • sudo devgrail passwd resets a password from the host when nobody can log in. It prompts twice and pipes the result into the server binary's new -set-password mode on stdin — never argv or the environment, both readable via ps and docker inspect — then revokes that user's sessions. It runs against the live stack: SQLite is in WAL mode and sessions are re-checked per request, so no restart is needed. DEVGRAIL_FORCE_ADMIN_PASSWORD=1 still works as the fallback for a host that cannot run the one-shot container.

Changed

  • The installer now offers to fix the two Docker daemon settings it used to only complain about. install.sh printed a wall of text about userns-remap and default-address-pools, told the operator to edit /etc/docker/daemon.json by hand, and carried on — advice arriving at the one moment someone is already root at a terminal, and the one moment both changes are still cheap. It now explains each in plain terms, says what applying it costs, and offers to do it: the key is merged into daemon.json with jq (existing keys preserved, the prior file kept as .devgrail-bak-<timestamp>), Docker is restarted, and if the daemon does not come back the change is rolled back and the daemon restarted again — a host left without Docker is worse than either setting is worth. A daemon.json that does not parse is never touched.

    Nothing is applied without an explicit yes, and --yes is deliberately not that yes: restarting dockerd stops every container on the host. Unattended runs opt in on purpose with --userns-remap=yes|no / --address-pools=yes|no or DEVGRAIL_USERNS_REMAP / DEVGRAIL_ADDRESS_POOLS; no also stops the question being re-asked on later upgrades. --dry-run reports what it would offer and changes nothing.

    Two guards decide when the offer is even made. userns-remap is offered only on a host with no Docker images, containers or volumes: it moves the daemon to a per-UID data root, so anything created beforehand — the DevGrail data volume included — would become invisible, and on a host with state the installer explains the backed-up manual path instead of prompting for something it cannot make safe. The address-pool range is checked against this host's own routes before being offered (Docker's own docker0/br-* routes excluded, since they already sit in 172.16/12), falling back to 10.201.0.0/16 and then to declining outright — handing Docker a pool that shadows an operator's VPN or LAN route is worse than the ceiling it would lift. Pools an operator configured deliberately are reported, never rewritten.

Read the full entry →
SignedAugust 14, 2026 · 2 images, 5 installer files

Fixed

  • After a rollback, every later install run refused to upgrade — silently. --rollback points devgrail-server:latest back at the displaced build, but versions.env is written before the health gate and so still names the release that was rolled off. The skip-if-unchanged check compared that file against the registry and asked only whether something was tagged :latest, so it concluded "already at vX — skipping" and did nothing: the host kept running the old binary while versions.env, the installer's own closing banner and any script reading them all reported the new one. /healthz was the only thing telling the truth, and disagreeing with it looked like a stale response rather than the symptom it was. The only way forward was to delete versions.env by hand, which nothing documented. The check now asks whether :latest is that version — it compares against the release tag docker load gave those exact bytes — and when it is not, moves the tag back to them rather than re-downloading images that are already on the host. --dry-run reports that state instead of calling it unchanged, and --rollback now says plainly that versions.env names the release it rolled off and that re-running goes forward again.
Read the full entry →
SignedAugust 14, 2026 · 2 images, 5 installer files

Fixed

  • An interrupted install could wedge every later run. The concurrency lock is held on a file descriptor, which is inherited by child processes — so a daemon started during the run (dockerd, on a host where systemctl forks it directly rather than starting a unit) kept the lock alive after the installer itself had exited. Re-running then reported Another install.sh holds the lock and waited out the full 300s for a run that had ended minutes earlier, with no way out but deleting the lock file — which nothing told the operator to do. The installer now records the holder's PID beside the lock and breaks a lock that no live process claims, saying so as it does. Two belts: the commands that can fork a surviving daemon no longer pass the lock descriptor to it, and the timeout message now names the lock file and how to inspect and clear it by hand.
Read the full entry →
SignedAugust 14, 2026 · 2 images, 5 installer files

Trust and polish for the install path (INSTALL_UPGRADE_PLAN.md phase 4), and the first release signed with a key installers can verify against.

Added

  • DNS is checked before the stack starts. install.sh resolves the dashboard hostname and a random label under the wildcard domain, compares them against this host's addresses, and says so before Let's Encrypt burns a failed challenge against a name that does not reach you. It asks rather than refusing — split-horizon DNS and a pre-cutover install are legitimate — and --skip-dns-check turns it off.
  • --dry-run reports what an upgrade would change: release, image and asset versions, download size against free disk in both filesystems, which settings would change, and whether a snapshot would be taken. It writes nothing.
  • --yes for unattended runs: never prompts, takes the prior value for every question. It deliberately does not answer the post-failure rollback question — that is what --auto-rollback is for.
  • A concurrency lock. Two installers at once raced on .env, versions.env, config.yaml and docker compose up; the second now waits and then refuses.
  • Release signing. make publish can sign a release with a key that is not on the web host (scripts/release-key.sh generates one), and install.sh verifies it against a key embedded in the script, then refuses any artifact whose digest is not in the signed payload. Until now the only integrity check was a digest served by the same origin as the bytes, which catches corruption but not a compromised registry. Unverifiable releases warn and install; DEVGRAIL_REQUIRE_SIGNATURE=1 makes them a refusal. The trust model, the signed payload format, and the key rotation procedure are documented in docs/SECURITY.md.
  • docker-compose.override.yml is supported. The compose file and Traefik config are product-owned and rewritten on every run; local changes belong in the override, which the installer never touches and now passes to every compose invocation. Documented in docs/RUNBOOK.md.
  • README.md with an OS/arch support matrix and minimum RAM/disk, and a LICENSE.
  • A test asserting migration numbers stay unique, so the collision that exists today (005_api_keys / 005_projects) cannot be joined by another.

Changed

  • The registry's "current release" no longer depends on scan order when two releases are published in the same millisecond.
  • The admin dashboard marks each release as signed or unsigned.
  • install.sh installs openssl alongside jq when it is missing.
  • The VPS marketplace / cloud-init packaging described in the spec is now marked explicitly as not built.
  • Publishing a release is one command: scripts/publish.sh takes the version from this file, finds the registry URL, tokens and signing key on its own, and runs on Windows as well as Linux. make publish is unchanged.

Fixed

  • arm64 workspaces get a terminal that runs. The workspace image fetched the x86_64 ttyd binary whatever it was being built for. The pinned digest matched (it is the same file), so the build passed and the breakage only appeared on a customer's arm64 host, where the terminal could not start. The binary is now chosen by TARGETARCH, with a pinned digest per architecture, and an unrecognised architecture fails the build instead of shipping.
Read the full entry →