Move a Next.js app from Vercel to your own server
Follow this on your own server. Start free, no card needed
Your Next.js app runs on Vercel and you want it on a server you control, with a fixed monthly bill. The code barely changes. What changes is everything Vercel did around it: building on every push, holding your environment variables, setting a few variables of its own, running cron jobs, and serving HTTPS. This guide moves each of those pieces to a single server, then switches DNS without downtime, and keeps the Vercel deployment as your fallback until the new one has proven itself.
What you’ll need
- A server. This guide uses a DigitalOcean Basic droplet,
s-1vcpu-2gb(1 vCPU, 2 GB of memory, 50 GB SSD), at $12 a month. If your build is heavy,s-2vcpu-4gbis $24 a month. Any Ubuntu 24.04 server with a public IP works the same way. - Access to the domain’s DNS, wherever it’s hosted today (Vercel’s nameservers or your registrar).
- The Vercel CLI on your own machine (
npm i -g vercel), logged in to the team that owns the project. - The repository Vercel deploys from, with a lockfile committed.
The commands assume Next.js 16 (16.4.3 at the time of writing) and Node.js 24, the current LTS line.
Step 1: List what Vercel does for you
Before you touch the server, check the project for the parts that depend on Vercel itself:
grep -rn "VERCEL_" --include=*.ts --include=*.tsx --include=*.js --include=*.mjs . | grep -v node_modules
cat vercel.json 2>/dev/null
You’ll typically find some of these:
- System environment variables. Vercel sets
VERCEL_ENV,VERCEL_URL,VERCEL_PROJECT_PRODUCTION_URL,VERCEL_GIT_COMMIT_SHAand others on every deployment. None of them exist on your server. Step 3 replaces the ones your code reads. - Cron jobs defined under
cronsinvercel.json. Vercel triggers them by sending a GET request to the path you listed, on a schedule in UTC. Step 8 moves them to the server’s cron. - Preview deployments for every branch and pull request. A single server runs one environment; the FAQ covers your options.
- Vercel-only packages such as
@vercel/analyticsor@vercel/functions. Check their documentation for what they do off Vercel before you rely on them.
The framework features themselves come with you. Next.js’s self-hosting guide states that Image Optimization through next/image and Proxy (the file convention formerly called Middleware) both work with zero configuration when you run the Next.js server.
Step 2: Export your environment variables
From the project directory, link it and pull the production variables into a file:
vercel link
vercel env pull .env.vercel --environment=production
Open .env.vercel and check every value. Variables stored with Vercel’s Secret type can’t be read back, so the CLI writes a placeholder for each of them. Get the real values from where they came from (your database provider, Stripe, your auth provider) and fill them in. Keep this file out of Git; the default Next.js .gitignore already ignores .env* files.
You should end up with a complete list of NAME=value lines and no placeholders.
Step 3: Replace the variables Vercel set for you
For each VERCEL_ variable your code reads, add a variable of your own and point the code at it. The common case is building absolute URLs:
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
Then add NEXT_PUBLIC_SITE_URL=https://app.example.com to your environment file. Do the same for auth libraries: Auth.js reads AUTH_URL (NextAuth v4 reads NEXTAUTH_URL), and on Vercel it could infer the URL. On your server, set it to your real domain.
Commit the code changes. You should be able to run grep -rn "VERCEL_" again and see nothing your app still needs.
Step 4: Turn on standalone output
Add output: "standalone" to next.config.ts, keeping your other options:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
With this, next build writes a self-contained server to .next/standalone, so the production image doesn’t need your full node_modules.
Step 5: Add a Dockerfile
This Dockerfile follows the official with-docker example from the Next.js repository: install dependencies with the package manager your lockfile belongs to, build, and run only the standalone output as the unprivileged node user.
FROM node:24-slim AS dependencies
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \
elif [ -f yarn.lock ]; then corepack enable yarn && yarn install --frozen-lockfile --production=false; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install --frozen-lockfile; \
else echo "No lockfile found." && exit 1; \
fi
FROM node:24-slim AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NODE_ENV=production
RUN if [ -f package-lock.json ]; then npm run build; \
elif [ -f yarn.lock ]; then corepack enable yarn && yarn build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm build; \
fi
FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]
On Vercel, every variable was present during the build. In Docker, only what you pass in is. Next.js inlines NEXT_PUBLIC_ variables into the browser bundle during next build, so each one needs an ARG and ENV pair in the builder stage, like NEXT_PUBLIC_SITE_URL above. Server-only variables don’t; the app reads them at runtime.
Add a .dockerignore so local files and secrets stay out of the build:
.git
.next
node_modules
.env*
Build and run it once on your own machine with the exported variables, to catch problems before the server is involved:
docker build --build-arg NEXT_PUBLIC_SITE_URL=http://localhost:3000 -t my-app .
docker run --rm -p 3000:3000 --env-file .env.vercel my-app
You should see the app at http://localhost:3000, not a crash about a missing variable.
Step 6: Prepare the server
Create the droplet (Ubuntu 24.04, Basic, $12), connect over SSH and install Docker with its official script:
curl -fsSL https://get.docker.com | sh
docker compose version
You should see a Compose version printed. Then clone the repository and copy your environment file to it, renamed to .env:
git clone https://github.com/you/your-app.git /opt/your-app
scp .env.vercel root@your-server-ip:/opt/your-app/.env
Run the first command on the server and the second on your own machine. On the server, make sure .env contains NEXT_PUBLIC_SITE_URL with your real domain, and lock the file down with chmod 600 /opt/your-app/.env.
Step 7: Add Compose and Caddy, then start
Next.js’s self-hosting guide recommends a reverse proxy in front of the Node server. Caddy fills that role and handles certificates. Add compose.yaml to the repository:
services:
app:
build:
context: .
args:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
restart: unless-stopped
env_file: .env
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- app
volumes:
caddy-data:
caddy-config:
And a Caddyfile with your domain:
app.example.com {
reverse_proxy app:3000
}
Commit both and git pull on the server. Start only the app for now. Caddy waits until Step 9, because it can’t get a certificate while the domain still points at Vercel:
cd /opt/your-app
docker compose up -d --build app
The first build takes a few minutes. Then check that the app answers inside its container:
docker compose exec app node -e "fetch('http://localhost:3000').then((r) => console.log(r.status))"
You should see 200. If you see an error instead, docker compose logs app shows why the app didn’t start, which is usually a missing variable.
Step 8: Move Vercel cron jobs to the server
For each entry under crons in vercel.json, add a crontab line that requests the same path. Vercel’s schedules are always UTC, and so is a fresh Ubuntu server, so the expressions copy across unchanged. If your route checks CRON_SECRET (Vercel sends it as Authorization: Bearer <value>), keep checking it and send the header yourself.
Create a small script on the server:
cat > /opt/your-app/cron-request.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
set -a
source /opt/your-app/.env
set +a
curl -fsS -H "Authorization: Bearer ${CRON_SECRET}" "https://app.example.com$1"
EOF
chmod 700 /opt/your-app/cron-request.sh
Then crontab -e and add one line per job, using the path and schedule from vercel.json:
0 5 * * * /opt/your-app/cron-request.sh /api/cron >> /var/log/your-app-cron.log 2>&1
Put a # in front of each line for now, and remove it once DNS has moved in Step 9, so no job runs on both platforms at once. Once it’s live, tail /var/log/your-app-cron.log after the scheduled time should show your route’s response.
Step 9: Switch DNS
Lower the TTL on the domain’s DNS record a day ahead if you can. Then change the record to point at your server: an A record with the server’s IPv4 address, replacing the Vercel record. If the domain uses Vercel’s nameservers, make the change in Vercel’s DNS settings, or move the domain to another DNS provider first.
Wait until the new address is what the world sees:
dig +short app.example.com
When that prints your server’s IP, start Caddy and watch it get the certificate:
docker compose up -d caddy
docker compose logs -f caddy
You should see a line saying the certificate was obtained successfully. Then https://app.example.com loads from your server with a valid certificate. Uncomment the crontab lines and disable the cron jobs in Vercel’s project settings.
Keep the Vercel project for a few days. Visitors whose DNS hasn’t refreshed still reach it, and if something goes wrong you can point the record back.
Step 10: Deploy updates and keep backups
A release is now:
cd /opt/your-app
git pull
docker compose up -d --build app
Caddy keeps running and keeps its certificates; the app is unavailable for the few seconds the new container takes to start. Vercel also stops building your pushes once you disconnect the Git integration, so do that when you’re confident in the new setup.
The server itself holds nothing you can’t rebuild except .env, so keep a copy of it in a password manager. Your database stays wherever it is today and keeps its own backups; you can migrate it separately later.
The shortcut: do it with Railyard
Steps 4 to 10 are what Railyard does for you. Connect the same repository, and it builds and deploys the app on a server in your own cloud account, with HTTPS and a redeploy on every push. There’s no Dockerfile, Compose file or Caddyfile to write.
- Add a server: in DigitalOcean’s control panel, open API, generate a personal access token with read and write access, and create the droplet from Servers → Add a server in Railyard. Or connect a server you already have (Ubuntu 22.04 or 24.04, passwordless
sudo, ports 22, 80 and 443 open). - Deploy the repository, or open
https://app.railyard.run/deploy?repo=https://github.com/you/your-app.gitwith your own URL. - Paste the variables from Step 2 into Variables → Bulk import.
Railyard picks the package manager from your lockfile, runs your build script and starts the app with npm start, listening on $PORT. Postgres or Redis added from the app page has its connection string injected as a variable. Recreate each vercel.json cron job as an entry in the app’s Cron jobs tab, which runs a command in the app’s image on a schedule (in UTC) and keeps a history of each run. To request a route, use Node’s built-in fetch with the app’s RAILYARD_URL variable:
node -e "fetch(process.env.RAILYARD_URL + '/api/cron', { headers: { authorization: 'Bearer ' + process.env.CRON_SECRET } }).then((r) => process.exit(r.ok ? 0 : 1))"
``` Pull-request previews can be turned on for the app once GitHub is connected; see [Environments and previews](/docs/environments-and-previews).
The Free plan covers one server plus a database server and three apps, with no card required; Pro is $19 a month per team and Team is $49 a month per team. Start with [Deploy from a repository](/docs/deploy-from-a-repository), or see [Deploy Next.js to DigitalOcean](/deploy/nextjs-to-digitalocean) for the provider side. Moving apps from Heroku, Render, Fly or Railway as well? [Import from another platform](/docs/import-from-another-platform) reads them with an API token.
## Troubleshooting
**The build fails with a missing variable, or a `NEXT_PUBLIC_` value is empty in the browser.** It wasn't present during `next build`. Add an `ARG` and `ENV` pair for it in the Dockerfile, pass it under `build.args` in `compose.yaml`, and rebuild. Restarting the container doesn't change values that were inlined at build time.
**Logins redirect to a `vercel.app` address.** Your auth URL still holds a Vercel value from the export. Set `AUTH_URL` or `NEXTAUTH_URL` to your domain in `.env` and run `docker compose up -d app`.
**Caddy logs certificate errors for hours.** DNS isn't pointing at the server yet, or a firewall blocks ports 80 and 443. `dig +short app.example.com` should return your server's IP. Caddy retries on its own once it does.
**Code that reads geolocation or Vercel request headers gets nothing.** Headers such as `x-vercel-ip-country` are added by Vercel's network and don't exist behind Caddy. Remove that code path or use a geolocation service of your own.
**The build is killed on the 2 GB server.** It ran out of memory. Add a 2 GB swap file (`fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile`), move to the $24 size, or build the image elsewhere and pull it from a registry.
## FAQ
**Will my app's code need to change?** Usually only where it reads `VERCEL_` variables or uses Vercel-specific packages. Routing, Server Components, Server Actions, Image Optimization and Proxy all run on the standalone server.
**What about preview deployments?** One server serves one environment. A second, smaller server for staging, built from another branch, covers most needs. On Railyard, pull-request previews are a setting on the app.
**Does ISR keep working?** Yes. Next.js stores its cache on the local disk of each server instance by default, which works for a single instance. The cache starts empty after each deploy, because the container is replaced.
**Can I keep using services I bought through Vercel?** Services that give you a connection string or API token, such as a Marketplace database, keep working from any server as long as you copy the credentials. Check each one's own documentation for limits outside Vercel.
The move is mostly bookkeeping: variables, cron jobs and DNS. Once those three are done, a release on your own server is a `git pull` and one command. Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.