Deploying Next.js standalone output over SSH
Next.js has a production mode that fits SSH deployments well: output: 'standalone'. The build produces a compact server bundle at .next/standalone/server.js, which can be started with Node or PM2 without shipping the full development tree.
There is one detail that matters: standalone output does not automatically include public or .next/static. Catapult's nextjs recipe builds on the server, then links both directories into the standalone output so the deployed server can serve static assets correctly.
The target layout
After a successful deploy, the active release should look conceptually like this:
/home/deploy/myapp/current
├── .env -> /home/deploy/myapp/shared/.env
├── .next/
│ ├── standalone/
│ │ ├── server.js
│ │ ├── public -> /home/deploy/myapp/current/public
│ │ └── .next/static -> /home/deploy/myapp/current/.next/static
│ └── static/
├── ecosystem.config.cjs
└── public/current is a symlink managed by Catapult. PM2 points at current, while Catapult creates immutable releases under releases/<timestamp>.
Next.js config
Enable standalone output in next.config.ts:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
}
export default nextConfigThe upstream behavior is documented in the Next.js output documentation.
Catapult config
Install Catapult:
npm install --save-dev @catapultjs/deployThen create deploy.ts:
import { defineConfig } from '@catapultjs/deploy'
import '@catapultjs/deploy/recipes/git'
import '@catapultjs/deploy/recipes/nextjs'
import '@catapultjs/deploy/recipes/pm2'
export default defineConfig({
hosts: [
{
name: 'production',
ssh: 'deploy@your-server.com',
deployPath: '/home/deploy/myapp',
},
],
})This gives you the deployment chain:
git checkout release -> npm install -> next build -> standalone symlinks -> publish current -> PM2 reloadFor a monorepo, configure the app path before importing the recipe:
import { defineConfig, set } from '@catapultjs/deploy'
set('nextjs_path', 'apps/web')
import '@catapultjs/deploy/recipes/git'
import '@catapultjs/deploy/recipes/nextjs'
import '@catapultjs/deploy/recipes/pm2'
export default defineConfig({ ... })Environment file
Run setup once:
npx cata deploy:setupThen upload the production .env to the shared directory:
scp .env deploy@your-server.com:/home/deploy/myapp/shared/.envThe nextjs recipe declares .env as a shared file. Each new release receives a symlink to the same server-side environment file.
PM2 entrypoint
Commit an ecosystem.config.cjs to your project:
const path = require('path')
const deployPath = '/home/deploy/myapp'
module.exports = {
apps: [
{
name: 'next',
cwd: path.join(deployPath, 'current'),
script: '.next/standalone/server.js',
instances: 1,
exec_mode: 'cluster',
},
],
}The cwd is the important part. The standalone server should run from the active release so relative paths resolve to that release's .next and public directories.
Deploy and inspect
Deploy:
npx cata deployInspect PM2:
npx cata task pm2:list
npx cata task pm2:logsCheck the app locally from the server if needed:
curl -I http://127.0.0.1:3000If that works but the site is not reachable from the internet, the issue is usually the reverse proxy or firewall. Point Nginx, Caddy, Traefik or your load balancer to 127.0.0.1:3000.
Rollback behavior
Because every deploy creates a new release, rollback is just a symlink switch:
npx cata rollbackIf PM2 is part of the pipeline, Catapult reloads it after the rollback target becomes current again.
Static export?
This article covers a server-rendered standalone Next.js deployment. If your project uses output: 'export', use recipes/nextjs_static instead. That recipe builds locally and deploys ./out/. as static files.