← Blog

Drupal

Drupal 7 rescue: when staging was production all along

A Utrecht housing cooperative hired us to migrate their Drupal 7 site before EOL. Two hours in we realised staging and production were the same machine.

Jacob Molkenboer· Founder · A Brand New Company· 4 Jun 2026· 9 min
Open leather logbook with brass tag, green index card marker, iron key on paper sleeve, ivory desk, side light.

It was a Monday, 23:14. We were two hours into what was supposed to be a smoke test on the staging environment of a Utrecht housing cooperative's Drupal 7 site. Drupal 7 official end-of-life had passed on 5 January 2025, and the cooperative had finally signed off on the migration to Drupal 10. The staging URL looked normal. We clicked into a tenant's account page to verify a custom field. The page loaded a real IBAN, a real address, and a maintenance request logged that afternoon.

Staging was production. Production was staging. The team had been editing live for six years.

How we found out

The tell was small and we almost missed it. The "staging" URL was something like nieuw.cooperatief.nl. We logged in with the test credentials the in-house developer (long since departed) had documented in a 2019 Google Doc. A tenant note we added on staging at 23:11 appeared in a CSV export the cooperative's office assistant ran the next morning from what she called "the real site". She thought it was funny. We did not.

The architecture, once we pulled it apart, was this:

  • One LAMP server at a Dutch hoster (one of the cheap ones).
  • Two vhosts, cooperatief.nl and nieuw.cooperatief.nl, both pointing at the same docroot.
  • One settings.php. One database. One sites/default/files.
  • A single $base_url override based on $_SERVER['HTTP_HOST'].

Every "test" anyone had run since 2019 had written into the real database. Every PDF rendered on staging had been written into the same files directory the production site served from. The cooperative's tenants had been clicking through test PDFs without knowing it for six years.

Warning

If your staging URL serves real customer data when you change a record, you do not have a staging environment. You have a production environment with a second domain name.

What we did in the first 24 hours

The migration was no longer the priority. The priority was stopping data corruption while we worked out what was there. We did three things that night.

First, we put the staging vhost behind a deny-from-all in Apache so no internal tester could write to it by accident:

<VirtualHost *:443>
    ServerName nieuw.cooperatief.nl
    DocumentRoot /var/www/cooperatief
    <Location />
        Require ip 10.0.0.0/8
        Require all denied
    </Location>
</VirtualHost>

Second, we took a clean drush sql-dump and an rsync of sites/default/files to an off-server location. The hoster's nightly backup window was 03:00 and we did not want to find out at 03:15 that "nightly" actually meant "weekly, with luck".

Third, we wrote a one-paragraph email to the cooperative's director that started with the word "urgent" and ended with "do not let anyone log in tonight". She did not love it. She did call back inside ten minutes.

The audit

The next morning we mapped what was actually running. Six years of "we'll fix it next quarter" had layered up.

The site was Drupal 7.78. The last D7 security release at the time was 7.103. Twenty-five intervening releases included three SA-CORE advisories rated Highly Critical, including Drupalgeddon 2 (which had been patched, late) and SA-CORE-2018-004 (which had not). The site was running on PHP 7.2, which itself reached end-of-life in November 2020.

Modules told a similar story. Forty-one contrib modules installed. Eleven had a security advisory open. Three were unmaintained. The custom module that handled tenant invoice generation was a 2,400-line file called cooperatief_custom.module with no tests, two TODOs from 2017, and one developer's name in every comment.

Files were worse. sites/default/files was 38GB. Of that, 31GB was a folder called old_backups containing SQL dumps from 2018, 2019, and 2020. They were world-readable through the web. We checked. Yes, you could download them. No, we did not tell anyone how we knew.

Warning. If your Drupal files directory contains a folder called old_backups, archive, or private_temp, open example.com/sites/default/files/old_backups/ in a fresh browser right now. If you can see a directory listing, you have a data breach in progress.

The migration plan

Drupal 7 official end-of-life passed on 5 January 2025. After that date, the Drupal Security Team no longer publishes advisories for Drupal 7 core or contrib. Vendor extended support is available from a handful of Drupal Association partners, and it is not cheap. The cooperative did not want to pay for extended support indefinitely. They wanted to be on a supported version of Drupal by the end of the fiscal year.

We did not lift-and-shift. Drupal 7 to Drupal 10 is not an upgrade. It is a rebuild with a migration script. The architecture changed in Drupal 8 (Symfony, Twig, configuration management as YAML, Composer-managed dependencies), and the only sensible path is to scaffold a clean Drupal 10 install and use the Migrate Drupal module to pull content over.

The plan was four phases:

  1. Freeze. Stop all writes to the old database except the ones tenants needed to make (maintenance requests, account updates). Snapshot weekly.
  2. Scaffold. New Drupal 10 codebase, Composer-managed, on a separate server, with a real staging environment (separate database, separate files, separate everything).
  3. Migrate. Map each Drupal 7 content type to a Drupal 10 equivalent. Rewrite the custom invoice module as a small Symfony service. Move files to the new server with checksums.
  4. Cutover. DNS flip during a maintenance window, with the old site frozen and read-only for two weeks afterwards.

The migrate config that did most of the work

The bulk of the content migration was thirty-seven YAML files defining migration plugins. Drupal's migrate_plus and migrate_drupal handle most of the standard entities (users, taxonomy, nodes, files) out of the box. The pattern looks like this:

id: cooperatief_tenant_node
label: 'Tenants from Drupal 7'
migration_group: cooperatief
source:
  plugin: d7_node
  node_type: tenant
process:
  nid: tid
  title: title
  uid:
    plugin: migration_lookup
    migration: d7_user
    source: node_uid
  field_iban:
    plugin: get
    source: field_iban/0/value
  field_address:
    plugin: sub_process
    source: field_address
    process:
      country_code: country
      locality: city
      postal_code: postal_code
      address_line1: thoroughfare
destination:
  plugin: 'entity:node'
  default_bundle: tenant
migration_dependencies:
  required:
    - d7_user
    - d7_file

You write one of these per content type. You run drush migrate:import cooperatief_tenant_node against a copy of the Drupal 7 database. You read the error log, fix the source field mapping, run it again. The third time it works. The fourth time it works and is fast enough to run on the real data.

The cutover

We picked a Sunday at 06:00. Tenants would not be filing maintenance requests at 06:00 on a Sunday. The office assistant agreed to be on Slack from 05:45 in case anyone called.

The checklist was 41 items long. The first item was "verify the staging URL is not production". We laughed about it. We checked it anyway, twice. The seventh item was "run final migration with --update against snapshot from 05:00". The thirty-eighth item was "flip DNS TTL to 60 seconds at least 24 hours before". We had done that on the Friday.

At 06:47 the new site was live. Two tenants logged in before 09:00, neither noticed anything had changed except the search was faster. At 11:00 a board member emailed to ask why the contact form looked "different". It did not look different. We replied politely and asked for a screenshot. We never got one.

What we changed about how we work

The thing that stuck with us was not the migration. It was the original sin. A cheap hoster, a junior developer, six years of "we'll fix it next quarter", and a director who trusted the word "staging" because she had no reason not to.

We added one item to every audit we run now: open the staging URL, change one trivial thing, then check whether production sees the change. It takes ninety seconds. It would have saved this cooperative six years of latent risk.

There is a related thread that keeps coming up in our work: the gap between what a small team believes their infrastructure looks like and what it actually looks like. The same gap shows up wherever someone trusts a system they cannot inspect, whether that is a CMS, a CI pipeline, or an AI coding assistant. The fix is always the same. Open it up. Change one trivial thing. See what happens.

If you cannot describe, in two sentences, how a write on your staging environment is prevented from reaching production, you do not have a staging environment.

Five-minute audit you can run today

You do not need us for this part. If you run a Drupal site, a WordPress site, or any CMS with a "staging" subdomain, do these five things this afternoon:

  1. SSH into your staging server. Run grep -i 'database' sites/default/settings.php (Drupal) or grep -i DB_NAME wp-config.php (WordPress). Compare to production. If the database name is the same, you do not have staging.
  2. Compare the document root path between vhosts. Same path means same files.
  3. On staging, edit one node title. Save. Reload production. If the title changed, stop reading and call someone.
  4. Open example.com/sites/default/files/ in an incognito window. If you see a directory listing, add Options -Indexes to your Apache config tonight.
  5. Check your Drupal core version against the release list. If you are on Drupal 7 and not paying for vendor extended support, you are running unpatched code.

When we did the rebuild for the cooperative we ended up writing a short legacy migration playbook that catches this exact pattern in the first hour of any audit. The tell is almost always in the Apache config, and it almost always goes back to a hoster who set up "staging" by adding a second ServerName to the existing vhost.

Open your apache2.conf today. Read it. If you see two ServerName directives inside one VirtualHost block, you already know what to do.

Key takeaway

If you cannot describe in two sentences how a write on staging is prevented from reaching production, you do not have a staging environment.

FAQ

When did Drupal 7 reach end of life?

Drupal 7 reached official end-of-life on 5 January 2025. After that date, the Drupal Security Team no longer publishes core or contrib advisories. Paid vendor extended support is available from a handful of Drupal Association partners.

Can I upgrade Drupal 7 in place to Drupal 10?

No. Drupal 8 changed the architecture completely. You scaffold a clean Drupal 10 install and use the Migrate Drupal module to pull content from the old database. The codebase, theme, and custom modules are rebuilt.

How long does a Drupal 7 to Drupal 10 migration take?

For a mid-sized site (10 to 30 content types, 5k to 50k nodes) plan on six to twelve weeks. The migration itself is fast. Content-type mapping, custom-module rewrites, and the theme rebuild take the time.

What is the fastest way to check if staging is really separate from production?

Change one node title on staging, reload production. If the title changed, your environments are not separated. The check takes under two minutes and finds the worst class of misconfiguration.

drupallegacy sitesmigrationsecurityphpcase study

Building something?

Start a project