Skip to content
FreeToolNet
Go back

Moving a domain apex from Vercel to a VPS with nginx and certbot

My main site ran on Vercel at the apex of my domain. A self-hosted copy of the same Next.js site ran on my VPS at mirror.example.com, marked noindex so it would not compete with the real one. I decided to make the VPS copy the primary site.

The move took four steps: rebuild the image as indexable, add an nginx vhost for the apex, change one A record, and issue a certificate. The step that cost me time was the first one. The noindex flag was baked into the image, and changing the container’s environment did not remove it.

Where I started

One container named portfolio runs on the VPS, bound to 127.0.0.1:3010. The host nginx terminates TLS and proxies to it.

The repository was built to be a mirror. A build-time variable, NEXT_PUBLIC_MIRROR=1, switched on three things:

  • an X-Robots-Tag: noindex,nofollow response header, set in next.config.ts
  • a noindex robots meta tag, set in the root layout’s metadata
  • a robots.txt that disallows everything

That is the right setup for a mirror. For the primary site, all three had to go.

Step 1: rebuild the image as indexable

My first idea was to change NEXT_PUBLIC_MIRROR in the compose file and restart the container. That does nothing.

Next.js inlines NEXT_PUBLIC_ variables into the app when it is built. The header, the meta tag and the robots file had all been decided at build time. The running container never reads the variable again.

The real fix was in two places:

  • the Dockerfile default for the build argument, changed from 1 to 0
  • the build argument passed by the CI workflow, changed the same way

Pushing that commit triggered the deploy workflow. It rebuilt the image and restarted the container. Later pushes stay indexable because the default itself changed.

The compose file on the server still has a runtime NEXT_PUBLIC_MIRROR: 1 line. It is stale and has no effect. I mention it because it looks alarming when you read the file later.

If you take one thing from this post: for a NEXT_PUBLIC_ value, a restart is not enough. Check the built output, not the environment.

Step 2: add an nginx vhost for the apex

The mirror hostname already had its own vhost. I added a second one for the apex and www, pointing at the same container.

This is the vhost template I keep for sites like this, with the hostname replaced. It is the copy from this blog’s repository, so the port is the blog’s. The portfolio vhost proxies to 127.0.0.1:3010.

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    location / {
        proxy_pass http://127.0.0.1:3011;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Save it in /etc/nginx/sites-available/, symlink it into sites-enabled, then test and reload:

nginx -t && systemctl reload nginx

The file only has a port 80 block. Certbot adds the 443 block and the redirect in step 4.

The important difference from the mirror vhost is what this one leaves out. It has no X-Robots-Tag header. The template carries a comment saying so, because copying the mirror’s vhost and forgetting to delete that line would quietly deindex the main site.

Step 3: change the A record

DNS for the domain is at Hostinger, not at Vercel. The apex @ record was an A record pointing at Vercel’s address, 76.76.21.21. I changed it to the VPS address, 203.0.113.10 in this post.

www is a CNAME to the apex, so it followed without a change.

Rollback is the same edit in reverse. I left the Vercel project in place. It no longer receives traffic for the apex, but setting the A record back would restore the old site.

Step 4: issue the certificate

certbot --nginx -d example.com -d www.example.com --redirect

--nginx lets certbot edit the vhost from step 2. It adds the port 443 server block with the certificate paths. --redirect adds the HTTP to HTTPS redirect, which is a 301.

The server already had a certbot account from other sites, so I did not need to pass an email address.

The order matters. Certbot proves control of the hostname over HTTP, so the A record must already point at the VPS and the port 80 vhost must already be live. That is why DNS is step 3 and certbot is step 4.

The certificate was issued on 8 November 2026 and expires on 5 February 2027. Certbot renews it automatically. The options are described in the certbot documentation.

Keeping the mirror out of Google

After step 1 the image is indexable. The mirror hostname still serves the same container, so by default it would now be indexable as well, and I would have the same content on two hostnames.

I kept the mirror noindex at the nginx layer. Its vhost adds an X-Robots-Tag: noindex,nofollow header to every response. The app no longer sets the header, but nginx does, and only for that hostname.

The result:

HostnameServed byX-Robots-Tag from nginx
example.com, www.example.comportfolio containernone
mirror.example.comsame containernoindex,nofollow

Both hostnames serve identical content from one container. Only the header differs.

A cleaner end state is a 301 from the mirror hostname to the apex, which removes the duplicate entirely. I have not done that yet.

Checking the result

I checked the new server by sending requests for the real hostname straight to the VPS address:

curl -sS -I --resolve example.com:443:203.0.113.10 https://example.com

--resolve makes curl skip DNS and connect to the address you give it. With that I confirmed:

  • the apex returns 200 and the response comes from nginx
  • robots.txt allows crawling and lists the sitemap
  • the page has a robots meta tag of index,follow
  • http:// redirects to https:// with a 301
  • www returns 200

One warning about this check. It proves the server is right. It does not prove DNS is right, because it never asks DNS. Run at least one request without --resolve before you call the move done.

What I would repeat

  • Decide which layer owns noindex. For a build that must be indexable on one hostname and hidden on another, the proxy is the right place. The app cannot tell the two apart as easily as nginx can.
  • Treat NEXT_PUBLIC_ values as part of the image. Changing one means a rebuild.
  • Put the vhost in place before touching DNS, and run certbot after.
  • Keep the old host’s project until you are sure. It is the cheapest rollback there is.
  • Each new hostname needs its own A record. My zone has no wildcard record, so the mirror hostname did not resolve until I added one for it, and certbot could not issue its certificate before that.

How this post was made

Written with AI assistance and reviewed by the author before publishing. See the editorial policy.

Commands and output were captured on 2026-11-08 on Next.js site in a Docker container bound to 127.0.0.1:3010 on a VPS; host nginx with certbot; DNS at Hostinger; previous host Vercel (Next.js 16.4.5, Docker 29, Docker Compose v5).



Previous Post
Building a Next.js Docker image on a low-memory VPS with temporary swap
Next Post
Subdomains returned NXDOMAIN after I moved DNS to Cloudflare