Deploying Next.js to a free VM: local to live, step by step
The complete path from a Next.js repo on your laptop to an HTTPS site on your own domain, on a free Oracle VM. Every command, the real deploy.yml, and the registrar settings people get wrong.
This is the guide I wanted when I put this site on the internet. It goes from a Next.js repo on a laptop to https://imrpm.com served from a free Oracle Cloud VM, deployed automatically on every push to main.
Nothing here is theoretical — it's the pipeline running this page right now: GitHub Actions builds an arm64 image, pushes it to GitHub Container Registry, SSHes into the VM and restarts the containers behind Nginx with a Let's Encrypt certificate.
I hit seven distinct failures getting there, none of them in the application code. To keep this guide readable I've pulled those into a companion post: the seven things that broke. If a step here fails, that's where the symptom-to-cause table lives.
Total cost: $0/month, permanently. Time: an afternoon if nothing fights you.
Before you start: is this the right approach?
Be honest about this, because the answer for most people is no.
| You want | Use |
|---|---|
| The site live, minimum fuss | Vercel or Netlify — free, push to deploy, TLS included |
| A static site with a CDN | Cloudflare Pages |
| A real server you control | This guide |
| Predictable production hosting | Hetzner / DigitalOcean, ~$5/month |
I started on Vercel and it was working fine. I moved because I wanted a box I control, a Redis I own rather than rent, and direct exposure to the layers a PaaS hides. Those are good reasons. "It's cheaper" is not one — Vercel was already free.
If you're still here, you'll end up owning OS patches, firewall rules, certificate renewal and disk cleanup. That's the trade.
Where each command runs
This trips people up more than any individual command, so every block below is labelled. Four different places are involved:
| Label | Means |
|---|---|
| In your repo | A file you edit on your laptop and commit to git |
| On your laptop | A terminal on your own machine |
| On the VM | A terminal after ssh ubuntu@YOUR_VM_IP |
| In a browser | A web console — Oracle Cloud, GitHub settings, or your registrar |
If a command misbehaves, check you're in the right one before anything else. An
ssh that silently failed will leave you running VM commands on your laptop,
where they either error confusingly or, worse, quietly succeed against the wrong
machine.
Step 1 — Prepare the Next.js app
In your repo. Two changes before anything else.
Enable standalone output in next.config.ts. This emits .next/standalone — a self-contained server.js with only the dependencies your routes actually trace to. Without it your image has to carry a full node_modules.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;Add a Dockerfile at the app root. Three stages, so editing source doesn't reinstall dependencies:
ARG NODE_VERSION=22-alpine
FROM node:${NODE_VERSION} AS deps
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
RUN npm ci
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN apk add --no-cache libc6-compat \
&& addgroup -g 1001 -S nodejs \
&& adduser -u 1001 -S nextjs -G nodejs
# standalone deliberately excludes these two, so copy them explicitly
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/ >/dev/null 2>&1 || exit 1
CMD ["node", "server.js"]Two details that bite people: HOSTNAME=0.0.0.0 is required or the server binds to localhost inside the container and nothing can reach it. And public/ and .next/static/ must be copied separately — output: standalone leaves them out on purpose, assuming a CDN might serve them.
Verify locally before going further:
docker build -t myapp:local ./web
docker run --rm -p 3000:3000 myapp:local
curl -I http://localhost:3000If that doesn't return 200, stop here. Nothing downstream will fix it.
Step 2 — Create the VM
In a browser. Oracle Cloud → Compute → Instances → Create.
- Shape: Ampere A1 (Arm). This is the Always Free one. Up to 2 OCPU / 12GB across your tenancy — I run a 1 OCPU / 6GB slice.
- Image: Ubuntu 24.04
- SSH key: upload your public key, or let Oracle generate one and download it immediately — you cannot retrieve it later.
Note the architecture now. Ampere A1 is aarch64, not x86. On your laptop, connect and confirm:
ssh -i ~/.ssh/your-key ubuntu@YOUR_VM_IP
uname -m # aarch64This one fact determines how you build images in Step 5, and it's the least-advertised property of the free tier.
Step 3 — Open the firewalls (both of them)
Oracle has two independent firewalls. Opening one and not the other looks identical to opening neither, and this cost me more time than anything else.
3a. In a browser — the cloud-level VCN Security List. Networking → Virtual Cloud Networks → your VCN → Security Lists → Default → Add Ingress Rules:
| Source | Protocol | Destination port |
|---|---|---|
0.0.0.0/0 |
TCP | 80 |
0.0.0.0/0 |
TCP | 443 |
Leave Stateless unchecked — stateful rules allow the return traffic automatically.
3b. On the VM — its own iptables. Oracle's images ship a default-reject chain. Note ufw is usually not installed, so ufw status returning "command not found" does not mean you're clear:
sudo iptables -L INPUT -n -v --line-numbersYou'll see SSH allowed, then a catch-all REJECT — often at line 5. Insert before it (iptables stops at the first match):
sudo iptables -I INPUT 5 -m state --state NEW -p tcp --dport 80 -j ACCEPT
sudo iptables -I INPUT 5 -m state --state NEW -p tcp --dport 443 -j ACCEPT
sudo iptables -L INPUT -n -v --line-numbers # confirm both sit above REJECT
sudo netfilter-persistent save # or they vanish on rebootUse your own REJECT line number if it isn't 5.
Step 4 — Install Docker and prepare the deploy directory
On the VM, for everything in this step.
sudo apt-get update
sudo apt-get install -y docker.io docker-compose-plugin
sudo usermod -aG docker ubuntu
exit # group membership needs a NEW sessionThat exit drops you back to your laptop. SSH back into the VM and verify — do this even if Docker was preinstalled, because then you'd have skipped the section containing the usermod line:
docker ps # must work without sudoStill on the VM, create the deploy directory:
sudo mkdir -p /opt/rpm-portfolio
sudo chown ubuntu:ubuntu /opt/rpm-portfolioStep 5 — The deploy workflow
In your repo, create .github/workflows/deploy.yml. This is the actual file running this site:
name: Build and deploy to Oracle VM
on:
push:
branches: [main]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
# The VM is Ampere/aarch64, so build natively on an arm runner rather
# than emulating arm64 under QEMU on an x86 one - emulated builds
# commonly run 3-5x slower.
runs-on: ubuntu-24.04-arm
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
- uses: docker/build-push-action@v5
with:
context: ./web
push: true
platforms: linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to Oracle VM
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
REGISTRY: ${{ env.REGISTRY }}
IMAGE_NAME: ${{ github.repository }}
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << EOF
set -e
IMAGE_FULL="${REGISTRY}/${IMAGE_NAME}:main"
cd /opt/rpm-portfolio
docker pull "\$IMAGE_FULL"
docker compose down || true
sed -i "s|image: .*rpm-portfolio:.*|image: \$IMAGE_FULL|g" docker-compose.yml
docker compose up -d
sleep 10
docker compose ps
EOFThree things worth understanding rather than copying blindly:
The architecture line. runs-on: ubuntu-24.04-arm with platforms: linux/arm64 builds natively for the target. The alternative is an x86 runner plus docker/setup-qemu-action and platforms: linux/amd64,linux/arm64 — that works, but emulated arm64 builds are dramatically slower. If your VM is x86, drop both lines entirely and let it default.
The heredoc escaping. Inside << EOF, ${REGISTRY} expands on the runner (before the command is sent), while \$IMAGE_FULL escapes so it expands on the VM. Getting this backwards produces empty variables in the remote shell — a genuinely confusing failure.
secrets.GITHUB_TOKEN needs no setup. GitHub injects it per run. It's what pushes the image, and it's a completely different credential from the one the VM uses to pull. Conflating those cost me a debugging round-trip.
Step 6 — Deploy credentials
6a. On your laptop — generate a dedicated SSH key, not your personal one:
ssh-keygen -t ed25519 -f ~/.ssh/rpm-deploy -C "rpm-deploy" -N ""
ssh-copy-id -i ~/.ssh/rpm-deploy.pub ubuntu@YOUR_VM_IP
ssh -i ~/.ssh/rpm-deploy ubuntu@YOUR_VM_IP "docker --version"6b. In a browser — add three repository secrets. GitHub → Settings → Secrets and variables → Actions. Make sure you're under Repository secrets — GitHub also has Environment secrets on a separate page, and a job without an environment: key cannot see those. They fail silently as empty strings.
| Secret | Value |
|---|---|
DEPLOY_HOST |
VM public IP |
DEPLOY_USER |
ubuntu |
DEPLOY_KEY |
entire contents of ~/.ssh/rpm-deploy, including BEGIN/END lines |
6c. Create the token in a browser, then run docker login on the VM. GHCR packages are private by default. This must be a classic token — fine-grained PATs cannot access Packages at all, which is a documented gap, not a checkbox you're missing. Settings → Developer settings → Tokens (classic) → scope read:packages:
# on the VM
docker login ghcr.io -u YOUR_GITHUB_USERNAME
# paste the classic token as the passwordSimpler alternative: make the package public and skip this entirely. For a portfolio image with no baked-in secrets, that's a reasonable call.
Step 7 — Compose file on the VM
The deploy job only ever touches one file on the VM, so copy just that — no need to clone the repo there. From your local machine:
scp docker-compose.prod.yml ubuntu@YOUR_VM_IP:/opt/rpm-portfolio/docker-compose.ymlservices:
app:
image: ghcr.io/YOUR_USERNAME/YOUR_REPO:main
container_name: rpm-portfolio
ports:
# loopback only - Nginx is the only thing that should reach this
- "127.0.0.1:3000:3000"
environment:
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: rpm-redis
volumes:
- redis-data:/data
command: ["redis-server", "--appendonly", "yes"]
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
volumes:
redis-data:Match the image tag to what your workflow actually pushes. The metadata-action config above produces :main from the branch — not :latest. :latest is the more common convention and is not pushed here; using it gives you a confusing "not found" on first deploy.
Step 8 — First deploy
On your laptop:
git add .github/workflows/deploy.yml
git commit -m "Add deploy workflow"
git push origin mainWatch Actions in a browser. When it's green, verify on the VM:
ssh ubuntu@YOUR_VM_IP
cd /opt/rpm-portfolio
docker compose ps # both containers healthy
curl -I http://127.0.0.1:3000Then from your own machine, against the raw IP — this proves the firewalls from Step 3:
curl -I http://YOUR_VM_IPDon't move on until this returns 200. If it hangs, it's the VCN Security List; if it refuses instantly, it's iptables. That latency difference is the fastest diagnostic in the whole process — a drop times out, a REJECT answers immediately.
Step 9 — Point your domain
In a browser, at your registrar. I use Squarespace Domains, but every registrar has the same trap.
Go to your domain → DNS → DNS Settings. You'll almost certainly find a preset — Squarespace calls it "Squarespace Defaults" — containing four A records pointing at the registrar's own parking IPs.
Delete that preset first. This is the part people get wrong: adding your own @ record does not replace those. You end up with five A records, and DNS will hand different resolvers different answers, so your site loads for some visitors and shows a "coming soon" page for others.
- Delete the default/parking preset (trash icon on the card)
- Under Custom records, add:
| Type | Host | Data | TTL |
|---|---|---|---|
A |
@ |
your VM's public IP | lowest available |
Leave the Domain Connect and Email Security presets alone — those are CNAME/TXT records for domain linking and email, unrelated to web traffic.
Set a low TTL now, before you ever need to repoint. Then verify on your laptop:
dig +short imrpm.com @8.8.8.8Wait until that returns only your VM's IP. Mixed results mean the old records are still cached — those defaults often carry a 4-hour TTL, and public resolvers expire independently. Deleting them late doesn't reset that clock.
One warning that wasted an hour for me: your browser has its own DNS cache, separate from everything above. After dig was correct, Chrome still showed the old parked page while incognito worked fine. If terminal and browser disagree, believe the terminal, then clear chrome://net-internals/#dns.
Step 10 — HTTPS with Nginx and Certbot
10a. On the VM — free port 80. Nginx needs it, so the app must move to loopback — that's the 127.0.0.1:3000:3000 in Step 7. If you published 80:3000 earlier, change it and re-run docker compose up -d.
10b. On the VM — install:
sudo apt-get install -y nginx certbot python3-certbot-nginx10c. On the VM — reverse proxy config:
sudo tee /etc/nginx/sites-available/imrpm.com > /dev/null << 'EOF'
server {
listen 80;
server_name imrpm.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOF
sudo ln -s /etc/nginx/sites-available/imrpm.com /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl restart nginxnginx -t only validates syntax — it does not tell you the service is running. Check separately:
sudo systemctl status nginx # active (running)
curl -I http://localhost # on the VM10d. On the VM — get the certificate. DNS must be fully settled first — Certbot validates against whatever IP the domain currently resolves to:
sudo certbot --nginx -d imrpm.comAnswer the email prompt and say yes to the HTTP→HTTPS redirect. Certbot rewrites your Nginx config to add the 443 block and the redirect; you don't edit anything by hand.
10e. Verify, including renewal. First on the VM:
sudo certbot renew --dry-runThen on your laptop, against the real domain:
curl -I https://imrpm.comThe certbot package installs a systemd timer that renews automatically. The dry run is what proves it'll actually work in 60 days — don't skip it.
You're live
From here, deploying is git push origin main.
The maintenance you now own
Worth being clear-eyed:
- Firewall rules vanish on reboot unless you ran
netfilter-persistent save - Certificate renewal is automatic, but verify with the dry run after any change to how Nginx runs
- Disk fills with old images —
docker image prune -aoccasionally - Nobody pages you when it goes down
- OS patches are yours:
sudo apt-get update && sudo apt-get upgrade
If that list reads as a chore rather than a curiosity, use Vercel. The site is the point, not the server.
When it breaks
It will, and probably not where you expect. I hit seven failures across this pipeline and not one was in the app: empty secrets, a Docker group, a token type that cannot do the job, the wrong CPU architecture, five DNS records, and two separate firewalls.
Each one is written up with its exact symptom in the companion post: The seven things that broke.