Skip to content
All articles
8 min read

Stripe Webhook Signature Verification Failed: Fix

MD Rakibul Islam RakibMD Rakibul Islam RakibFull-stack developer, DevOps & Linux engineer
Stripe Webhook Signature Verification Failed: Fix

Stripe webhook signature verification fails when your code checks a parsed or changed body, or uses the wrong whsec_ secret. Verify the raw bytes instead.

The error looks like this: StripeSignatureVerificationError: No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe? It's one of the most common payment bugs I fix, and it's nasty because it often works on your laptop and breaks only after deploying. Meanwhile orders stay unpaid in your database while the money sits in Stripe. Here is how signature checking works, the six causes I see, and the exact fix for Express, Next.js and NestJS.

Key takeaways

  • Stripe signs the exact bytes it sent. If a JSON parser reads the body first and you re-stringify it, the bytes change and the signature no longer matches.
  • Every endpoint has its own secret. The whsec_ from stripe listen is not the one from your dashboard endpoint, and test mode and live mode secrets differ.
  • Express: use express.raw({ type: 'application/json' }) on the webhook route only. Next.js App Router: await req.text(). NestJS: rawBody: true and req.rawBody.
  • Timestamps matter: the default tolerance is 300 seconds, so a badly wrong server clock also fails verification.
  • Return 2xx fast and handle each event once, keyed by event.id, because Stripe retries failed deliveries.

How Stripe webhook signatures work

When Stripe sends an event to your endpoint, it adds a Stripe-Signature header with a timestamp (t=) and one or more signatures (v1=). The signature is an HMAC-SHA256 of the timestamp plus the raw request body, made with your endpoint's signing secret. Your server repeats the calculation with the same secret and body. If the results match, the event really came from Stripe and wasn't changed on the way.

The important word is raw. JSON.parse followed by JSON.stringify can change spacing, key order, number formatting and unicode escapes. Even one different byte gives a completely different HMAC. That's why the official libraries ask for the body as a string or a Buffer, exactly as received.

Stripe signs bytes express.json() re-stringified: bytes changed raw Buffer same bytes Stripe signed ✗ no match ✓ verified
Stripe signs the exact bytes it sends. A JSON parser that runs first hands your code a re-serialised copy, so the HMAC no longer matches. Pass the untouched raw body to constructEvent.

The six causes, most common first

1. A JSON body parser runs before your webhook handler

In Express, app.use(express.json()) at the top of the app parses every request, including the webhook. By the time your handler runs, req.body is an object, and passing it (or JSON.stringify(req.body)) to Stripe fails. Register the webhook route with a raw parser before the global JSON parser:

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,                              // Buffer, untouched
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET,
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
  // handle event...
  res.json({ received: true });
});

app.use(express.json()); // everything else

2. The wrong signing secret

This one catches people after deployment. There are several secrets and they're all called whsec_...:

  • Stripe CLI: stripe listen --forward-to localhost:3000/api/webhooks/stripe prints its own secret. It's only valid for that CLI session's forwarded events.
  • Dashboard endpoint, test mode: each endpoint you add under Developers → Webhooks has its own secret.
  • Dashboard endpoint, live mode: a different endpoint with a different secret.

Production needs the secret of the live-mode endpoint that points at your production URL. If you have two endpoints for one URL, each event is signed with its endpoint's secret, so check you're reading the right one. Also strip stray quotes or spaces from the environment variable.

3. Next.js reads the body as JSON

In the App Router, read the body as text and don't call req.json() first:

// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature')!;
  let event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }
  // handle event...
  return Response.json({ received: true });
}

In the older Pages Router, API routes parse JSON by default. Turn it off for this route with export const config = { api: { bodyParser: false } } and read the raw stream (for example with the micro package's buffer(req)).

4. NestJS without rawBody

Nest parses JSON for you, which is what you want everywhere except the webhook. Since Nest 9 you can keep a raw copy alongside the parsed body:

// main.ts
const app = await NestFactory.create(AppModule, { rawBody: true });

// webhook.controller.ts
@Post('webhooks/stripe')
handle(@Req() req: RawBodyRequest<Request>, @Headers('stripe-signature') sig: string) {
  const event = this.stripe.webhooks.constructEvent(req.rawBody!, sig, this.secret);
  // handle event...
}

If req.rawBody is undefined, check that the option is set where the app is created and that no custom body parser middleware replaced Nest's.

5. Something between Stripe and your app changes the body

Proxies and platforms can rewrite requests. Things I've seen: a serverless wrapper that base64-encodes the body, a framework middleware that trims whitespace, and an API gateway that re-encodes JSON. Plain Nginx proxy_pass passes the body through untouched, so on a normal VPS this is rarely the cause. To check, log the length and the first characters of the body you verify and compare them with the event payload shown in the Stripe dashboard.

6. The server clock is wrong

The signature includes a timestamp, and the libraries reject events older than the tolerance, 300 seconds by default, to stop replay attacks. The error then says the timestamp is outside the tolerance zone. On a Linux server, check with timedatectl that "System clock synchronized" says yes.

Test the fix locally before deploying

stripe listen --forward-to localhost:3000/api/webhooks/stripe
# in a second terminal
stripe trigger payment_intent.succeeded

Use the secret printed by stripe listen in your local .env. You should see [200] responses in the CLI output. After deploying, open your endpoint in the dashboard and use the resend button on a recent event to check production.

Make the handler production-ready

  • Respond fast. Verify, store the event, return 200, and do slow work (emails, PDFs) afterwards. Slow responses look like failures to Stripe.
  • Be idempotent. Stripe can deliver the same event more than once. Save event.id and skip events you've already processed, or make updates conditional, for example "mark paid only if still pending."
  • Don't rely on webhooks alone. On this site's checkout, a scheduled job also asks Stripe about orders still marked unpaid, so a missed webhook or a closed browser tab can't leave a paid order stuck.
  • Watch the failures. The endpoint page in the Stripe dashboard lists every failed delivery with the response your server gave. Check it after each deploy.

If the webhook request fails in the browser-to-API part of your app instead, my CORS error guide may be the one you need, and if your API returns 502 to Stripe, see fixing Nginx 502 Bad Gateway.

Frequently asked questions

Why does my Stripe webhook work locally but fail in production?

Usually because production uses a different signing secret. The secret from stripe listen only works for CLI-forwarded events; production needs the secret of the live-mode endpoint you created in the dashboard for your production URL.

Can I skip signature verification?

No. Without it anyone who finds your webhook URL can send a fake "payment succeeded" event and get your product for free. Verification is what proves the event came from Stripe.

What does "Webhook payload must be provided as a string or a Buffer" mean?

Your code passed a parsed JavaScript object to constructEvent. Pass the raw body instead: a Buffer from express.raw(), the string from await req.text(), or req.rawBody in NestJS.

Does Stripe retry failed webhooks?

Yes. In live mode Stripe retries a failed delivery for up to three days with increasing delays, so fixing the bug quickly lets most missed events arrive on their own. You can also resend individual events from the dashboard.

How do I find the right webhook secret?

In the Stripe dashboard go to Developers → Webhooks, open the endpoint that points at your URL, and reveal its signing secret. Make sure the dashboard is in the same mode, test or live, as the API keys your server uses.

Payments still not updating?

I fix Stripe integrations in Node.js, Next.js and NestJS apps: webhooks, subscriptions, refunds and the orders that got stuck along the way. Book my React, Next.js and Node.js bug fix service, see my web development services, or contact me with the error message and your framework.

MD Rakibul Islam Rakib

Written by

MD Rakibul Islam Rakib

Full-stack developer, DevOps engineer and Linux system administrator with 5+ years of production experience. I deploy, harden and fix servers and web apps for clients worldwide, and everything in this article runs on real servers I manage, including this site.

  • Stripe webhook signature verification failed
  • No signatures found matching the expected signature
  • Stripe raw body
  • constructEvent
  • Next.js Stripe webhook
  • NestJS rawBody
  • whsec

Keep reading

Cloudflare in Front of Your VPS: Setup Checklist
DevOpsOct 7, 2026

Cloudflare in Front of Your VPS: Setup Checklist

Cloudflare in front of a VPS: use Full (strict) SSL, restore real visitor IPs in Nginx, and firewall the origin so only Cloudflare reaches ports 80 and 443. Cloudflare's free plan gives a small business site a global CDN, DDoS protection and free HTTPS at the edge, and setup looks like "change your nameservers, done". The problems show up later: an endless redirect loop, logs and rate limits that only ever see Cloudflare's IPs, attackers going around Cloudflare straight to the server's IP, or a checkout page cached for everyone. This is the checklist I use when I put a client's VPS behind Cloudflare, with the Nginx and UFW commands for Ubuntu. Key takeaways Use Full (strict), never Flexible. Flexible sends traffic to your server unencrypted and causes redirect loops with an HTTPS redirect on the origin. Give the origin a real certificate: Let's Encrypt or a free Cloudflare Origin CA certificate. Restore the visitor's IP with Nginx's real_ip module and the CF-Connecting-IP header, or fail2ban and rate limits see only Cloudflare. Lock the origin so ports 80 and 443 only accept Cloudflare's IP ranges, and don't leak the server IP through other DNS records. Cache static assets, not personal pages. Never cache HTML for logged-in users, carts or dashboards. 1. Add the site and check every DNS record When you add a domain, Cloudflare scans your existing DNS records. The scan misses things, so compare it against an export from your current DNS provider before switching nameservers: MX, TXT (SPF, DKIM, DMARC, verification records), CNAMEs for subdomains and any CAA records. Missing an MX record breaks your email the moment the nameservers change. Then decide which records are proxied (orange cloud) and which are DNS only (grey cloud): Proxied: the website and app hostnames: example.com , www , app . DNS only: mail servers (MX targets), anything using non-web ports like SSH or a database, and services that must see the client directly. Only HTTP and HTTPS on standard ports (plus a short list of alternates) go through the proxy on the free plan, so an SSH hostname must be grey-clouded. 2. Set SSL/TLS to Full (strict) This setting controls how Cloudflare talks to your server . The visitor always sees HTTPS either way, which is why the wrong choice can go unnoticed. Flexible: Cloudflare connects to your server over plain HTTP. Traffic between Cloudflare and your server is unencrypted, and if Nginx redirects HTTP to HTTPS you get ERR_TOO_MANY_REDIRECTS , because every request arrives as HTTP and gets redirected forever. Full: HTTPS to the origin, but any certificate is accepted, even expired or self-signed ones. Full (strict): HTTPS with a valid certificate checked. This is the one you want. Newer zones may show "Automatic SSL/TLS" instead, where Cloudflare picks the most secure mode your origin supports. That's fine as long as your origin has a valid certificate so it can choose Full (strict). You can check or override it under SSL/TLS → Overview. 3. Give the origin a certificate Two good options: Let's Encrypt with Certbot. The HTTP challenge works through the proxy in most setups. The more robust option is the DNS challenge with the Cloudflare plugin and a scoped API token (Zone → DNS → Edit for this zone only), which also works before DNS points at the server. Cloudflare Origin CA. Generate it under SSL/TLS → Origin Server, with a validity of up to 15 years, and install the certificate and key in Nginx. It's trusted by Cloudflare only, not by browsers, so it breaks if you ever switch a record to DNS only or pause Cloudflare. If renewals start failing later, my Certbot renewal guide covers the Cloudflare-specific causes. 4. Restore real visitor IPs in Nginx Behind the proxy, every request comes from a Cloudflare IP. Your access logs, fail2ban, rate limiting and any "block this IP" rule become useless, or worse, ban Cloudflare. Cloudflare sends the real IP in the CF-Connecting-IP header; Nginx's real IP module can use it, but only for requests from Cloudflare's ranges. Generate the config from Cloudflare's published lists: { for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do echo "set_real_ip_from $ip;" done echo "real_ip_header CF-Connecting-IP;" } | sudo tee /etc/nginx/conf.d/cloudflare-realip.conf sudo nginx -t && sudo systemctl reload nginx Reload a page and check the access log shows your own IP. Cloudflare's ranges change rarely, but they do change, so re-run this every few months or from a monthly cron job. Real traffic arrives through Cloudflare with the visitor's IP in a header Nginx can trust. Requests that skip Cloudflare and hit the server's IP directly are dropped by the firewall. 5. Lock the origin to Cloudflare Cloudflare only protects traffic that goes through it. If someone finds your server's IP, they can bypass the CDN, the WAF and DDoS protection completely. Allow web traffic only from Cloudflare's ranges with UFW: for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do sudo ufw allow proto tcp from "$ip" to any port 80,443 comment 'cloudflare' done sudo ufw delete allow 'Nginx Full' # or: sudo ufw delete allow 80,443/tcp sudo ufw status numbered Keep your SSH rule in place before changing anything. If any app runs in Docker with published ports, UFW rules don't apply to it; see Docker bypasses UFW . For stronger protection, enable Authenticated Origin Pulls so Nginx only accepts connections that present Cloudflare's client certificate. Also stop leaking the IP: don't point grey-clouded records like mail or ftp at the same server as the website, check that emails sent from the server don't reveal its IP in headers, and remember that old DNS history sites may still show your previous IP. After a serious attack, changing the server IP is sometimes the cleanest fix, and my zero-downtime migration guide shows how. 6. Cache the right things By default Cloudflare caches static files by extension (images, CSS, JS, fonts) and not HTML. That's a safe start. Then: Send good cache headers from your app. Next.js already marks /_next/static/ files as immutable; your own uploads and images should have a long Cache-Control too. Only cache HTML on purpose, with a cache rule for public pages, and bypass it for /admin , /account , /checkout , /api and any request with a session cookie. Purge after deploys if you cache HTML, or use short edge TTLs. Caching a personalised page is the most dangerous Cloudflare mistake: one customer's account page can be served to the next visitor. 7. Know the limits Upload size: the Free and Pro plans accept request bodies up to 100 MB. Larger uploads need a DNS-only hostname, chunked uploads or direct-to-storage uploads. Timeouts: if your server takes longer than about 125 seconds to respond, visitors see error 524. Long jobs should run in the background; my 504 Gateway Timeout guide explains how. WebSockets work through the proxy on all plans, so chat and live features keep working. 8. Turn on the useful free extras Always Use HTTPS and Automatic HTTPS Rewrites (you can then drop your own HTTP→HTTPS redirect, or keep it; with Full (strict) both are fine). HSTS , once you're sure every subdomain works over HTTPS. Bot Fight Mode and a WAF rule to challenge traffic to /wp-login.php or /admin from countries you don't serve. Under Attack mode : know where the switch is before you need it. Cloudflare is one layer. The server behind it still needs the basics in my Ubuntu hardening checklist . Frequently asked questions Why do I get "too many redirects" after enabling Cloudflare? Your SSL/TLS mode is probably Flexible while your server redirects HTTP to HTTPS. Cloudflare connects over HTTP, the server redirects to HTTPS, and the loop repeats. Install a certificate on the server and switch the mode to Full (strict). Should I use Flexible or Full SSL in Cloudflare? Full (strict). Flexible leaves traffic between Cloudflare and your server unencrypted and causes redirect loops. Full without strict accepts invalid certificates, so it protects less than it seems. How do I see real visitor IPs behind Cloudflare? Configure Nginx with set_real_ip_from for each Cloudflare IP range and real_ip_header CF-Connecting-IP , then reload. Logs, fail2ban and rate limits will then see the visitor's IP instead of Cloudflare's. Does Cloudflare hide my server's IP address? Only for proxied records, and only if the IP doesn't leak elsewhere: grey-clouded records on the same server, outgoing email headers, or old DNS history. Restrict ports 80 and 443 to Cloudflare's ranges so a leaked IP is useless. Is Cloudflare's free plan enough for a business website? For most small business sites, yes: CDN, free edge certificates, DDoS protection, basic WAF rules and caching are all included. Paid plans add more WAF features, image optimisation and support. Want Cloudflare and your server set up properly? I set up VPS servers with Nginx, HTTPS, Cloudflare, real-IP logging and a locked-down firewall, and deploy your app on them. See my VPS setup service , the server hardening and security audit , all DevOps services , or contact me with your domain and hosting details.

Read article →
Landing Page That Converts: 11 Things to Get Right
Website DesignOct 7, 2026

Landing Page That Converts: 11 Things to Get Right

A landing page converts when it has one goal, a headline that names the outcome, proof near the button, answers to objections, and loads in under 2.5 seconds. Most landing pages I'm asked to fix don't have a design problem. They have a clarity problem: three different buttons, a headline about the company instead of the customer, testimonials hidden at the bottom, and a hero image so heavy the page takes five seconds to show anything on a phone. Visitors decide fast whether a page is for them. This checklist covers the 11 things I check on every landing page I build or review, in the order a visitor experiences them. Key takeaways One page, one goal, one main button. Every extra choice lowers the chance of the one you want. The headline says what the visitor gets , for whom, in plain words. Your company name can wait. Put proof next to the ask: real reviews, client names, numbers you can back up. Answer objections on the page with a short FAQ: price, time, risk, what happens next. Speed is part of conversion. Aim for a Largest Contentful Paint under 2.5 seconds on mobile, and measure conversions so you know what works. Above the fold: the first five seconds 1. Pick one goal Decide the single action the page exists for: book a call, request a quote, start a trial, buy. Remove the main menu if you can, or at least keep it minimal, and make every button on the page do that one thing. A landing page from an ad with links to your blog, careers page and social profiles is leaking visitors. 2. Write a headline about the outcome Compare "Innovative Cloud Solutions" with "Your website back online in hours, not days." The second tells the visitor what they get. A good formula is outcome + for whom + without the pain : "Bookkeeping for small cafés, done in a day a month." Under it, one sentence explains how. 3. Make the button say what happens "Submit" and "Learn more" are weak. "Get my free quote", "Book a 15-minute call" or "Start fixing my site" set expectations. Show the main button above the fold on mobile, give it the strongest colour on the page, and repeat it after each major section. 4. Show the product or the result A real screenshot, a short demo, a before-and-after or a photo of your actual work beats an abstract illustration. Visitors want to see what they're buying. Size it properly: this image is often the page's Largest Contentful Paint, so it must be compressed and loaded first. Each section answers the next question in a visitor's head, in order. The same single button appears at the top and again at the end, after the objections are answered. Below the fold: earning the click 5. Proof, right where the decision happens Put your strongest proof immediately under the hero and again near the final button: short reviews with real names, client logos (with permission), a project count or rating you can back up, and case results. Specific beats glowing: "Fixed our checkout in a day, sales came back the same evening" persuades more than "Amazing service!". Never invent testimonials or numbers. Besides being dishonest, fake reviews break consumer protection rules in many countries, and visitors can usually tell. 6. Benefits first, features second Three short blocks, each a benefit in the customer's words with the feature that makes it true underneath. "Never lose a booking" (benefit) → "automatic SMS reminders 24 hours before" (feature). 7. Show how it works in three steps People hesitate when the next step is unclear. "1. Tell us about your project. 2. Get a fixed quote within a day. 3. We build it, you approve each milestone." Three steps make the decision feel small. 8. Answer objections with a short FAQ List the questions you hear on sales calls: How much does it cost? How long does it take? What if I'm not happy? Do I need to prepare anything? Answer each in two or three sentences. A FAQ also gives Google and AI answer engines clear question-and-answer text to show, which brings more qualified visitors. 9. Keep the form short Every field you add is a reason to leave. For a first contact, name, email and one open question ("What do you need help with?") is usually enough. Ask for budget, phone and company size later, or make them optional. Show what happens after submitting: "I reply within one working day." Under the hood: speed, mobile and measurement 10. Make it fast on a phone Google treats a Largest Contentful Paint under 2.5 seconds as good, and slow pages lose visitors before they've read the headline. Compress the hero image and load it with high priority, use at most two font families, and drop heavy sliders, chat widgets and tracking scripts you don't use. My guide to fixing a slow LCP walks through each step. Then test on a real phone: tap targets big enough, no text too small, no sideways scrolling, the form easy to fill with a thumb. 11. Measure conversions, then change one thing at a time Track the button clicks and form submissions as conversions (key events in Google Analytics 4, or your ad platform's conversion tracking). Without that, you're redesigning on opinions. Once you have a baseline, test one change at a time, headline first, because it has the biggest effect. Small sites rarely have the traffic for formal A/B tests; changing the headline for two weeks and comparing the conversion rate is still far better than guessing. Website builders or a custom page? Builders like Webflow, Framer and Wix can produce a good landing page if you follow the checklist above, and they're quick to edit. A custom page built in Next.js gives you full control over speed, tracking and integration with your app or CRM. I compared both in AI website builders vs a custom website . If you're replacing an existing site, read how to redesign without losing SEO first. Frequently asked questions What makes a landing page convert? A clear single goal, a headline that states the outcome for a specific visitor, one obvious button, proof near that button, answers to common objections, a short form, and a page that loads quickly on mobile. How long should a landing page be? As long as it takes to answer the visitor's questions. A free download can be short. An expensive service needs more proof and more answers. Keep the main button visible at the top and repeat it after each major section so long pages don't hide it. Should a landing page have a navigation menu? For ad and campaign pages, usually not, or only a minimal one, because every link away from the page is a lost conversion. A service page on your main website can keep the normal menu. How many form fields should a landing page form have? As few as you need to start the conversation, often three: name, email and a short message. Extra qualifying questions can be optional or asked in the follow-up. How do I know if my landing page is working? Track conversions as events in your analytics and divide them by the number of visitors to get a conversion rate. Compare it over time and after each change, rather than judging by how the page looks. Want a landing page built to convert? I design and build fast landing pages with clear copy structure, real proof, conversion tracking and a sub-2.5-second load on mobile. See my landing page design and build service , all website design services , or contact me with your current page and the one action you want visitors to take.

Read article →
Migrate a Website to a New Server With Zero Downtime
DevOpsOct 7, 2026

Migrate a Website to a New Server With Zero Downtime

Move a site to a new server with zero downtime: lower the DNS TTL early, copy and test, do a final data sync, switch DNS and keep the old server running. Server migrations have a bad reputation because the usual approach is "copy everything, change DNS, hope". Then emails stop arriving, uploads from the last hour are missing, the SSL certificate isn't there yet and half the visitors still land on the old server. I move client sites between providers regularly, often to cut hosting costs or get off an old, unpatched machine, and the plan below is how I do it without customers noticing. It works for Node.js, PHP and WordPress sites alike. Key takeaways Lower the DNS TTL to 300 seconds at least a day before the move, so the switch spreads in minutes instead of hours. Inventory everything first: sites, databases, uploads, cron jobs, environment files, email and every DNS record. Test the new server before switching DNS with curl --resolve or your hosts file. Sync data twice: a full copy while the old server is live, then a short final sync during a brief write freeze. Keep the old server running for a week as your rollback plan, and only then cancel it. Step 1: Inventory the old server Most migration problems are things nobody remembered existed. Before touching anything, write down: Sites and apps: ls /etc/nginx/sites-enabled/ (or Apache's), pm2 list , docker ps , systemctl list-units --type=service --state=running . Databases: names, sizes, users and versions. Moving PostgreSQL 14 to 17 or MySQL 5.7 to 8 is an upgrade too; test it. Files: app code, user uploads, generated files, and the .env or config files with secrets. Scheduled jobs: crontab -l for every user, sudo crontab -l , /etc/cron.d/ and systemd timers. DNS: export every record from your DNS provider. Note MX, SPF, DKIM and DMARC records, and any subdomains pointing at the old IP. Email: does the server send mail (contact forms, receipts) directly or through a provider? Does it receive mail? Things that trust the old IP: payment provider webhooks, API allowlists, a database firewall, an SMS gateway, a partner's IP whitelist. Step 2: Lower the DNS TTL The TTL tells resolvers how long to cache your DNS record. If it's 86400 (a day), some visitors keep going to the old server for up to a day after you switch. Change the TTL of the A/AAAA records you'll move to 300 seconds, then wait at least as long as the old TTL before migrating so every cache has picked up the short value. Check what the world sees with: dig +noall +answer www.example.com The number before IN A is the remaining TTL. If you use Cloudflare's proxy (orange cloud), the switch is near-instant anyway, because visitors only ever see Cloudflare's IPs. I cover that setup in the Cloudflare VPS checklist . Step 3: Build and secure the new server Set up the new server properly from the start rather than copying the old one's mistakes: a non-root sudo user, SSH keys only, a firewall, automatic security updates. My Ubuntu 24.04 hardening checklist is the list I follow. Install the same major versions of your runtime (Node, PHP, Python) and database, or plan and test the upgrade. Then set up Nginx and the app the way I describe in deploying Next.js on a VPS with PM2 and Nginx . Step 4: First full copy of files and databases Copy files with rsync over SSH. It preserves permissions and can be re-run later to copy only what changed: rsync -aHAX --info=progress2 \ old-server:/srv/app/shared/uploads/ /srv/app/shared/uploads/ For databases, take a logical dump and restore it on the new server: # PostgreSQL ssh old-server "pg_dump -U app -Fc appdb" > appdb.dump pg_restore -U app -d appdb --no-owner appdb.dump # MySQL / MariaDB ssh old-server "mysqldump --single-transaction appdb" | mysql appdb Copy the .env files and cron jobs, and point the app's config at the new database. Don't enable cron jobs that send emails or charge cards yet, or customers get everything twice. Step 5: Test the new server before anyone sees it You need HTTPS working before the DNS switch, but Let's Encrypt's normal HTTP check needs DNS to already point at the new server. Three ways around it: copy /etc/letsencrypt from the old server, use a DNS-01 challenge with your DNS provider's plugin, or use a Cloudflare Origin CA certificate if you're behind Cloudflare. Then test the real domain against the new IP without changing DNS: curl -sI --resolve www.example.com:443:203.0.113.20 https://www.example.com/ For clicking around in a browser, add 203.0.113.20 www.example.com to your computer's hosts file temporarily. Log in, submit forms, upload a file, run a test payment, check the logs for errors. Remove the hosts entry when you're done. With a short TTL set in advance, changing the A record moves visitors to the new server within minutes. The old server stays up, so anyone still on a cached record is served normally. Step 6: Final sync and the switch The only moment data can be lost is between the last copy and the DNS switch. Keep it short: Pick a quiet time and tell the client or team. Freeze writes on the old server: a maintenance page for logged-in actions, or stop the app's workers, so no new orders or uploads land there. Final sync: re-run the same rsync (fast, only changes) and take a fresh database dump and restore. Switch DNS to the new IP. Enable cron jobs and workers on the new server, and disable them on the old one. Watch both servers' access logs. Traffic on the old one should drop to almost nothing within minutes. For a site where even a few minutes of frozen writes is too much, you can set up database replication from old to new and promote the new one at switch time. It's more work and only worth it for busy stores and apps. Step 7: After the switch Update everything that trusted the old IP: webhooks, allowlists, SPF records if the server sends mail directly. Check email both ways. Send a contact-form test and a real email to the domain. Check renewals: run sudo certbot renew --dry-run on the new server. Set up backups and monitoring on the new server on day one, not "later". Keep the old server for about a week, powered on, as a rollback. Take a final snapshot before cancelling it. Raise the TTL back to an hour or more once everything is stable. Frequently asked questions How long does it take to migrate a website to a new server? Preparing, copying and testing usually takes a few hours to a couple of days, depending on the size of the data and the number of services. The actual switch, with a short TTL and a final sync, takes minutes. Will my website go down during migration? Not if you test the new server first, lower the TTL in advance and keep the old server running. Visitors with cached DNS still reach the old server, which keeps working until their cache expires. How do I get an SSL certificate before changing DNS? Copy the existing Let's Encrypt files from the old server, use a DNS-01 challenge through your DNS provider, or use a Cloudflare Origin CA certificate if your site is behind Cloudflare. The normal HTTP challenge only works after DNS points at the new server. How long does DNS propagation take? As long as the record's TTL. With a TTL of 300 seconds set a day ahead, most visitors move within five to ten minutes. A few badly behaved resolvers cache longer, which is why the old server should stay up for a while. Can I move from shared hosting to a VPS this way? Yes. Download files over SFTP or the host's backup tool, export the database from phpMyAdmin or the control panel, and follow the same test-then-switch steps. Pay special attention to email, which shared hosting often provides and a plain VPS doesn't. Want your site moved for you? I migrate websites and apps between servers and providers, including off Vercel, Heroku and shared hosting, with testing before the switch and the old server kept as a fallback. See my hosting migration service , the VPS setup service , all DevOps services , or contact me with what you're running now and where you want it.

Read article →