Vercel suited me for a long time. You push your code, thirty seconds later it's live with a valid certificate, a CDN and per-branch previews. To get a project off the ground, I don't know anything more comfortable. The trouble shows up later, once the project actually lives. The bill climbs with traffic and serverless functions, some proprietary features get awkward to reproduce elsewhere, and you end up not really knowing where or how your app runs. It's an excellent starting point, and a trap the moment you want to control your costs and your infrastructure.
For this portfolio, like for several client projects, I went the other way. A VPS for a few euros a month, a Docker image, a reverse proxy, a homemade pipeline. The idea isn't to go back to the stone age of FTP deploys: I keep the "git push and it's live", but on a machine I control from end to end. Here's how it's wired, and the two or three spots where I got caught.
The Docker image: everything rests on standalone mode
The piece that changes everything is output: "standalone" in next.config.ts. At build time, Next traces exactly which files the runtime needs and copies them into .next/standalone/. You go from an image of roughly 1 GB down to around 200 MB. Without it, you drag your whole node_modules into your production container for nothing.
The Dockerfile is multi-stage: one step to install dependencies, one to build, a last one that keeps only the bare minimum.
# deps: install dependencies (optimal Docker layer caching)
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
RUN corepack enable && yarn install --immutable
# builder: build the app
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && yarn build
# runner: final image, non-root
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -S nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
Two things are worth pausing on. The final image runs as a non-root user (nextjs:nodejs), because a web container running as root is a bad habit you rarely get away with for free. And you have to copy public/ and .next/static/ next to the standalone server.js by hand: Next won't do it for you, and if you forget, your app boots but serves your pages with no CSS and no images.
Environment variables: build and runtime don't play on the same team
This is the trap almost everyone falls into once. With Next, variables don't all behave the same way. The NEXT_PUBLIC_* ones are inlined at build time: they end up in plain text inside the JavaScript bundle shipped to the browser. So you have to pass them as ARG to docker build, otherwise they'll simply be empty on the client side. Server secrets, on the other hand (the email API key, the reCAPTCHA secret), are read at runtime and have no business being in the image. You inject them when the container starts, through an env_file.
In practice, my production docker-compose references a .env.production that's never committed for the secrets, and receives the NEXT_PUBLIC_* values as build.args. The rule I keep in mind: if a value has to be visible in the browser, it's baked at build time; if it has to stay secret, it arrives at runtime. Mix the two up and you get either an empty variable in production, or a secret key published in your bundle.
The reverse proxy: automatic SSL, without touching a config file
The container listens on port 3000, but I don't expose it on the host. Instead, it joins a shared Docker network with a reverse proxy that handles HTTPS and routing. Here I use Nginx Proxy Manager, and let me say it straight away: it's one choice among several, not an obligation. Caddy or Traefik do the same job perfectly well. If I pick Nginx Proxy Manager for this article, it's for how simple it is to demo: everything is driven from a graphical interface. You create a "Proxy Host", point sergent.dev at the portfolio container on port 3000, tick Let's Encrypt, and the certificate gets issued then renewed on its own. Routing and SSL are handled with a click, without ever opening a configuration file.
That's also its limit. Caddy and Traefik lean more toward the infra-as-code philosophy: the config lives in a versioned, reproducible file that travels with your repo. For a server you set up once and leave running, NPM's graphical interface is unbeatable on comfort. For infrastructure you want to be able to recreate identically on command, I'd look at Caddy instead. Both approaches hold up; I mostly wanted you to know the brick is interchangeable.
On the compose side, the service publishes no port on the host. It just joins the proxy's network.
# docker-compose.prod.yml (excerpt)
services:
portfolio:
image: ghcr.io/<owner>/portfolio:latest
container_name: portfolio
env_file: .env.production
networks: [nginx-manager_default] # shared network with the proxy
restart: unless-stopped
networks:
nginx-manager_default:
external: true
No application port is reachable directly from the internet. The only entry point is the proxy on 443. Several apps can live behind the same one, each on its own subdomain.
The pipeline: getting "push and it's live" back
All the rest is pointless if deploying turns into a manual chore again. The goal is to get exactly the Vercel reflex back: I push to master, and the app ships to production on its own. A GitHub Actions workflow takes care of it.
push master
├─► lint
├─► security scan (Trivy, blocking on fixable CVEs)
└─► build → push image to GHCR (private GitHub registry)
└─► deploy: SSH into the VPS → docker compose pull → up -d
The image is stored on GHCR, GitHub's container registry. The deploy step connects to the VPS over SSH, runs a docker compose pull then an up -d: the new container replaces the old one with no visible downtime. I add a Trivy scan that fails the pipeline the moment a dependency carries a critical vulnerability with a fix available. Security isn't a review you do "later", it sits right in the deployment path. If a fixable CVE slips through, nothing ships.
The bug no green build will flag
I'm saving the best for last, because it's the one that cost me the most time. With output: "standalone", the next start command doesn't serve the app properly. It's not a bug, it's just no longer the right command: you have to run node .next/standalone/server.js. If you keep next start in your container, you can spend a while wondering why nothing answers.
Nastier still: a next build that goes green doesn't guarantee that all your pages render. A page that calls notFound(), or a static route with no generated params, gets prerendered as a 404 without the slightest build error. The pipeline is all green, the image is pushed, deployed, and it's in production that you discover your broken page.
The fix takes one line and thirty seconds. Before every push to production, I run node .next/standalone/server.js locally, with .next/static and public copied alongside like in the real image, and I check with curl that my key pages do answer 200. That smoke test is what catches the silent 404s before a visitor sees them. A green build lies sometimes; the standalone server actually running, never.
Self-hosting a modern Next.js app has never struck me as complicated or nostalgic. It's a handful of config files, once, and then the same comfort as a managed platform, on a machine whose exact price and contents I know. For a marketing site, an internal business app or a SaaS getting started, it's the trade-off I'd make again without hesitating.
If you want a hand hosting or deploying your app, let's talk about your project.
Top comments (0)