Next.js on a Container Versus Vercel: When Server Rendering Needs a Server
Vercel is where Next.js is designed to run, and for a personal project its free Hobby plan is hard to argue with. This is not an argument against it. It is a map of the line where a Next.js app stops fitting a serverless free tier and starts fitting a container, with the real limits on both sides and a Dockerfile that produces a small image.
What Vercel's Hobby plan actually gives you
- Free, with one condition that matters: personal, non-commercial use only. A side project that takes payments, shows ads or belongs to a company is outside the terms; the next plan is Pro at $20 per member per month.
- 100 GB of fast data transfer, 1 million function invocations, 4 hours of active CPU and 360 GB-hours of provisioned memory a month.
- Functions run up to 60 seconds. No overage billing on Hobby: when a limit is hit, the resource is unavailable until the next cycle.
- Preview deployments per pull request, the global CDN, image optimization and Incremental Static Regeneration, all wired in without configuration.
If your app is a static or mostly static site, a portfolio, docs, or a blog, and it is non-commercial, stop reading and use Vercel. Nothing in a container tier beats that deal for that shape.
Where a container fits better
- Commercial hobby projects. A small SaaS with three paying users is "commercial". On a container free tier the question is not asked; the trade is that the container sleeps after 15 minutes without traffic and takes about a minute to wake.
- A real server. WebSockets, long-lived connections, an in-process job queue, a custom
server.js, or a request that legitimately takes longer than a minute. Serverless functions end; a container process does not. - No per-invocation ceiling. A container has no invocation, CPU-second or memory-hour budgets, only the box it runs in: 512 MB and 0.25 vCPU on the free tier, and 100 container-hours a month.
- Everything in one place. A Next.js front end next to a Python API and a managed Postgres, in the same dashboard, with environment variables you set once.
The Dockerfile: standalone output
Next.js can emit a self-contained server with only the files it needs. Turn it on in next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = { output: 'standalone' };
module.exports = nextConfig;FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/.next/standalone ./
COPY --from=build --chown=app:app /app/.next/static ./.next/static
COPY --from=build --chown=app:app /app/public ./public
USER app
EXPOSE 3000
CMD ["node", "server.js"]The standalone folder contains server.js and a pruned node_modules, so the final image is the Node runtime plus what the app really uses, typically well under 200 MB rather than the 500 MB+ you get by copying the whole project. HOSTNAME=0.0.0.0 matters: the standalone server binds to localhost by default, which the platform cannot reach.
Why your own Dockerfile rather than the generated one: SnapDeploy's generated Node.js Dockerfile builds on a Node 18 base image. Next.js 15 still runs there, but recent releases require Node 20 or newer, and the standalone layout above is not something a generic generator knows about. With a Dockerfile in the repository, SnapDeploy uses it and reads the port from EXPOSE. If your pages use next/image, install sharp as a dependency; it is compiled during npm ci on the Alpine image without extra steps.
What changes without Vercel's platform features
| Feature | On Vercel | In a container |
|---|---|---|
| Static assets and CDN | Global edge cache automatically | Served by the Node process; put a CDN in front if traffic is global. On SnapDeploy every container URL is already behind Cloudflare. |
| Incremental Static Regeneration | Shared cache across functions | Works; the cache lives in .next/cache inside the container and is rebuilt after a restart or a wake. |
| Image optimization | Built in | Done by the server with sharp; CPU-bound on a quarter vCPU, so pre-size images or use an external loader. |
| Preview per pull request | Automatic | Manual: deploy a branch to a second container (the free tier has four). |
| Cold start | Function cold starts, usually well under a second | None while awake; about 60 seconds to wake a free container that slept, none on Always-On ($12/month). |
Memory and CPU, measured
A standalone Next.js server with the App Router idles around 100 to 150 MB and stays under 300 MB serving a typical page mix, so 512 MB works. The constraint is CPU: server rendering a heavy page on 0.25 vCPU takes several times longer than on your laptop. Static generation at build time (generateStaticParams, ISR with a long revalidate) moves that work to the build, where it belongs, and the container mostly serves files.
Deploying
- Add
output: 'standalone'and the Dockerfile; commit a.dockerignorewithnode_modules,.nextand.env*. - Push. Containers → Deploy from GitHub → pick the repository. Port 3000.
- Add environment variables.
NEXT_PUBLIC_*values are baked in at build time, so set them before the first build. - Deploy. Builds take a few minutes (the
npm run buildstep dominates). Test the container URL, then the same page after 20 idle minutes to see the wake page once.
Check the image before you push
docker build -t site .
docker images site --format "{{.Size}}"
docker run --rm -p 3000:3000 site
curl -I http://localhost:3000/If the size is several hundred megabytes, the standalone folder was not used (check next.config.js) or node_modules is being copied in. If the page renders but images are broken, public/ or .next/static was not copied into the runtime stage. If the container starts but nothing answers on port 3000, HOSTNAME is missing and the server is bound to localhost.
Environment variables: two kinds
Next.js treats NEXT_PUBLIC_* variables as build-time constants: they are inlined into the client bundle when npm run build runs, so they must be present during the build, and changing one means a rebuild. Everything else (database URLs, API keys, secrets) is read at runtime by server components, route handlers and middleware, and can be changed in the container settings followed by a restart. A common mistake is putting a secret in a NEXT_PUBLIC_ name; it ends up in the JavaScript you ship to every browser.
Where free stops
- The container sleeps after 15 minutes without traffic; the first visitor afterwards waits about a minute on a wake page. Vercel has no equivalent wait.
- 100 container-hours a month across four containers, counted while awake. A site visited a few times a day is fine; one that is pinged to stay up is not.
- Custom domains need Always-On ($12/month). Vercel's Hobby plan includes them.
- No built-in analytics, speed insights or per-branch previews; the free tier's four containers are what you have for previews.
Numbers used: Vercel Hobby limits from Vercel's plan and limits pages as of September 2026; SnapDeploy free tier from the free tier docs (4 containers, 512 MB, 0.25 vCPU, 100 container-hours a month, sleeps after 15 minutes, no card).