Deploy Your Next.js App to a $12 VPS: A Complete Walkthrough

If you’ve only ever deployed by clicking a button in a dashboard, the idea of deploying to your own server probably feels like entering unfamiliar territory. This guide will walk you through the entire process, from signing up for a server to having your Next.js app running on a real domain with HTTPS. By the end, you’ll have a production-ready deployment that costs about $12 per month and gives you complete control over your stack.

What You’ll Need

Before we start, gather these:

  • About 1-2 hours of uninterrupted time
  • A credit card (for DigitalOcean)
  • A domain name (optional but recommended – we’ll cover both scenarios)
  • Basic familiarity with the terminal
  • Basic understanding of Git

No prior server management experience required. We’ll explain everything as we go.

Part 1: Get a Server

We’re using DigitalOcean because their interface is straightforward and their documentation is excellent. You could use Hetzner, Linode, or Vultr just as easily – the concepts are the same. Head to digitalocean.com and create an account. You’ll need to add a payment method. Once you’re in, click the green “Create” button and select “Droplets” (that’s what DigitalOcean calls their servers).

Choosing your configuration:

  • Image: Ubuntu 24.04 LTS (the long-term support version)
  • Droplet Type: Basic (the shared CPU option)
  • CPU Options: Regular, $12/month (2GB RAM, 1 CPU, 50GB SSD)
  • Datacenter: Pick the region closest to your users
  • Authentication: Password (we’ll set up SSH keys later)
  • Hostname: Give it a memorable name like “nextjs-production”

The 2GB RAM option is important. Next.js builds can be memory-intensive, and the $6 option with 1GB will struggle. The extra $6 per month is worth the stability.

Click “Create Droplet” and wait about 60 seconds. DigitalOcean will email you the root password and show you your server’s IP address. Save both somewhere secure.

Part 2: Connect to Your Server

Open your terminal. If you’re on Windows, use PowerShell or Windows Terminal. Type this command, replacing your_server_ip with the actual IP address DigitalOcean gave you:

ssh root@your_server_ip

You’ll see a warning about the authenticity of the host. Type yes and press enter. This is normal for the first connection. Enter the password from the email when prompted. You won’t see characters appear as you type – that’s a security feature. If everything worked, your terminal prompt should change to something like root@nextjs-production:~#. You’re now inside your server. Take a moment to let that sink in. You’re literally inside a computer in a datacenter somewhere. Everything you type here happens on that machine.

Part 3: Initial Server Setup

First things first – update the system packages:

apt update && apt upgrade -y

This might take a few minutes. It’s updating all the software that came pre-installed on the server.

Create a non-root user

Running everything as root is risky. If you make a mistake, you could break the entire system. Let’s create a regular user with sudo privileges:

adduser deploy

You’ll be asked to set a password. Choose something strong and save it. You can skip all the other questions by pressing enter.

Give this user sudo privileges:

usermod -aG sudo deploy

Set up SSH keys

Right now, you’re logging in with a password. SSH keys are more secure and more convenient. On your local machine (not the server), open a new terminal window and run:

ssh-keygen -t ed25519 -C "[email protected]"

Press enter to accept the default location. You can set a passphrase or leave it empty.

Now copy your public key to the server. Still on your local machine:

ssh-copy-id deploy@your_server_ip

Enter the deploy user’s password when prompted.

Note for Windows users: If ssh-copy-id isn’t available, you can manually copy your key. Run cat ~/.ssh/id_ed25519.pub on your local machine to display your public key, copy it, then on the server run:

mkdir -p ~/.ssh
nano ~/.ssh/authorized_keys

Paste your public key, press Ctrl+X, then Y, then Enter. Then set permissions:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

Test it by opening a new terminal window and connecting:

ssh deploy@your_server_ip

You should get in without typing a password. If this works, you can close your old terminal window where you were logged in as root.

Optional but recommended: Harden SSH access

Now that SSH keys work, you can improve security by disabling password authentication and root login. Edit the SSH config:

sudo nano /etc/ssh/sshd_config

Find and change these lines (or add them if they don’t exist):

PermitRootLogin no
PasswordAuthentication no

Press Ctrl+X, then Y, then Enter to save. Reload SSH:

sudo systemctl reload sshd

Important: Make sure you can still connect with ssh deploy@your_server_ip before closing your current session. If something goes wrong, you can always access the server through DigitalOcean’s web console.

Set up a basic firewall

Ubuntu comes with a firewall tool called ufw. Let’s configure it to allow SSH, HTTP, HTTPS, and temporarily port 3000 for testing:

sudo ufw allow OpenSSH
sudo ufw allow http
sudo ufw allow https
sudo ufw allow 3000/tcp
sudo ufw enable

Type y when asked to confirm. Check the status:

sudo ufw status

You should see those four ports listed as allowed. We’ll remove port 3000 access later once we have Caddy running in front of our app.

Part 4: Install Docker

Docker lets us package our Next.js app with all its dependencies into a container. This means the app will run the same way regardless of what else is on the server.

Install Docker with these commands:

sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Add your user to the docker group so you don’t need sudo for every docker command:

sudo usermod -aG docker deploy

Log out and log back in for this to take effect:

exit
ssh deploy@your_server_ip

Verify Docker is working:

docker --version
docker compose version
docker run hello-world

If you see version numbers and a welcome message, Docker is installed correctly.

Part 5: Prepare Your Next.js App

Let’s create a simple Next.js app that we can deploy. On your local machine, create a new directory:

npx create-next-app@latest my-vps-app

When prompted:

  • TypeScript? Your choice (we’ll use Yes)
  • ESLint? Yes
  • Tailwind? Your choice
  • src/ directory? No
  • App Router? Yes
  • Import alias? No
  • Package manager? Choose npm (this guide uses npm and package-lock.json)

Navigate into the project:

cd my-vps-app

Create a Dockerfile

In the root of your project, create a file called Dockerfile:

FROM node:20-alpine AS base

# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED=1

RUN npm run build

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 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

ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

This Dockerfile uses Next.js’s standalone output mode, which creates a minimal production build. Let’s enable that.

Update next.config.js

Open next.config.js (or next.config.mjs if you chose TypeScript) and modify it to enable standalone mode:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
}

export default nextConfig

Create docker-compose.yml

In the root of your project, create docker-compose.yml:

version: '3.8'

services:
  web:
    build: .
    ports:
      - "3000:3000"
    restart: unless-stopped
    environment:
      - NODE_ENV=production

This tells Docker Compose how to build and run your app. The restart: unless-stopped means if your app crashes, Docker will automatically restart it.

Create .dockerignore

Just like .gitignore, we want to exclude unnecessary files from our Docker build:

node_modules
.next
.git
.gitignore
README.md
.env*.local

Push to GitHub

Initialize a git repository and push your code:

git init
git add .
git commit -m "Initial commit"

Create a new repository on GitHub (don’t initialize it with anything), then:

git remote add origin https://github.com/yourusername/my-vps-app.git
git branch -M main
git push -u origin main

Part 6: Deploy Your App

Back in your SSH session on the server, clone your repository:

cd ~
git clone https://github.com/yourusername/my-vps-app.git
cd my-vps-app

Build and start your app:

docker compose up -d --build

The -d flag runs it in the background. The --build flag tells Docker to build the image first. This will take a few minutes the first time.

Watch the build progress:

docker compose logs -f

Press Ctrl+C to stop watching the logs (the app keeps running).

Check if your app is running:

docker compose ps

You should see your web service with a status of “Up”.

Test it

Open your browser and go to http://your_server_ip:3000. You should see your Next.js app running.

Take a moment to appreciate this. You just built and deployed a containerized application on a server you’re managing yourself.

Part 7: Set Up a Domain and HTTPS

Right now your app is accessible via IP and port 3000. That’s not great for production. Let’s put it behind a proper domain with HTTPS.

Point your domain to your server

Go to your domain registrar (wherever you bought your domain) and add an A record:

  • Type: A
  • Name: @ (or leave blank for root domain)
  • Value: your_server_ip
  • TTL: 300 (or whatever the default is)

If you want to use a subdomain like app.yourdomain.com, create an A record with Name: app instead. DNS changes can take up to 24 hours to propagate, but usually happen within a few minutes.

Install Caddy

Caddy is a web server that automatically handles HTTPS certificates. It’s simpler than Nginx for first-timers.

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

Configure Caddy

Edit the Caddy configuration file:

sudo nano /etc/caddy/Caddyfile

Replace everything in that file with this (use your actual domain):

yourdomain.com {
    reverse_proxy localhost:3000
}

Press Ctrl+X, then Y, then Enter to save and exit.

Restart Caddy:

sudo systemctl restart caddy

Test it

Go to https://yourdomain.com in your browser. You should see your Next.js app with a valid SSL certificate. The little padlock icon in your browser should be green. Caddy automatically obtained an SSL certificate from Let’s Encrypt and will renew it before it expires.

Clean up: Remove port 3000 access

Now that Caddy is handling traffic on ports 80 and 443, you can remove direct access to port 3000:

sudo ufw delete allow 3000/tcp

Your app is now only accessible through your domain with HTTPS, not via the raw port.

Part 8: Updating Your App

When you want to deploy changes, the process is straightforward.

On your local machine, make your changes, commit them, and push:

git add .
git commit -m "Update something"
git push

On your server:

cd ~/my-vps-app
git pull
docker compose up -d --build

The --build flag rebuilds the Docker image with your changes. The -d flag keeps it running in the background.

If you want zero-downtime deployments, you’d need to set up a more sophisticated approach with load balancers or rolling updates, but for most side projects, a few seconds of downtime during deploys is acceptable.

Part 9: Basic Monitoring

You should know when something goes wrong. Here are the essential commands:

Check if your app is running:

docker compose ps

View logs:

docker compose logs -f

View the last 100 lines of logs:

docker compose logs --tail=100

Restart your app:

docker compose restart

Stop your app:

docker compose down

Start your app:

docker compose up -d

Check server resource usage:

First install htop if you haven’t already:

sudo apt install htop

Then run it:

htop

Press Q to quit htop.

Troubleshooting

“Permission denied” when running docker commands

You forgot to log out and back in after adding your user to the docker group. Run:

exit
ssh deploy@your_server_ip

Docker build fails with “no space left on device”

Your server ran out of disk space. Clean up old Docker images:

docker system prune -a

App builds successfully but shows 502 error

Check the logs:

docker compose logs

The app might be crashing on startup. Common causes include missing environment variables or database connection issues.

Can’t connect to the server via SSH

Make sure you’re using the right IP address and that you haven’t been locked out by the firewall. If you locked yourself out, you can access the server through DigitalOcean’s web console (click “Access” on your droplet’s page).

Domain doesn’t point to server

DNS can take time to propagate. Check if DNS is working:

nslookup yourdomain.com

If it shows your server’s IP, DNS is working. If Caddy still can’t get a certificate, make sure ports 80 and 443 are open in your firewall and that nothing else is using those ports.

Caddy won’t start

Check the Caddy logs:

sudo journalctl -u caddy -n 50

Common issues include syntax errors in the Caddyfile or another process using port 80 or 443.

App runs locally but not on the server

Your local environment likely has different environment variables. Check what your app needs and add them to the docker-compose.yml file under the environment section:

environment:
  - NODE_ENV=production
  - DATABASE_URL=your_database_url
  - API_KEY=your_api_key

Never commit secrets to git. Instead, create a .env file on the server (add it to .gitignore) and reference it in docker-compose.yml:

env_file:
  - .env

What’s Next

You now have a production-ready Next.js deployment running on your own server. Here’s what you might want to explore next:

Add a database: PostgreSQL runs great in Docker. Add it as another service in your docker-compose.yml.

Set up automated backups: DigitalOcean offers automated snapshots for a few dollars per month, or you can write a cron job to backup your data to object storage.

Add monitoring: Tools like Uptime Kuma (also runs in Docker) can alert you when your site goes down.

Improve the deployment process: Look into GitHub Actions to automatically deploy when you push to main.

Learn more Docker: Understanding Docker volumes, networks, and multi-stage builds will help you build more sophisticated deployments.

Scale up: When one server isn’t enough, you can add a load balancer and multiple app servers.

But for now, you’ve accomplished something significant. You went from clicking a deploy button to managing your own infrastructure. You understand what’s actually running your application, and you control every piece of it. That knowledge doesn’t go away. Even if you go back to using managed platforms for your next project, you’ll understand what they’re actually doing behind those dashboards. And you’re paying $12 a month instead of $50.