Deploying Next.js to Portainer: Everything That Went Wrong

5 min read
Deploying Next.js to Portainer: Everything That Went Wrong

Deploying Next.js to Portainer: Everything That Went Wrong

In my first post I mentioned this site runs on my own server via Docker. What I didn't mention is how many hours it took to get there. This is that story.

Spoiler: a lot went wrong. Let's go through it in order.


The Setup

The site is a Next.js 16 app with output: 'standalone' β€” which produces a self-contained server.js you can run without installing anything. Perfect for Docker.

The Dockerfile is a standard multi-stage build:

FROM node:20-slim AS deps
# install dependencies

FROM deps AS builder
# build the app

FROM node:20-slim AS runner
# copy only what's needed and run

Portainer pulls the repo from GitHub and deploys the stack from docker-compose.yml. Simple enough in theory.


Problem 1: Alpine Linux and Native Binaries

My first Dockerfile used node:18-alpine. Alpine is small, fast, and popular β€” what could go wrong?

Error: Could not load native binding

Next.js 16 ships Turbopack, which includes native binaries compiled for glibc (the standard Linux C library). Alpine uses musl libc β€” a leaner alternative that is not compatible with glibc binaries.

The fix: switch to a Debian-based image.

FROM node:20-slim AS base

node:20-slim is Debian slim β€” it has glibc, it works, and it's still reasonably small. Alpine is great for many things, but not for apps that ship native binaries.


Problem 2: Windows node_modules in the Build Context

I develop on Windows. My local node_modules contains Windows-compiled native extensions. Without a .dockerignore, Docker copies the entire project into the build context β€” including node_modules.

Inside the container (Linux), those Windows binaries are useless at best and breaking at worst. The Linux npm ci layer gets overwritten by the Windows one.

The fix: a .dockerignore file.

node_modules
.next
.git
.env

With this in place, Docker ignores the local junk and only builds from source. The npm ci inside the container installs clean Linux dependencies.


Problem 3: Portainer Can't Find .env

I had this in my docker-compose.yml:

env_file:
  - .env

Portainer deploys to a server where there is no .env file β€” because the secrets are injected through Portainer's environment variables UI, not a local file.

The fix: reference environment variables directly.

environment:
  - NEXTAUTH_URL=${NEXTAUTH_URL}
  - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
  - GITHUB_ID=${GITHUB_ID}
  - GITHUB_SECRET=${GITHUB_SECRET}
  - ADMIN_USERNAME=${ADMIN_USERNAME}

Portainer substitutes ${VAR} from its own environment settings. No .env file needed on the server.


Problem 4: Permission Denied on Data Files

The site uses file-based storage β€” JSON files for projects and timeline, markdown for blog posts. These live in mounted volumes so they persist across container rebuilds.

EACCES: permission denied, open '/app/data/timeline.json'

The container was running as a non-root user (nextjs, uid 1001) while the host files were owned by root. The non-root user couldn't write to them.

Quick fix on the server: chmod -R 777 /opt/toxik/data.

Permanent fix in the Dockerfile: remove the USER nextjs line entirely. For a personal self-hosted site, running as root in the container is acceptable β€” the container is already isolated.


Problem 5: Code Changes Not Showing After Redeploy

This one cost the most time.

I'd push new code to GitHub, trigger a Portainer stack update β€” and see the exact same old UI. Every time. I tried bumping versions, clearing browser cache, deleting and recreating the stack. Still old code.

The root cause: docker compose up does not rebuild Docker images by default.

When you run docker compose up, Docker checks if an image for the service already exists locally. If it does, it uses it β€” without rebuilding. Your new source code sits in the git clone, completely ignored.

The fix is one line in docker-compose.yml:

pull_policy: build

This tells Docker Compose to always run docker build before starting the service, regardless of whether an image already exists. Every Portainer update now actually rebuilds the image from the latest code.

I also added a CACHE_BUST build argument for extra insurance:

build:
  args:
    CACHE_BUST: "20260225-1"
ARG CACHE_BUST=0
COPY . .

Changing the CACHE_BUST value invalidates Docker's layer cache from that point forward β€” forcing a full rebuild of the app even if package.json hasn't changed. Bump it whenever you need a clean slate.


What Actually Works Now

After all of that:

  • node:20-slim β€” no more native binary issues
  • .dockerignore β€” clean Linux build, no Windows contamination
  • environment: ${VAR} β€” Portainer injects secrets correctly
  • No USER nextjs β€” no permission errors on volume mounts
  • pull_policy: build β€” every deploy actually deploys new code
  • CACHE_BUST arg β€” force full rebuild when needed

The deploy flow is now: push to GitHub β†’ Portainer detects the change β†’ rebuilds the image β†’ restarts the container. It works reliably.


Was it worth it? Absolutely. I learned more about Docker in one weekend than in the previous year. And the site runs exactly the way I want it to.

Next time I'll write about Aitrin β€” a much bigger rabbit hole.

β€” Peter