A Drupal 11 Upgrade, a Missing Setgid Bit, and a Four-Minute Outage

I upgraded this site from Drupal 10 to Drupal 11. The Drupal part went fine, eventually. What actually took the site down for four minutes had nothing to do with Drupal at all — it was a missing setgid bit, and I only found it because I've learned not to trust a Drupal error log to tell me the whole story.

Two blockers before core would even move

I'd already worked through the easy upgrades — patch releases, a couple of major-version bumps on build tooling, drush 12 to 13 — before touching Drupal core itself. Running composer require drupal/core-recommended:^11 should have been the anticlimactic last step. Instead it failed immediately, and composer's dependency solver gave me forty lines of "found but not loaded, likely because it conflicts with another require" for every single 11.x release in a row, which is composer's way of telling you something is wrong without telling you what. The actual blocker, once I dug it out, was drupal/advagg — an advanced CSS/JS aggregation module I had enabled and genuinely using. It has never had a release beyond 6.0.0-alpha1, and that alpha only declares support for Drupal 9 and 10. No newer tag exists. Drupal 11 compatibility for it is still an open issue upstream, worked on in patches nobody's shipped as a release yet. Here's the thing that made the decision easy: Drupal core has covered async aggregate generation and JS minification natively since 10.1. I checked, and system.performance already had both css.preprocess and js.preprocess turned on. Advagg wasn't providing anything the site would actually lose by removing it — it was just an extra, now-abandoned layer sitting on top of functionality core already handled. Uninstalled it, removed the dependency, moved on. The second blocker was smaller but more embarrassing, because I'd already seen it coming and ignored it. Earlier in the process, a routine composer outdated scan had flagged consolidation/robo as a major version behind — 4.x installed, 5.x available — and I'd filed it under "low priority, not investigated" along with a couple of other build-tooling packages. It turns out robo 4.x pins symfony/console to ^6, and Drupal 11 needs ^7. "Low priority" and "hard blocker" were the same package the whole time; I just hadn't traced the dependency graph far enough to notice.

The upgrade that worked, and the outage that didn't

With both of those cleared, drupal/core actually installed: 10.4.6 to 11.4.5. Update hooks ran clean. Config re-exported without surprises — the usual major-version housekeeping, an RSS settings object deprecated and removed, a few views gaining new default properties. Locally, everything checked out: no PHP errors on a fresh page load, the site's custom hooks still behaved correctly, a full round-trip through the JSON:API publishing pipeline worked exactly as before. Then I deployed the same sequence to production, and the homepage started returning a bare 403 Access denied to every visitor. The important detail is what that error page looked like: plain text, no Drupal theming around it, nginx's own server header and nothing else. Drupal generates its own access-denied pages, with the full site theme wrapped around them. A response with none of that means Drupal was never invoked at all — the request died before PHP even started running application code. That ruled out an entire category of things I might otherwise have gone chasing: broken module code, a bad permission on a route, a Drupal-level access check gone wrong. None of that was reachable yet. So I skipped drush entirely and went straight to /var/log/nginx/ssl-www.linkhub.dk.error.log, and the actual error was sitting right there: "Unable to open primary script: index.php (Permission denied)." PHP-FPM runs as www-data. The freshly-scaffolded index.php — regenerated by composer as part of the Drupal 11 upgrade, like it is on every install — had been written with group ownership ln, the deploying user, instead of www-data. Every file that had survived from before was correctly group-owned by www-data; every file composer had just written wasn't. The web root's permissions had never actually enforced that — nothing was propagating the right group to new files, it had simply never come up before, because nothing had rewritten index.php itself since whatever process originally set it up correctly. Fixing the immediate problem was one command: chown -R ln:www-data across the docroot, and the site was back within a few minutes of the first report. Fixing the actual problem was chmod g+s on the web root and vendor directory — the setgid bit, so that any file created inside from now on inherits the directory's group automatically instead of the creating user's. That's the fix that should mean this specific failure doesn't come back the next time a deploy rewrites scaffold files.

What I'm taking from this

The Drupal-specific lessons are narrow and mostly already written down: check whether a contrib module actually has a release that supports your target core version before you're mid-upgrade and find out the hard way, and don't file a major-version bump as "low priority" without at least glancing at what it requires — the dependency graph doesn't care about your priority queue. The more useful lesson generalizes past this one upgrade. A command exiting successfully is not the same claim as "the site is up." composer install returned 0. Every step I'd scripted had run without error. The only thing that actually caught the outage was checking the real HTTP response afterward — and the only thing that diagnosed it correctly was noticing that the error page's total absence of Drupal branding was itself a data point, one that pointed straight at the layer below the application before I'd wasted time debugging the application.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.