You've migrated your Node.js project to pnpm. Locally, everything works well — faster installs, cleaner node_modules, a strict lockfile.
Then you deploy to AWS Elastic Beanstalk.
And it fails.
Here's what happened when we pushed our first pnpm-based deployment:
[ERROR] An error occurred during execution of command [app-deploy] -
[InstallDependencies]. Stop running the command.
Error: install dependencies fails: Command /bin/sh -c npm --omit=dev install
failed with error exit status 1.
Stderr: npm warn old lockfile
npm warn old lockfile The package-lock.json file was created with an old version
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and
package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock
file with `npm install` before continuing.
The root cause: Elastic Beanstalk's Node.js platform hardcodes npm install during deployment. There's no config toggle, no environment variable, no checkbox in the console. It runs npm install. Period.
Our first fix attempt was to use corepack enable — the standard way to activate pnpm on a normal Node.js installation — inside a platform hook. That also failed:
.platform/hooks/prebuild/01_install_pnpm.sh: line 4: corepack: command not found
.platform/hooks/prebuild/01_install_pnpm.sh: line 22: pnpm: command not found
It turns out Amazon Linux 2023's Node.js package does NOT include corepack, unlike standard nodejs.org distributions. So not only does Beanstalk force npm install, you can't even install pnpm the standard way.
You can't change it. But you can trick it.
The Strategy: An npm Wrapper
Instead of fighting Beanstalk's lifecycle, we work with it. The approach:
- Install pnpm on the instance (using Corepack or npm, depending on the platform)
-
Replace the
npmbinary with a wrapper script that interceptsinstallandcicommands and silently redirects them topnpm - All other
npmcommands (--version,run, etc.) still use the original npm
Beanstalk thinks it's running npm. It's actually running pnpm. It never knows the difference.
Here's the architecture:
Beanstalk Lifecycle
│
├─ prebuild hook ──► Install pnpm via Corepack
│ Replace /usr/bin/npm with wrapper
│
├─ npm install ────► Wrapper intercepts → pnpm install
│
└─ npm start ──────► Wrapper passes through → original npm
Project Structure
Elastic Beanstalk uses a .platform directory for lifecycle hooks. Here's what we need:
your-project/
├── .platform/
│ └── hooks/
│ └── prebuild/
│ └── 01_install_pnpm.sh ← The core script
├── Procfile ← web: pnpm start
├── package.json
├── pnpm-lock.yaml ← Your pnpm lockfile
└── ...
Why
hooks/prebuild?
Beanstalk runs hooks in this order:prebuild→npm install→predeploy→postdeploy. We need pnpm ready before Beanstalk attempts to install dependencies.
The Bug That Almost Broke Everything
Our first version of the script had a critical chicken-and-egg bug:
1. Script runs: npm install -g pnpm
2. Script replaces /usr/bin/npm with wrapper that calls pnpm
3. Deployment FAILS (some other reason)
4. Next deployment tries again...
5. npm is now broken! It tries to call pnpm, but pnpm doesn't exist!
6. "pnpm: command not found" — forever
The wrapper was created before verifying pnpm was properly installed. If anything failed between installation and wrapper creation, npm would be permanently broken on the instance — it would try to call pnpm (which doesn't exist), and since npm is broken, you can't even npm install -g pnpm to fix it.
This is why the script must:
- Always restore the original npm first (safety net)
- Verify pnpm is installed before creating the wrapper
- Only then replace npm with the wrapper
The Platform Hook Script
Create .platform/hooks/prebuild/01_install_pnpm.sh:
#!/usr/bin/env bash
set -e
echo "=== Starting pnpm installation via Corepack ==="
# ─── SAFETY NET ─────────────────────────────────────
# If a previous deployment failed mid-script, the original
# npm binary might be stuck at /usr/bin/npm_original.
# Restore it before we do anything else.
if [ -f /usr/bin/npm_original ]; then
echo "Restoring original npm from backup..."
mv /usr/bin/npm_original /usr/bin/npm
fi
# ─── INSTALL COREPACK ───────────────────────────────
# Amazon Linux 2023 does NOT include corepack unlike
# standard nodejs.org Node.js distributions.
# We must bootstrap it using npm first.
echo "Installing corepack..."
npm install -g corepack
# ─── ENABLE PNPM VIA COREPACK ──────────────────────
# 'corepack enable' creates a shim (lightweight proxy) at /usr/bin/pnpm
# It doesn't download pnpm yet — just creates the pointer.
# 'corepack prepare' actually downloads the binary and caches it,
# preventing the "Do you want to download?" prompt during automated builds.
echo "Enabling corepack and installing pnpm..."
corepack enable
corepack prepare pnpm@latest --activate
# ─── VERIFY INSTALLATION ───────────────────────────
if ! command -v pnpm &> /dev/null; then
echo "ERROR: pnpm installation failed!"
exit 1
fi
echo "pnpm installed successfully: $(pnpm --version)"
# ─── THE NPM WRAPPER ───────────────────────────────
# Only AFTER pnpm is verified, it's safe to create the wrapper.
# We backup the real npm binary, then replace it with a script
# that intercepts install/ci commands and redirects to pnpm.
echo "Creating npm wrapper..."
mv /usr/bin/npm /usr/bin/npm_original
cat > /usr/bin/npm << 'EOF'
#!/bin/bash
# Loop through ALL arguments — not just $1.
# Beanstalk runs: npm --omit=dev install
# If you only check $1, you'd see --omit=dev and miss 'install' entirely.
for arg in "$@"; do
case "$arg" in
install|ci|add)
echo "[npm-wrapper] Intercepted install → using pnpm"
pnpm install --prod --frozen-lockfile --ignore-scripts
exit $?
;;
rebuild|update|prune|dedupe)
echo "[npm-wrapper] Intercepted $arg → skipping (pnpm handles this)"
exit 0
;;
esac
done
# Everything else (--version, run, start, etc) → use original npm
/usr/bin/npm_original "$@"
EOF
chmod +x /usr/bin/npm
echo "=== pnpm installation complete ==="
Important: The file MUST have Linux line endings (LF, not CRLF). If you're on Windows, configure your editor or use
git config core.autocrlf input.
Make It Executable
This is the step everyone forgets. The hook script must be executable, or Beanstalk silently ignores it:
# If you're on macOS/Linux
chmod +x .platform/hooks/prebuild/01_install_pnpm.sh
# If you're on Windows, use Git to track the permission
git update-index --chmod=+x .platform/hooks/prebuild/01_install_pnpm.sh
Understanding the Wrapper: The $1 Bug
Our original wrapper only checked $1:
# BROKEN — only checks the first argument
if [ "$1" = "install" ] || [ "$1" = "ci" ]; then
pnpm install --prod --frozen-lockfile --ignore-scripts
else
/usr/bin/npm_original "$@"
fi
This broke because Beanstalk runs:
npm --omit=dev install
Here, $1 is --omit=dev, not install. The wrapper missed it entirely and fell through to the original npm, which failed because there's no package-lock.json.
The fix was to loop through ALL arguments using for arg in "$@" and use a case statement:
for arg in "$@"; do
case "$arg" in
install|ci|add)
pnpm install --prod --frozen-lockfile --ignore-scripts
exit $?
;;
rebuild|update|prune|dedupe)
exit 0 # Skip — pnpm handles it
;;
esac
done
Why intercept rebuild, update, prune, and dedupe?
Beanstalk runs npm rebuild and sometimes npm prune after install. With pnpm, these are unnecessary — pnpm handles everything during install. We skip them with exit 0 so Beanstalk doesn't interpret it as a failure.
Why --prod --frozen-lockfile --ignore-scripts?
| Flag | Why |
|---|---|
--prod |
Only install production dependencies (skip devDependencies) |
--frozen-lockfile |
Fail if lockfile is out of sync — prevents "works on my machine" bugs |
--ignore-scripts |
Skip postinstall scripts during deploy for security and speed |
Understanding Corepack Shims
When you run corepack enable, it doesn't install the real pnpm binary. It creates a shim — a lightweight proxy script at /usr/bin/pnpm that intercepts your command.
Running corepack prepare pnpm@latest --activate actually pre-downloads the real binary into the system cache. Without this step, the shim would prompt:
! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-10.28.2.tgz
? Do you want to continue? [Y/n]
This prompt would hang an automated deployment. The --activate flag ensures it downloads silently.
Note: If you SSH into the instance as a different user (e.g., ec2-user), you may still see this prompt because the user-specific cache is empty. The web application runs fine because the deployment user (root/webapp) already had pnpm "prepared" by the script.
Deploy and Verify
Push your code and watch the Beanstalk logs. Here's what a successful deployment looks like (from our actual eb-hooks.log):
=== Starting pnpm installation via Corepack ===
Restoring original npm from backup...
Installing corepack...
changed 1 package in 1s
Enabling corepack and installing pnpm...
Preparing pnpm@latest for immediate activation...
pnpm installed successfully: 10.28.2
Creating npm wrapper...
=== pnpm installation complete ===
Performance Results (February 2, 2026)
These are actual numbers from our production Elastic Beanstalk environment after fixing the npm wrapper bug:
| Metric | Before (npm) | After (pnpm) | Improvement |
|---|---|---|---|
| Total Deploy Stage | ~6 minutes | 1 min 6 sec | 5.5x faster |
| Package Install | ~3 minutes | 13 seconds | 14x faster |
| npm rebuild | ~5 seconds | Skipped | N/A |
Troubleshooting
Key Log Files
| Log File | Purpose |
|---|---|
/var/log/eb-hooks.log |
Prebuild script output (your script logs here) |
/var/log/eb-engine.log |
Deployment steps, artifact downloads |
/var/log/web.stdout.log |
Application runtime output/errors |
# View last 100 lines of each log
sudo tail -100 /var/log/eb-hooks.log
sudo tail -100 /var/log/eb-engine.log
sudo tail -100 /var/log/web.stdout.log
Diagnosing the npm Wrapper State
# Check if npm is original or wrapper
cat /usr/bin/npm
# If it shows the wrapper script, npm has been replaced
# If it shows the original npm content (long script), it's untouched
# Check if backup exists
ls -la /usr/bin/npm_original
# Check if pnpm exists
which pnpm
pnpm --version
Fixing Broken State Manually
If npm is broken on the instance (wrapper exists but pnpm doesn't):
# Restore the original npm
sudo mv /usr/bin/npm_original /usr/bin/npm
# Then install pnpm manually
sudo npm install -g pnpm
# Verify
pnpm --version
Running the App Manually via SSH
SSH sessions don't have Elastic Beanstalk environment variables. Load them first:
sudo su -
source /opt/elasticbeanstalk/deployment/env
cd /var/app/current
pnpm start
Common Errors & Solutions
| Error | Cause | Solution |
|---|---|---|
corepack: command not found |
Amazon Linux doesn't include corepack | Use npm install -g corepack first |
pnpm: command not found (inside npm) |
npm wrapper created before pnpm installed | Restore npm_original, then reinstall |
| App crashes with missing env var | SSH session doesn't have EB env vars | Use source /opt/elasticbeanstalk/deployment/env
|
| Rollback loop | Old broken version keeps deploying | Disable auto-rollback in CodePipeline (set to "None") |
TL;DR
- AWS Elastic Beanstalk hardcodes
npm install— you can't change it - Amazon Linux 2023 doesn't include
corepack— you must bootstrap it withnpm install -g corepack - Create a
.platform/hooks/prebuild/hook that installs pnpm and replaces npm with a wrapper - The wrapper must loop through ALL arguments (not just
$1) because Beanstalk runsnpm --omit=dev install - Always restore the original npm first to avoid the chicken-and-egg bug
- Result: package install dropped from ~3 minutes to 13 seconds
Should AWS Just Support pnpm Natively?
Yes. But until that day comes, this wrapper approach is production-tested and running across multiple services without issues.
If you found this useful, feel free to share or leave a comment.
This approach has been running in production since December 2025 across multiple Node.js services on Elastic Beanstalk (Amazon Linux 2023, Node.js 22.x platform).
Top comments (2)
The
$1bug is such a classic gotcha. I hit something almost identical with a Docker entrypoint wrapper once — spent way too long wondering why flags were eating my subcommand detection.That chicken-and-egg problem with the npm backup is really well documented too. The safety net of restoring npm_original first is the kind of thing you only learn after a painful debugging session on a broken instance, haha.
One question — have you considered just containerizing the app and using ECS instead? I know it's a bigger lift, but it removes the whole "platform hardcodes npm" problem entirely. Or is EB working well enough now that it's not worth the migration?
Firstly , thank you for your appreciation and definitely agree with you on painful debugging xD. Regarding your question , yes it is too big of a lift right now to migrate to ECS since we have 5 beanstalks running our entire production architecture all connected via shared load balancer , ACM Verified etc. Migrating our entire codebase from javascript to typescript itself was a very huge migration and required god knows how many permissions and approvals from upper management. So this is something we could look in future but definitely not now. And yes EB is working very well enough right now that we could probably delay this containerization until it becomes like a priority necessity.