Deploy a Next.js App to a VPS with Cloudflare Tunnel
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:
| Setting | Value |
|---|---|
| Hostname | yourdomain.com |
| Service | http://web:3000 |
| DNS | Cloudflare 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
healthcheckso 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/publiccopies → 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 withcurl -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.
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
Full Stack Software Developer· Nepal's SEO, AEO, GEO & AIO expert and share-market educator. More about me
