Short answer: treat the upgrade as an ordinary release of the same site. Build it and test it away from production, rehearse it on a copy filled from production until the configuration comes out clean, and then ship it the way every release ships: a tested container image, one deploy command, a health check, and a backup taken just before. None of that needs a planned outage. It needs a site that can be rebuilt from code, and a way back that is written down before you start. Here are drupal.org's steps, what we add to them, and the traps we have paid for.
What does "without a downtime window" mean?
It means no planned outage: no evening with a maintenance page while someone upgrades the site by hand. The site keeps serving the current release while the new one is built and tested elsewhere. The switch itself is a container swap followed by drush deploy.
It does not mean nothing can fail during that switch. One of our own scars, below, is four requests that failed in the seconds between a new container starting and the deploy finishing. We fixed it. The point is to know where those seconds are.
Our founder drove the Drupal 9 → 11 cutover across a ~400-site healthcare fleet, on live production data, where no organisation on it could accept a downtime window (2023–2026). That is his record from before Tecnocratica. Our own sites started on Drupal 11.3 in April 2026, so none of them has made the 10 → 11 jump. They have gone through a minor upgrade (11.3 to 11.4, July 2026), then two security releases and a bug-fix release. The method below grew out of those releases: the health gate came in July 2026, the test gate and the rehearsal rule in October.
What are drupal.org's steps for Drupal 10 to 11?
drupal.org's guide (updated 21 August 2026), in short:
- Be on Drupal 10.3.0 or later (in practice, the latest 10.6.x), on PHP 8.3 or later.
- Replace with their contributed versions, or stop using, the modules Drupal 11 removed from core: Actions UI, Activity Tracker, Book, Forum, Statistics and Tour.
- Find what isn't ready with Upgrade Status, and fix deprecated code with Drupal Rector.
- Require Drupal 11 with Composer (
drupal/core-recommended:^11and its scaffold and project-message packages), update Drush, and allow thesymfony/runtimeComposer plugin, which core added as a dependency in 11.4. - Run
composer update, thendrush updatedb. - Deploy, then check the logs and run cron.
The guide adds: "Ideally, first perform the update locally in DDEV, and then deploy to your server." It says nothing about backups, maintenance mode or downtime. The rest of this article is what "then deploy" looks like on a site that can't go dark.
Step 1: can the site be rebuilt from code?
Before any upgrade, the code and the configuration have to live in git, the exported configuration (config/sync) has to match production, and the build has to produce the site from the lock file. If production holds configuration that git doesn't, the upgrade will overwrite it or trip over it.
Our sites start from our public skeleton, so this holds from the first commit. On a site we inherit, getting there is the first piece of work.
Step 2: rehearse on a copy of production
Tests install a fresh site, so they never see production's database. The rehearsal does:
- Fill a local copy from production, database and files. It goes one way only: content never travels from a local copy back to production.
- Put the Drupal 11 code on it and run
drush deployexactly as production will: database updates, configuration import, cache rebuild. - Run
drush config:status. It must report no differences.
Repeat until it does. Our platform makes this rehearsal compulsory for any release that adds, removes or replaces a module, which every 10 → 11 upgrade does. Two of the traps below showed up there and nowhere else before production.
Step 3: ship it like any other release
On our platform a release goes through four gates:
- CI runs the tests on a fresh install against MariaDB and audits the runtime dependencies for known advisories, then builds a container image. A failing test means no image, and so no deploy.
- The deploy pulls that image, starts it and runs
drush deploy. The pipeline never touches the database or files volumes except through the deploy's own updates. - The health gate then requests the home page and requires HTTP 200, at least 5,000 bytes and no fatal-error markers, retrying for up to two and a half minutes.
- Behind Cloudflare, anonymous readers keep getting cached pages meanwhile. After the deploy, only the pages whose HTML changed are purged and fetched again.
Step 4: write the way back before you need it
Right before the upgrade, take a snapshot: the image running now, and a database dump taken now. Then:
- if the site breaks and the database schema did not change, start the previous image again;
- if
drush deployalready ran database updates, a step drupal.org's procedure includes, restore the dump too. Anything written between the snapshot and the rollback is lost, which is why the snapshot is taken immediately before.
Which traps have we paid for?
Each is written up, with its fix, in the BATTLE_SCARS file of our public skeleton:
- A security update that looked applied and wasn't (§18). Core from 11.3.12 pulls
symfony/runtime, a Composer plugin. Blocked, it stopped the install after Composer had already written the new versions to the lock file.composer auditread the lock and said clean; the running code was still the old version. Check the installed version (drush status), not only the lock. - A green build that served a fatal error for about three and a half days (§19). The same plugin, allowed as
falseinstead oftrue: Composer installed it but skipped the file it generates, and every request needs that file. The broken site answered HTTP 200 with a 500-byte body. That is why our health gate checks the size, not only the status. - A module removed in the same deploy that uninstalls it (§41). Uninstalling needs the module's code. Take it out of Composer in the same push and the configuration import stops with "The module X does not exist". So a module leaves in two deploys: uninstall it with the code still present, then remove the code.
- A Spanish-default site whose configuration never came out clean (§42). On a site whose default language isn't English, the locale module rewrites the language code of imported configuration, so
config:statusshowed dozens of false differences after every deploy. Export from a site that has run the import. - Requests served by the previous release's code (§43). Drupal caches its compiled service container in the database, under a key that doesn't cover the site's own code. For a few seconds after a new image started, requests used the old definitions, and four failed. One key per image fixed it.
- A base image its publisher had stopped building (§47). Our image pinned a PHP 8.3 tag of the official Drupal image that was last updated in January 2026, so PHP's security release of October 2026 never reached it. We moved to PHP 8.4. An upgrade is a good moment to check that your PHP really is current.
How long does it take, and what does it cost?
We don't give a duration before reading the code. It depends on how many contributed modules you run, how much custom code there is, and how far production has drifted from git. An hour of our founder's time costs USD 75. It is the way to start: you show us the site, and we tell you where the work is. Today the engineer is our founder, so we work business hours on Colombian time (UTC-5), with no 24/7 on-call.
What about the deadline, and Drupal 12?
Drupal 10 reaches end of life on 9 December 2026 (what to do, and by when). Drupal 12 is due the same week. It needs PHP 8.5 and a site already on Drupal 11.3 or later. Landing on Drupal 11.4 now is the step both ask for.
To talk about your upgrade, see what we offer, write to us, or ask the concierge on our home page.