When I first deployed my Next.js app with a custom server.js to AWS Elastic Beanstalk (EB) running 64bit Amazon Linux 2, I hit a pretty frustrating wall. Everything seemed fine after creating the application and environment with the eb-cli
, but the EB dashboard quickly turned from Degraded to Severe.
When I connected to the instance with eb ssh
and ran npm run build
, I was greeted with this error:
> Build error occurred
Error: > Build directory is not writeable. https://err.sh/zeit/next.js/build-dir-not-writeable
It took me a while, but I eventually figured out what went wrong, and in this post, I’ll walk you through the problem, my code setup, the fix, and some extra production-ready improvements I added along the way.
My Project Setup
Here’s the starting point: a custom Next.js server using Express, and a package.json with some useful scripts.
server.js
const express = require("express");
const next = require("next");
const port = process.env.PORT || 3000;
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
async function start() {
await app.prepare();
const server = express();
// Health check for EB/ALB
server.get("/health", (_req, res) => res.status(200).send("OK"));
// Simple version endpoint
const version = process.env.APP_VERSION || "0.1.0";
server.get("/version", (_req, res) => res.json({ version }));
// Let Next handle everything else
server.all("*", (req, res) => handle(req, res));
const httpServer = server.listen(port, () => {
console.log(`> Server listening on http://localhost:${port}`);
});
// Graceful shutdown
const shutdown = () => {
console.log("Shutting down...");
httpServer.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10000);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
start().catch((err) => {
console.error("Server failed to start:", err);
process.exit(1);
});
package.json
{
"name": "webreader-client",
"version": "1.0.0",
"scripts": {
"dev": "nodemon --exec babel-node server.js",
"build": "next build",
"start": "node server.js",
"verify:write": "node scripts/preflight.js",
"deploy": "npm run verify:write && next build"
},
"dependencies": {
"express": "^4.19.2",
"next": "13.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}
scripts/preflight.js
To prevent myself from wasting time on permissions issues, I also added this script:
const fs = require("fs");
const path = require("path");
const targets = [".", ".next", "node_modules"].map((p) => path.resolve(p));
for (const target of targets) {
try {
const testPath = fs.existsSync(target) ? target : path.dirname(target);
fs.accessSync(testPath, fs.constants.W_OK);
console.log(`[OK] Writable: ${testPath}`);
} catch (e) {
console.error(`[ERR] Not writable: ${target}`);
process.exit(1);
}
}
Now I can run npm run verify:write
to confirm my directories are writable before deploying.
Why the Error Happened
Here’s what actually caused the problem:
- Next.js build process → writes output to the
.next/
directory. - Elastic Beanstalk deployment flow → copies app code to
/var/app/staging
, then symlinks it to/var/app/current
. - EB runtime user → runs as
webapp
, notroot
.
The problem? I created the .next/
folder with sudo, which made it owned by root. When next build
ran under the webapp
user, it couldn’t write to the .next
folder, so the build crashed.
The quick fix was:
sudo chown -R webapp:webapp /var/app/staging /var/app/current
But the better fix was: don’t create .next
manually with sudo. Let Next.js handle it.
Making Elastic Beanstalk Deployment Production Friendly
To avoid permission headaches, I added a postdeploy hook so everything runs as the correct user during deployment.
.platform/hooks/postdeploy/01-build.sh
#!/bin/bash
set -xe
APP_DIR="/var/app/current"
# Ensure correct ownership
chown -R webapp:webapp "$APP_DIR"
# Install and build as webapp
sudo -u webapp bash -lc "cd '$APP_DIR' && npm ci && npm run deploy"
Then I made it executable:
chmod +x .platform/hooks/postdeploy/01-build.sh
This way, the build runs cleanly under webapp
.
Managing Processes: Procfile vs PM2
I learned the hard way that running pm2 start ...
inside npm run deploy
is a bad idea — it conflicts with EB’s lifecycle. Instead, I used a Procfile:
Simple (no PM2):
web: node server.js
With PM2 Runtime (safer in production):
web: npx pm2-runtime ecosystem.config.js
ecosystem.config.js
module.exports = {
apps: [
{
name: "webreader-client",
script: "server.js",
instances: "max",
exec_mode: "cluster",
env: { NODE_ENV: "production" }
}
]
};
Extra Feature I Added
Along the way, I added some extra practical functionality:
/health
endpoint → EB/ALB health checks pass smoothly./version
endpoint → quick way to verify which build is running.- Graceful shutdown → so rolling updates don’t kill in-flight requests.
npm run verify:write
→ catches permission issues before deploy.- Postdeploy hook → ensures builds run as the right user.
Final Thought
When I first saw “Build directory is not writeable”, I thought something was broken with Next.js. In reality, it was just a simple permissions mismatch between root
and the webapp
user on Elastic Beanstalk. By fixing ownership, moving the build process into a postdeploy hook, and cleaning up how I managed processes with EB, I turned a nasty error into a solid, production-ready setup.