
Run Next.js in Docker on a cPanel server and point your domain at it with a .htaccess reverse proxy. Zero-downtime rollouts and GitHub Actions auto-deploy, using SSH only.
I like SSH and I like Docker. My client liked cPanel.
That is the whole story behind this post. I wasn't going to give up docker rollout and a one-command deploy because a control panel was part of the deal, and it turns out you don't have to. cPanel keeps doing what the client wants — domains, DNS, email, SSL certificates that renew themselves — and Docker keeps doing what I want, which is everything else.
If you're in the same spot, this guide gets you:
.htaccess fileThe only real prerequisite is SSH.
The trick is to stop thinking of cPanel as your app server. It's your edge. It owns the domain and the TLS certificate, and it hands every request to Docker.
Browser
│ https://example.com
▼
cPanel Apache / LiteSpeed ← domain, SSL, .htaccess
│ [P] proxy to 127.0.0.1:3000
▼
nginx container (host-bound, loopback only)
│ round-robins across replicas
▼
Next.js containers ← docker rollout swaps theseThat middle layer is what makes zero downtime possible, and it's the part most cPanel guides skip. .htaccess can only proxy to one fixed port. It can't health-check anything, and it can't load-balance. So if Apache points straight at your Next.js container, every deploy is a hole in your uptime.
Give port 3000 to a tiny nginx container instead. It sits there permanently, so .htaccess never has to change again, and behind it you can swap app containers all day. It's the same nginx trick from my Docker rollout post, just with Apache in front of it.
cPanel does have "Setup Node.js App", which runs your app under Passenger. It's fine for something small. I skipped it because it pins you to whichever Node versions your host compiled, gives you no control over the build step, makes standalone output awkward, and has no answer at all for zero-downtime deploys. If you're already comfortable with Docker, you'd be trading every tool you know for a form in a control panel.
.htaccess trick changes that. If ssh only gets you a jailed user shell, this guide isn't for your plan.docker rollout installed (Step 6 here)Check you're in business:
docker run --rm hello-world
apachectl -M 2>/dev/null | grep -E 'proxy_module|proxy_http_module|rewrite_module|headers_module'That second command should list all four modules. If any are missing, add them in WHM → EasyApache 4 → Modules and rebuild. On a LiteSpeed server apachectl -M won't exist — that's fine, LiteSpeed 6.0+ handles the proxy rules natively.
This goes in the document root of your domain — usually ~/public_html, or ~/public_html/subdomain for an addon domain.
RewriteEngine On
# cPanel wants to serve index.php. There isn't one — everything is Next.js.
DirectoryIndex disabled
RewriteRule ^index\.php.*$ - [L]
# SSL terminates up here at cPanel, so the app only ever sees plain HTTP.
# mod_proxy sets X-Forwarded-For and X-Forwarded-Host automatically.
# It does NOT set this one, and Next.js needs it to build correct URLs.
RequestHeader set X-Forwarded-Proto "https"
# Hand everything to the proxy container on loopback.
# Keep the literal 127.0.0.1 here — see the LiteSpeed gotcha at the end.
RewriteRule ^(.*)$ http://127.0.0.1:3000/$1 [P,L]Three notes on this file.
DirectoryIndex disabled is doing real work. Without it Apache finds (or looks for) index.php and resolves the request itself instead of passing it along. You get a blank page or a 403 and no useful error anywhere.
The RequestHeader line assumes you force HTTPS. You should — cPanel has a "Force HTTPS Redirect" toggle per domain. If you serve both schemes, hardcoding https makes Next.js generate https:// links on plain HTTP requests.
I dropped the CORS header. A lot of cPanel proxy snippets include Header always set Access-Control-Allow-Origin "*", which makes every response on your site readable by any other website. Browsers refuse to send cookies with a wildcard, so session auth isn't exposed, but token-authenticated API routes are looser than they need to be. If you genuinely need CORS, scope it to the routes that need it:
<LocationMatch "^/api/public">
Header always set Access-Control-Allow-Origin "https://trusted-client.com"
</LocationMatch>Read this one twice, because it is the mistake that turns a tidy setup into an open server.
When you write ports: - "3000:80", Docker binds 0.0.0.0 — every interface. Worse, it writes its own iptables rules that sit in front of firewalld and CSF, so your firewall will happily report the port as closed while Docker serves traffic on it anyway.
The result: http://your-server-ip:3000 serves your entire app with no TLS, bypassing cPanel completely. Google will find it and index it as a duplicate site.
Always include the loopback address:
ports:
- "127.0.0.1:3000:80" # correct
# - "3000:80" # exposes the app to the whole internetVerify after starting:
ss -lntp | grep 3000You want 127.0.0.1:3000. If you see 0.0.0.0:3000, fix it now.
Standard standalone build. If you already have one from my Docker rollout post, it works here unchanged.
const nextConfig = {
output: 'standalone',
}
export default nextConfigexport const dynamic = 'force-dynamic'
export async function GET() {
return Response.json({ status: 'ok' })
}# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
# .env is mounted, not copied — it never lands in an image layer.
RUN --mount=type=secret,id=env,target=/app/.env \
--mount=type=cache,target=/app/.next/cache \
npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=5 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server.js"]This is the piece that buys you zero downtime.
server {
listen 80;
server_name _;
# Docker's embedded DNS. valid=2s means nginx re-checks every 2 seconds
# and follows containers as rollout creates and destroys them.
resolver 127.0.0.11 valid=2s ipv6=off;
location / {
# The variable forces resolution at request time. A literal
# `proxy_pass http://app:3000;` resolves once at startup and then
# proxies into a container that no longer exists.
set $upstream http://app:3000;
proxy_pass $upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Apache already added one; append rather than replace.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Pass through what Apache told us, don't invent a new value.
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Next.js App Router streams RSC payloads. Buffering breaks Suspense
# boundaries — the page arrives all at once instead of progressively.
proxy_buffering off;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
}services:
app:
image: next-app:latest
restart: unless-stopped
env_file: .env
# No host port. Two app containers must be able to run at once.
expose:
- "3000"
proxy:
image: nginx:alpine
restart: unless-stopped
ports:
# Loopback only — cPanel is the only thing allowed to reach this.
- "127.0.0.1:3000:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- appNote the two different meanings of 3000: the proxy publishes host port 3000, and Next.js listens on container port 3000. Apache talks to the first, nginx talks to the second. They never collide because only the proxy touches the host.
#!/bin/bash
if [ -z "${BASH_VERSION:-}" ]; then
exec /bin/bash "$0" "$@"
fi
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGE="next-app:latest"
HEALTH_URL="http://127.0.0.1:3000/api/health"
[[ -f .env ]] || { echo "Missing .env"; exit 1; }
echo "==> Pulling code"
git pull --ff-only
echo "==> Building"
DOCKER_BUILDKIT=1 docker build --secret id=env,src=.env -t "$IMAGE" .
echo "==> Ensuring proxy is up"
docker compose up -d --remove-orphans proxy
if [ "$(docker compose ps -q app | grep -c .)" -eq 0 ]; then
echo "==> First start"
docker compose up -d app
else
echo "==> Rolling out"
docker rollout app --timeout 90 --wait-after-healthy 5
fi
echo "==> Verifying through the proxy"
for i in $(seq 1 30); do
if curl -sf --max-time 2 "$HEALTH_URL" >/dev/null; then
echo "Healthy"
break
fi
sleep 2
done
docker container prune -f >/dev/null 2>&1 || true
docker image prune -f >/dev/null 2>&1 || true
docker builder prune -f --filter until=168h >/dev/null 2>&1 || true
echo "Done"
docker compose pschmod +x deploy.sh
./deploy.shHit your domain. If you get the app over HTTPS with a valid certificate, cPanel and Docker are now cooperating.
Generate a deploy key on the server, as the user that owns the app:
ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/gh_deploy -N ""
cat ~/.ssh/gh_deploy.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
cat ~/.ssh/gh_deploy # private key — copy this into GitHubAdd these repository secrets under Settings → Secrets and variables → Actions:
| Secret | Value |
|---|---|
SSH_HOST |
Your server IP or hostname |
SSH_USER |
The cPanel user that owns the app |
SSH_PORT |
cPanel often uses 2222, not 22 |
SSH_KEY |
The full private key, including the BEGIN/END lines |
APP_PATH |
Absolute path to the project, e.g. /home/youruser/apps/web |
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
# Two deploys at once means two builds racing over the same image tag.
group: production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
port: ${{ secrets.SSH_PORT }}
key: ${{ secrets.SSH_KEY }}
# Longer than the build takes, or the action hangs up mid-deploy
# and leaves you with half a rollout.
command_timeout: 15m
script: |
set -e
cd ${{ secrets.APP_PATH }}
./deploy.shThe build happens on the server, not in the runner. That keeps .env on the box where it belongs and avoids pushing images to a registry you'd otherwise have to set up. The tradeoff is that your VPS needs enough RAM to run next build — on a 1GB box, add swap first (there's a snippet in my server maintenance post).
Push to main and watch it go.
ports: "3000:80" publishes to the entire internet, and your firewall will lie to you about it. Docker's iptables rules sit in front of firewalld and CSF. Always write 127.0.0.1:3000:80, then confirm with ss -lntp.
On LiteSpeed, use a literal 127.0.0.1 in the proxy rule. LiteSpeed auto-creates the proxy target for a loopback IP, but a hostname needs an External App registered in WebAdmin first — without it you get a bare 500 and nothing useful in the logs. Since plenty of cPanel hosts run LiteSpeed rather than Apache, the IP form is the portable one.
Missing DirectoryIndex disabled gives you a blank page, not an error. Apache resolves the request itself instead of proxying, and nothing is logged as wrong.
cPanel does not set X-Forwarded-Proto. Without it Next.js thinks it's on HTTP, and every generated URL, redirect and canonical tag comes out wrong.
proxy_buffering off matters more than it looks. With buffering on, App Router streaming stops working — Suspense boundaries resolve all at once and your loading states never appear.
cPanel's SSH port is often 2222. If GitHub Actions times out connecting, that's usually why.
AutoSSL needs to reach /.well-known/acme-challenge/. Your catch-all rule proxies that to Next.js, which 404s it, and renewal fails silently two months later. Add an exclusion above the proxy rule:
RewriteRule ^\.well-known/ - [L]Don't let cPanel's File Manager near the app directory. It's easy to "helpfully" drop an index.html in the docroot, which DirectoryIndex disabled mostly protects you from — but only mostly.
cPanel and Docker aren't actually in conflict. They just want different jobs. Let cPanel keep the domain, the DNS and the certificate — the things it genuinely is good at — and let Docker run the application.
Three pieces make it work: a .htaccess that proxies to loopback with the right forwarded headers, a proxy container that permanently owns port 3000 so .htaccess never needs to change, and app containers behind it that docker rollout can swap without a gap.
And the client still logs into cPanel and sees exactly what they expected to see. Everyone gets what they wanted.