← Blog

Deploy Next.js to DigitalOcean

Railyard

June 8, 2026 · 9 min read

Follow this on your own server. Start free, no card needed

You have a Next.js app that works locally, and you want it running on a DigitalOcean droplet you control instead of a platform that bills by usage. This guide takes you from an empty droplet to a live app on your own domain: a small Docker image built from Next.js’s standalone output, Caddy in front of it handling HTTPS certificates, and a short routine for shipping the next release.

What you’ll need

  • A DigitalOcean droplet. The Basic s-1vcpu-2gb size (1 vCPU, 2 GB of memory, 50 GB SSD, 2,000 GB of transfer) costs $12 a month and runs a typical Next.js app. If your build is heavy, the s-2vcpu-4gb size (2 vCPU, 4 GB, 80 GB SSD) costs $24 a month. Prices are from DigitalOcean’s droplet pricing page.
  • A domain name whose DNS you can edit.
  • A DigitalOcean account with an SSH key added.
  • A Next.js app in a Git repository, with a lockfile committed (package-lock.json, yarn.lock or pnpm-lock.yaml).

This guide uses Next.js 16.4.3 and Node.js 24, the current LTS release line, through the official node:24-slim image.

Step 1: Create the droplet

In the DigitalOcean control panel, choose Create → Droplets. Pick Ubuntu 24.04 (LTS) x64, the Basic plan with a Regular SSD, and the $12 size. Choose a region close to your users (Frankfurt or Amsterdam keep the data in the EU), select your SSH key, and create the droplet.

When it’s ready, copy its public IPv4 address and connect:

ssh root@your-droplet-ip

You should see the Ubuntu welcome banner and a root@ prompt.

While you’re in the DNS settings for your domain, create an A record that points the domain (or a subdomain such as app.example.com) at that IP. DNS can take a few minutes to propagate, and Caddy needs it in place before it can get a certificate in Step 5, so it’s worth doing now.

Step 2: Install Docker

Ubuntu doesn’t ship Docker. Docker’s official convenience script installs the engine and the Compose plugin in one go:

curl -fsSL https://get.docker.com | sh

Check that both are there:

docker --version
docker compose version

You should see a version line for each and no errors.

Step 3: Turn on standalone output

Next.js can trace exactly which files the production server needs and copy them into .next/standalone, along with a small server.js that starts the app. That keeps the final image small, because it doesn’t carry your whole node_modules folder.

Open next.config.ts (or next.config.js) and add output: "standalone" to the config object, keeping any options you already have:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;

Commit the change. You should see .next/standalone/server.js appear the next time you run npm run build locally.

Step 4: Add a Dockerfile

Add this Dockerfile at the root of the repository. It follows the structure of the official with-docker example in the Next.js repository: one stage installs dependencies with whichever package manager your lockfile belongs to, one builds the app, and the last one runs 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"]

Two lines need a word of explanation. ARG NEXT_PUBLIC_SITE_URL and the ENV line after it exist because Next.js inlines every NEXT_PUBLIC_ variable into the browser bundle during next build. A value you only provide when the container starts is too late for those. Add one ARG and ENV pair per NEXT_PUBLIC_ variable your app uses, or remove the pair if it uses none. Server-only variables (database URLs, API secrets) don’t belong here; they’re read at runtime.

Next to it, add a .dockerignore so your local node_modules, build output and environment files never enter the build:

.git
.next
node_modules
.env*

Excluding .env* matters: without it, COPY . . would copy your secrets into a build layer.

Step 5: Add Compose and Caddy

Next.js’s self-hosting guide recommends putting a reverse proxy in front of the Node server rather than exposing it directly. Caddy does that job and also gets and renews a Let’s Encrypt certificate on its own.

Create compose.yaml in 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:

Only Caddy publishes ports. The app listens on port 3000 inside the Compose network, where Caddy reaches it by its service name. The caddy-data volume holds your certificates, so they survive restarts.

Then a Caddyfile next to it, with your own domain:

app.example.com {
	reverse_proxy app:3000
}

Commit both files.

Step 6: Put the code and the secrets on the droplet

On the droplet, clone the repository. For a private repository, add a read-only deploy key to the repository first, or clone over HTTPS with a token:

git clone https://github.com/you/your-app.git /opt/your-app
cd /opt/your-app

Create the .env file there. It never goes into Git (the default create-next-app .gitignore already ignores .env* files), so it stays on the server between deploys:

cat > .env <<'EOF'
NEXT_PUBLIC_SITE_URL=https://app.example.com
DATABASE_URL=postgres://user:password@db-host:5432/app
EOF
chmod 600 .env

Compose uses this one file twice: it fills ${NEXT_PUBLIC_SITE_URL} in compose.yaml for the build, and env_file hands every variable to the running app.

Step 7: Build and start

docker compose up -d --build

The first build takes a few minutes. Then check the containers:

docker compose ps

You should see app and caddy both with a status of Up. Follow Caddy’s log while it gets the certificate:

docker compose logs -f caddy

You should see a line saying the certificate was obtained successfully. Open https://app.example.com and your app loads with a valid certificate.

Step 8: Ship updates

Every release is the same three commands on the droplet:

cd /opt/your-app
git pull
docker compose up -d --build app

Compose rebuilds the image and replaces the app container. Caddy keeps running and keeps its certificate. Expect a few seconds of errors while the new container starts, because there’s only one copy of the app; if that matters, deploy at a quiet time.

To roll back, check out the previous commit and rebuild:

git log --oneline -5
git checkout <previous-commit>
docker compose up -d --build app

Run git checkout main (or your branch name) before the next normal deploy.

Step 9: Backups

The app itself is stateless: its code lives in Git and its image can be rebuilt at any time. What you can’t rebuild is the .env file, so keep a copy somewhere other than the droplet, such as a password manager. If the app uses a database, that database is the real thing to back up; DigitalOcean’s droplet backups or a scheduled pg_dump copied off the server both work.

Next.js keeps its page and data cache on the container’s disk by default. That works for a single instance like this one, and the cache simply starts empty after each deploy, since the container is replaced.

The shortcut: do it with Railyard

The manual path works, and you now know every piece of it. Railyard does the same job without the Dockerfile, the Compose file or the Caddyfile. See Deploy Next.js to DigitalOcean for the full walkthrough.

  1. In DigitalOcean’s control panel, open API and generate a personal access token with read and write access.
  2. In Railyard, go to Servers → Add a server → Create on your cloud, pick DigitalOcean, a region and a size (s-1vcpu-2gb is the default). The droplet is created in your own account and DigitalOcean bills you directly, with no markup. You can also bring a droplet you already have: any Ubuntu 22.04 or 24.04 server with passwordless sudo and ports 22, 80 and 443 open.
  3. Deploy the repository, or open https://app.railyard.run/deploy?repo=https://github.com/you/your-app.git with your own repository URL.

Railyard picks npm, pnpm, yarn or bun from your lockfile, runs your build script and starts the app with npm start. The app listens on $PORT, and HOST and similar variables are set for you. Add Postgres or Redis from the app page and the connection string is injected as a variable. Large front-end builds are fitted to the build server’s memory, so you don’t have to manage swap or Node’s heap size yourself. Every push redeploys, and HTTPS is handled for you.

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. The Node.js docs list the detection rules, and the quickstart walks through a first deploy.

Troubleshooting

bind: address already in use for port 80 or 443. Another web server on the droplet already holds the port. sudo ss -tlnp | grep -E ':(80|443) ' shows which process. Stop and disable it (sudo systemctl disable --now nginx, for example), then run docker compose up -d again.

The certificate never arrives. Almost always DNS. Run dig +short app.example.com and compare the answer with the droplet’s IP. If they differ, fix the A record and wait; Caddy keeps retrying on its own. If the droplet has a cloud firewall, ports 80 and 443 must be open, because Let’s Encrypt validates over them.

A NEXT_PUBLIC_ value is empty or stale in the browser. It was missing at build time. Check that it’s in .env, that compose.yaml passes it under build.args, that the Dockerfile has a matching ARG and ENV pair, and rebuild with docker compose up -d --build app. Changing .env and restarting isn’t enough for these.

next build is killed partway through. The 2 GB droplet ran out of memory, and the kernel killed the build. Add swap and build again:

fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

Resizing to the $24 size also works, and so does building the image elsewhere and pulling it from a registry.

Login or redirects point at the wrong URL. Your auth library still has an old URL. If you use Auth.js or NextAuth, check AUTH_URL or NEXTAUTH_URL in .env, then restart with docker compose up -d app.

FAQ

Does this work with pnpm or yarn? Yes. The Dockerfile picks the package manager from the lockfile, as the official Next.js example does. A bun lockfile isn’t handled by this Dockerfile; add a branch for it or commit a package-lock.json.

Can I run more than one app on the same droplet? Yes. Give each app its own service in compose.yaml and its own site block in the Caddyfile (other.example.com { reverse_proxy other:3000 }). One Caddy serves all of them and manages each certificate.

Do Image Optimization and Proxy still work? According to Next.js’s self-hosting guide, both next/image optimization and Proxy (the file convention formerly called Middleware) work with zero configuration when you run the Next.js server, which is what server.js does.

Do I need a database? Only if your app uses one. Point DATABASE_URL in .env at a managed database or one you run yourself, and back it up on its own schedule.

Should I disable telemetry? Next.js collects anonymous telemetry by default. Add ENV NEXT_TELEMETRY_DISABLED=1 to the builder and runner stages if you’d rather opt out.

Once this is in place, a release is a git pull and one Compose command, and the bill is a flat $12 a month with 2,000 GB of transfer included, instead of a number that moves with your traffic.

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.

Skip the droplet setup.

Connect your DigitalOcean account, paste your repository URL, and Railyard builds, deploys and serves your Next.js app over HTTPS.

Free plan, no card, and 14 days of Pro for every new team. One flat price per team.