From Vercel or Netlify to a Container: The Moment You Need a Real Server
Vercel and Netlify are excellent at the thing they were built for: a front end, a CDN, and functions that answer a request and go away. Most projects never need more. This guide is for the moment yours does: the WebSocket that has nowhere to live, the job that takes two minutes, the queue worker, the "non-commercial use only" line you just re-read in the terms. It covers what the free tiers actually allow, the shapes of app that outgrow them, and the move, which is usually smaller than people fear because the front end can stay exactly where it is.
The free tiers, precisely
| Vercel Hobby | Netlify Free | |
|---|---|---|
| Who may use it | Personal, non-commercial projects only. Anything that earns money for anyone involved needs Pro ($20 per member per month). | No such clause. |
| Traffic | 100 GB fast data transfer a month; no overage, resources pause at the cap | 300 credits a month; bandwidth costs 20 credits per GB, so about 15 GB if spent on traffic |
| Functions | 1 million invocations, 4 hours of active CPU, 360 GB-hours of memory a month; up to 60 seconds per invocation | 125,000 invocations a month; short synchronous timeouts (10 seconds on the lower plans); background functions are a paid feature |
| Long-lived connections | No (functions end) | No |
| What stays great | Previews per pull request, edge caching, image optimization, custom domains | Deploy previews, forms, redirects, custom domains |
The four shapes that outgrow a function
- A connection that stays open. WebSockets, server-sent events that run for minutes, a game lobby, a live dashboard. A function is a request; a server is a process.
- Work that takes longer than the timeout. Report generation, a scrape, a batch import, an LLM call chain. You can chain functions and queues to fake it; a container just runs the loop.
- A worker. Anything that consumes a queue or polls something on a schedule that is not "when a request arrives".
- The commercial clause. A side project that charges three customers is commercial on Vercel's Hobby plan. On a container free tier that question is not asked.
What a container does not fix: it does not give you a global edge for free, it sleeps after 15 minutes without traffic on the free tier, and previews per branch are manual. Those are the reasons to keep the front end where it is.
The move that usually works: split, don't migrate
Leave the static front end on Vercel or Netlify. Move the part that needs a server into a container, give it an API URL, and point the front end at it. The front end keeps its CDN and previews; the server gets a process. Three things to set up:
- The server. Take the function handlers and mount them on a small server (Express, Hono, FastAPI, whatever the functions were written in). A Vercel or Netlify function is a request handler already; the wrapper changes, the body does not.
- CORS. The front end now calls another origin. Allow exactly that origin, and read it from an environment variable so previews and production can differ.
- Secrets. Move server-side environment variables to the container. Anything prefixed for the browser (
NEXT_PUBLIC_,VITE_) stays with the front end.
// api/server.js: the same handlers, now a process
const express = require("express");
const app = express();
app.use(express.json());
app.use((req, res, next) => {
res.set("Access-Control-Allow-Origin", process.env.FRONTEND_ORIGIN);
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
app.get("/health", (req, res) => res.send("ok"));
app.post("/api/report", require("./handlers/report")); // the old function body
app.listen(process.env.PORT || 3000, "0.0.0.0");FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "api/server.js"]If the server is a full Next.js app rather than an API, the standalone-output Dockerfile in our Next.js guide is the right shape, and the same CORS point does not apply because pages and API share an origin again.
Long jobs and workers on a container that sleeps
A two-minute request is fine: the container is awake while it runs, and nothing times it out. A worker that must run when nobody is visiting is the honest limit of the free tier: a container with no HTTP traffic is idle by definition and sleeps after 15 minutes. Options, in order: do the work in the request that needs it; trigger it from the front end's scheduled function (Vercel cron or Netlify scheduled functions, which are free and can call your container's endpoint on a schedule, waking it if needed); or make the worker container Always-On ($12/month), where it simply keeps running.
Deploying the server
- Push the repository (or a new repository for the server). Containers → Deploy from GitHub → pick it; port 3000.
- Set
FRONTEND_ORIGINand the server-side secrets. Attach a database add-on if the functions used one, or paste the connection string of the one they already use. - Deploy; test
/healthon thecontainers.snapdeploy.appURL, then point the front end's API base URL at it and redeploy the front end. - Keep the old functions for a week, then delete them so the secrets do not live in two places.
Where free stops on the container side
- 4 containers, 512 MB and 0.25 vCPU each, 100 container-hours a month counted while awake. An API called from a busy front end stays awake and spends hours; one called a few times an hour sleeps between calls.
- The first request after 15 idle minutes waits about a minute. For an API behind a front end that is a slow first fetch, not a wake page; show a loading state.
- Custom domains only on Always-On. Most split setups do not need one on the API.
Test the server locally first
docker build -t api .
docker run --rm -p 3000:3000 -e PORT=3000 -e FRONTEND_ORIGIN=http://localhost:5173 --env-file .env.local api
curl -i -H "Origin: http://localhost:5173" http://localhost:3000/healthThe response should carry the Access-Control-Allow-Origin header; if it does not, the browser will refuse the real calls and the symptom looks like a network error rather than a CORS one.
Numbers used: Vercel Hobby limits and fair-use terms and Netlify's Free plan from their pricing and documentation pages as of September 2026; SnapDeploy free tier from the free tier docs.