117 —Drupal
Drupal 7 to static on Cloudflare Pages: a 2-hour playbook
Drupal 7 reached end of life. You inherited one. Here's a two-hour route to a static export on Cloudflare Pages with the contact form still working.
The site is a 2014 Drupal 7 build for a regional law firm. About 140 nodes, a news section nobody updates, a partners grid, four landing pages, and one contact form that still routes inquiries to a shared inbox. The hosting bill is €38 a month for a VPS that hasn't been patched since 2022. The client wants it gone but not broken. You have an afternoon.
This is the playbook we run when a Drupal 7 site needs to come off life support without a real rebuild. The end state is a static export on Cloudflare Pages, the contact form posting to a small worker, and the original legacy site archived as a tarball you can spin back up if anyone asks. Two hours, give or take a coffee.
The shape of the migration
Drupal 7 reached end of life on 5 January 2025. Security advisories stopped. PHP 7.4, which most D7 sites still run on, stopped getting fixes in November 2022. So the threat model is real: any unauthenticated RCE in a contrib module is now a permanent open door. The job isn't to modernise Drupal. The job is to get the HTML off the dying server while preserving what the client actually uses.
For a brochure site with a contact form, that breaks into four moves:
- Crawl the rendered site to flat HTML.
- Rewrite the contact form to post to a worker.
- Publish to Cloudflare Pages with the right redirects.
- Archive the database and Drupal root so you can prove what you migrated.
Each step has one gotcha. We'll hit them in order, with a five-minute inventory up front and a verification pass before the cutover.
The five-minute inventory
Five minutes inside the admin saves an hour of confused recrawls later. Log in at /?q=user, then answer four questions on a scratchpad. Which contrib modules are enabled that affect the rendered output — Views, Panels, Webform, anything that pulls content from query parameters? Which content types have body fields with embedded media — YouTube iframes, inline references to /sites/default/files, oEmbed cards? Does the theme use Drupal's image styles for responsive images, and which derivative sizes does the rendered HTML reference? Are there any forms beyond the contact form — a newsletter signup, a careers application, a search box that hits a Views endpoint?
None of this is paranoia. It's a map of what the crawl will and won't capture. A Views page with exposed filters won't give you the filtered states unless you enumerate the filter URLs by hand. Webform submissions need the same worker-rewrite treatment as the contact form, and they're easy to miss because they often live on careers or quote-request pages the client forgot about. The /sites/default/files directory holds every uploaded image and PDF on the site; if it ends up outside the crawl scope, you ship a site full of broken assets and the client notices within an hour.
Save the answers in a four-line text file next to the project. You'll reference it twice in the next two hours, and again in eighteen months when somebody asks why a particular page still resolves the way it does.
Step 1 — crawl the rendered site, not the source
The temptation is to read node--page.tpl.php and rebuild the templates by hand. Don't. The fastest path is to crawl the live site as a browser would and dump the rendered HTML. wget still does this better than anything written this decade.
wget \
--mirror \
--convert-links \
--adjust-extension \
--page-requisites \
--no-parent \
--reject-regex '(\?|/user/|/admin/|/node/add)' \
--user-agent='Mozilla/5.0 static-export' \
--wait=0.3 --random-wait \
https://oldsite.example.nl/
A few things to know before you hit return. --convert-links rewrites absolute URLs to relative paths, which is what you want for Pages. --adjust-extension turns /about into /about.html, which matters for the redirect rules later. The --reject-regex drops query-string variants of the same page (Drupal loves to expose ?page=1) and the admin paths nobody should be hitting anonymously anyway.
Run it. Make coffee. Come back to a directory tree that mirrors the site.
Clean the output
The crawl will leave you with Drupal-isms in the HTML: jquery.once.js, drupal.js, the Drupal.settings blob, CSS aggregation files named css_8f2a...css. Most of it is harmless on a static host. The piece worth pruning is the inline <script> block that defines Drupal.settings — it usually leaks the basePath, the theme path, and sometimes module configuration that hints at the stack.
find . -name '*.html' -exec sed -i '' \
'/Drupal\.settings/,/};/d' {} +
On Linux drop the empty '' after -i. This is one of those edits where you want to diff a couple of files before you trust the regex.
Step 2 — the contact form, rewritten
The old form posts to /contact and relies on Drupal's form API to validate, send mail through drupal_mail(), and redirect to a thank-you node. None of that survives the export. You need three things on the new stack: a place to POST, a way to send the email, and a thank-you page.
A Cloudflare Worker does the first two in about thirty lines. Bind it to /api/contact on the same Pages project and the form never leaves the origin, which keeps the browser happy about CORS and the client happy about not seeing a third-party domain on their site.
export default {
async fetch(request, env) {
if (request.method !== 'POST') return new Response('Method not allowed', { status: 405 });
const form = await request.formData();
const name = (form.get('name') || '').toString().slice(0, 200);
const email = (form.get('email') || '').toString().slice(0, 200);
const message = (form.get('message') || '').toString().slice(0, 5000);
const honeypot = (form.get('website') || '').toString();
if (honeypot) return Response.redirect('https://example.nl/thanks/', 303);
if (!email.includes('@') || message.length < 10) {
return new Response('Invalid submission', { status: 400 });
}
await fetch('https://api.postmarkapp.com/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Postmark-Server-Token': env.POSTMARK_TOKEN,
},
body: JSON.stringify({
From: 'web@example.nl',
To: 'info@example.nl',
Subject: `Contact form: ${name}`,
TextBody: `From: ${name} <${email}>\n\n${message}`,
MessageStream: 'outbound',
}),
});
return Response.redirect('https://example.nl/thanks/', 303);
},
};
Then patch the captured HTML so the form points at the worker and ditches Drupal's tokens.
find . -name '*.html' -exec sed -i '' \
-e 's|action="/contact"|action="/api/contact"|g' \
-e '/name="form_build_id"/d' \
-e '/name="form_token"/d' \
-e '/name="form_id"/d' {} +
Add a honeypot field by hand to the contact template — a hidden input named website — and keep the existing field names so you don't have to retrain the inbox filters the client built up over a decade.
Step 3 — publish, with the redirects that matter
Drop the crawled directory in a git repo, push it, point Cloudflare Pages at it. The build command is empty; the output directory is .. That part takes four minutes.
The part that takes the rest of Step 3 is the redirect file. Drupal sites accumulate URL aliases over years, and the crawl gave you .html extensions you don't want exposed. Cloudflare Pages reads a _redirects file at the root.
/node/12 /diensten/ 301
/node/47 /over-ons/ 301
/contact /contact/ 301
/*.html /:splat/ 301
/sites/default/files/* /assets/:splat 301
The /node/* lines are the ones that bite you a week later if you skip them. Old inbound links from LinkedIn posts and PDFs still hit /node/12, and without a map they 404 silently. You can dump the alias table out of Drupal before you tear it down:
SELECT CONCAT('/', source) AS old,
CONCAT('/', alias, '/') AS new
FROM url_alias
ORDER BY pid;
Paste the output into _redirects, prepend 301 to each line, and you've covered the long tail. Cloudflare's redirect docs are worth a skim — there's a 2,000-line cap per file, which is more than enough for a brochure site but worth knowing.
Verify before flipping DNS
The temptation after Step 3 is to swap the A record and call it done. Don't. The static deployment behaves differently from Drupal on five things you can check in fifteen minutes, and any of them can embarrass you in front of the client.
First, drop the TTL on the existing record to 300 seconds at least 24 hours before the planned cutover. If you skip this and have to roll back, you're at the mercy of whatever TTL the registrar set originally — usually a day, sometimes longer. Cloudflare's explainer on TTL behaviour is worth a five-minute read if it's been a while.
Second, run a link-check against the Cloudflare Pages preview URL before flipping DNS. wget --spider --recursive --no-verbose against the preview deployment prints every 404 and 500 it encounters; pipe the output through grep -E 'broken|failed' and you have a finite punch list instead of a vague feeling.
Third, submit the contact form yourself with a real payload, from a phone on cellular data so you're not getting a cached response from your office IP. Watch the inbox. If the worker is silently dropping mail because Postmark rejected the From address as an unverified sender signature, you want to find that out before the client does.
Fourth, sample the redirects with curl -sI. The homepage, the contact page, three random /node/* URLs from the old alias dump. You're looking for 301 with a Location header pointing at the new alias, not a Pages 404 in disguise.
Fifth, view the source of the deployed pages in a browser. The Drupal.settings blob you removed in Step 1 is the obvious tell, but some D7 themes also embed the editor's username or last-edit timestamp as an HTML comment. Strip anything you don't want a competitor's developer reading.
Step 4 — archive the original so it's recoverable
Before you cancel the VPS, take two artifacts. One mysqldump of the Drupal database, one tarball of the docroot. Put them both on cold storage with a README that says what PHP and MySQL versions they ran against. This is the bit people skip and regret eighteen months later when a board member asks for a press release that lived on the old news section.
mysqldump --single-transaction --routines --triggers \
-u drupal -p drupal7db | gzip > drupal7-final.sql.gz
tar --exclude='sites/default/files/styles' \
-czf drupal7-docroot.tar.gz /var/www/oldsite
The styles directory holds Drupal's derivative images — thumbnails, scaled versions, the cropped variants the theme generates on the fly. They're regenerated from the originals, so excluding them shaves a few hundred megabytes off the archive. Keep the dump file and the tarball together in a single folder named with the cutover date and the original domain; future-you will not remember either by the time anyone asks.
What this gets you
An afternoon's work moves the site from a vulnerable PHP 7.4 box to a static deployment with TLS, CDN, and DDoS protection at €0/month for the volumes a regional law firm sees. The contact form still works. The URLs still resolve. The client doesn't notice anything except that the site got faster.
What you give up is the CMS. If the client edits content more than once a quarter, this is the wrong move and you should be looking at a real rebuild. For everyone else — and there are a lot of everyone else, sitting on Drupal 7 sites that haven't been touched since 2019 — the static export is the honest answer.
When we built Pier the awkward part of jobs like this was always the database side: poking around url_alias, node, and field_data_body to figure out what's actually on the site before you crawl it. The way we ended up handling it was a built-in MySQL editor docked next to the file tree, with version history on every query so you can experiment against the live database without having to keep a separate dump open in a second window.
The smallest thing you can do today: run wget --mirror against one of the dormant Drupal sites in your portfolio, just to see how clean the output is. That alone tells you which clients are a two-hour job and which need a real conversation.
— Questions —
Does the contact form still work after the static export?
Yes, if you route it to a small Cloudflare Worker that handles validation and sends mail through Postmark or similar. The Drupal form tokens get stripped during the crawl.
What about old /node/123 URLs from external links?
Dump the url_alias table from MySQL before tearing down Drupal, then write each old path to a 301 in the Cloudflare Pages _redirects file. It catches the long tail.
Can I keep editing content after the migration?
Not without a rebuild. The static export is the right call for sites edited rarely. If the client updates monthly or more, look at a real CMS migration instead.
Why not just keep Drupal 7 running on a patched LTS host?
Drupal 7 stopped receiving security advisories in January 2025. Vendor patches still help with the OS but not with contrib module RCEs, which is where most exploits land.