Skip to content
Sandeep Kumar ChaudharySandeep
Back to BlogDevOps

Deploy a Next.js App to a VPS with Cloudflare Tunnel

By Sandeep Kumar ChaudharyJun 20, 20263 min read
Deploy a Next.js app to a VPS with Cloudflare Tunnel — DevOps guide by Sandeep Kumar Chaudhary

TL;DR

This guide shows how to deploy a Next.js app to your own VPS using Docker and a Cloudflare Tunnel. You build a tiny standalone image, run it behind cloudflared so no inbound ports are exposed, and point your domain at the tunnel — secure HTTPS with no nginx, no firewall holes, and no certificate management.

Key takeaways

  • Next.js `output: "standalone"` produces a self-contained server, so the runtime Docker image stays small and runs `node server.js` directly.
  • A Cloudflare Tunnel connects outbound from your VPS to Cloudflare, so you never open ports 80/443 or touch the firewall.
  • cloudflared and your app share a private Docker network, letting the tunnel reach the container by service name.
  • Cloudflare terminates TLS at its edge, so you get HTTPS without managing certificates on the origin.
  • This pattern lets multiple isolated apps live on one VPS without fighting over ports.

Deploying a Next.js app to your own VPS usually means wrestling with nginx, opening ports, and renewing TLS certificates. A Cloudflare Tunnel removes all three. This guide shows the exact approach I use to ship production Next.js apps to a cheap VPS — a tiny Docker image, a cloudflared connector, and zero open ports — with HTTPS handled for you at the edge.

It is written from first-hand experience running this setup live, so every step is the real thing, not theory.

Why a Cloudflare Tunnel instead of nginx?

A traditional self-host exposes ports 80 and 443, runs a reverse proxy, and manages certificates with something like Let's Encrypt. A Cloudflare Tunnel flips the model: a small daemon called cloudflared opens an outbound connection from your server to Cloudflare. Web requests then ride that connection back to your app.

The practical wins:

  • No inbound ports. Your firewall can deny all inbound web traffic. There is nothing to port-scan.
  • No certificates on the origin. Cloudflare terminates TLS at its edge.
  • No port conflicts. Several apps can share one VPS because none of them bind the host's 80/443.
  • No reverse-proxy config to maintain. Routing lives in Cloudflare.

If you are new to containers, the DevOps overview explains where this fits.

Step 1 — Build a small image with standalone output

Tell Next.js to emit a self-contained server. In next.config.ts:

const nextConfig = {
  output: "standalone",
};
export default nextConfig;

Then use a multi-stage Dockerfile so the final image only carries the runtime, not your whole toolchain:

FROM node:22-alpine AS base
RUN corepack enable

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

Standalone output does not copy public or .next/static into the bundle automatically, so copy them in explicitly — a step people often miss.

Step 2 — Compose the app and the tunnel together

Run the app and cloudflared as one stack on a private network so the tunnel can reach the app by name:

services:
  web:
    build: .
    container_name: portfolio-web
    restart: unless-stopped
    networks: [appnet]
  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN}
    depends_on: [web]
    networks: [appnet]
networks:
  appnet:
    driver: bridge

Notice the app has no ports: mapping to the host. It is only reachable inside appnet, which is exactly what we want.

Step 3 — Create the tunnel and route your domain

In the Cloudflare dashboard, create a tunnel (or use the API), then add a public hostname that maps your domain to the internal service:

SettingValue
Hostnameyourdomain.com
Servicehttp://web:3000
DNSCloudflare creates a proxied CNAME to the tunnel

Put the tunnel token in a .env file (never commit it), then start everything:

echo "TUNNEL_TOKEN=your-token" > .env
docker compose up -d --build

Within seconds cloudflared registers connections to Cloudflare's nearest edge, and your site is live over HTTPS. You can confirm the connector is healthy in its logs (Registered tunnel connection).

Step 4 — Verify and harden

A few checks make this production-ready:

  • Confirm the origin is reachable only through the tunnel, not the public IP.
  • Keep the firewall closed to inbound 80/443.
  • Set a container healthcheck so a crashed app restarts.
  • For zero-downtime, build first, then recreate, so the old container serves until the new one is ready.

For an apex domain, Cloudflare's CNAME flattening lets the tunnel's CNAME sit at the root, and you can add a www → apex redirect so you have a single canonical host.

Common pitfalls

  • Forgetting static/public copies → broken styles and images. Copy both into the runner stage.
  • Mismatched Host header → host-based redirects fail. Cloudflare forwards the original Host, so this usually just works, but verify with curl -I.
  • Committing the tunnel token → rotate it immediately. Treat it like a password.

That is the whole recipe: a small standalone image, a private network, and a tunnel doing the heavy lifting. According to Cloudflare's documentation, the connector is outbound-only, which is why this approach is both simpler and safer than exposing a reverse proxy yourself. The Next.js deployment docs confirm that standalone output supports every Next.js feature in a container.

Deploy once like this and you will not want to go back to managing ports and certificates by hand.

#next.js#cloudflare tunnel#docker#vps deployment

Frequently Asked Questions

How do you deploy a Next.js app to a VPS with Cloudflare Tunnel?

Build the app as a Docker image using Next.js standalone output, run it with docker-compose alongside a cloudflared container on a shared private network, create a tunnel in Cloudflare, route your hostname to http://your-app:3000, and add the tunnel's DNS record. Cloudflare handles HTTPS and routing, so no inbound ports are opened on the VPS.

Do I need to open ports 80 and 443 on my VPS?

No. A Cloudflare Tunnel connects outbound from cloudflared to Cloudflare's edge, so traffic reaches your container through that connection. You can leave the firewall closed to inbound web traffic, which removes a whole class of attack surface and avoids conflicts when several apps share one server.

Why use Next.js standalone output for Docker?

With `output: "standalone"`, Next.js traces exactly which files the server needs and emits a minimal `server.js` plus a trimmed `node_modules`. The runtime image can skip installing full dependencies, so it builds faster and ships far smaller than copying the whole project.

Does Cloudflare Tunnel give me HTTPS automatically?

Yes. TLS is terminated at Cloudflare's edge using a certificate Cloudflare manages for your zone, so visitors get HTTPS without you generating or renewing certificates on the origin. The origin connection runs over the encrypted tunnel.

Can I run multiple apps on one VPS this way?

Yes. Because no app binds to the host's 80/443, each app can run as its own isolated docker-compose stack with its own cloudflared (or shared tunnel with multiple public hostnames). They never collide on ports, which makes the VPS easy to share safely.

Sandeep Kumar Chaudhary

Sandeep Kumar Chaudhary

Full Stack Software Developer· Nepal's SEO, AEO, GEO & AIO expert and share-market educator. More about me