Tools
Tools: How I Tricked AWS Elastic Beanstalk Into Using pnpm
2026-02-18
0 views
admin
The Strategy: An npm Wrapper ## Project Structure ## The Bug That Almost Broke Everything ## The Platform Hook Script ## Make It Executable ## Understanding the Wrapper: The $1 Bug ## Why intercept rebuild, update, prune, and dedupe? ## Why --prod --frozen-lockfile --ignore-scripts? ## Understanding Corepack Shims ## Deploy and Verify ## Performance Results (February 2, 2026) ## Troubleshooting ## Key Log Files ## Diagnosing the npm Wrapper State ## Fixing Broken State Manually ## Running the App Manually via SSH ## Common Errors & Solutions ## Should AWS Just Support pnpm Natively? 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. Here's what happened when we pushed our first pnpm-based deployment: 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: 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. Instead of fighting Beanstalk's lifecycle, we work with it. The approach: Beanstalk thinks it's running npm. It's actually running pnpm. It never knows the difference. Here's the architecture: Elastic Beanstalk uses a .platform directory for lifecycle hooks. Here's what we need: Why hooks/prebuild?
Beanstalk runs hooks in this order: prebuild → npm install → predeploy → postdeploy. We need pnpm ready before Beanstalk attempts to install dependencies. Our first version of the script had a critical chicken-and-egg bug: 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: Create .platform/hooks/prebuild/01_install_pnpm.sh: 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. This is the step everyone forgets. The hook script must be executable, or Beanstalk silently ignores it: Our original wrapper only checked $1: This broke because Beanstalk runs: 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: 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. 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: 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. Push your code and watch the Beanstalk logs. Here's what a successful deployment looks like (from our actual eb-hooks.log): These are actual numbers from our production Elastic Beanstalk environment after fixing the npm wrapper bug: If npm is broken on the instance (wrapper exists but pnpm doesn't): SSH sessions don't have Elastic Beanstalk environment variables. Load them first: 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). Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
[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. Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
[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. CODE_BLOCK:
[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. CODE_BLOCK:
.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 Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
.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 CODE_BLOCK:
.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 CODE_BLOCK:
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 Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
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 CODE_BLOCK:
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 CODE_BLOCK:
your-project/
├── .platform/
│ └── hooks/
│ └── prebuild/
│ └── 01_install_pnpm.sh ← The core script
├── Procfile ← web: pnpm start
├── package.json
├── pnpm-lock.yaml ← Your pnpm lockfile
└── ... Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
your-project/
├── .platform/
│ └── hooks/
│ └── prebuild/
│ └── 01_install_pnpm.sh ← The core script
├── Procfile ← web: pnpm start
├── package.json
├── pnpm-lock.yaml ← Your pnpm lockfile
└── ... CODE_BLOCK:
your-project/
├── .platform/
│ └── hooks/
│ └── prebuild/
│ └── 01_install_pnpm.sh ← The core script
├── Procfile ← web: pnpm start
├── package.json
├── pnpm-lock.yaml ← Your pnpm lockfile
└── ... CODE_BLOCK:
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 Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
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 CODE_BLOCK:
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 COMMAND_BLOCK:
#!/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 ===" Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
#!/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 ===" COMMAND_BLOCK:
#!/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 ===" COMMAND_BLOCK:
# 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 Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
# 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 Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
npm --omit=dev install Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
npm --omit=dev install COMMAND_BLOCK:
npm --omit=dev install COMMAND_BLOCK:
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 Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
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 COMMAND_BLOCK:
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 CODE_BLOCK:
! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-10.28.2.tgz
? Do you want to continue? [Y/n] Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-10.28.2.tgz
? Do you want to continue? [Y/n] CODE_BLOCK:
! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-10.28.2.tgz
? Do you want to continue? [Y/n] CODE_BLOCK:
=== 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 === Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
=== 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 === CODE_BLOCK:
=== 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 === COMMAND_BLOCK:
# 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 Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
# 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 Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
# 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 Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
sudo su -
source /opt/elasticbeanstalk/deployment/env
cd /var/app/current
pnpm start Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
sudo su -
source /opt/elasticbeanstalk/deployment/env
cd /var/app/current
pnpm start COMMAND_BLOCK:
sudo su -
source /opt/elasticbeanstalk/deployment/env
cd /var/app/current
pnpm start - Install pnpm on the instance (using Corepack or npm, depending on the platform)
- Replace the npm binary with a wrapper script that intercepts install and ci commands and silently redirects them to pnpm
- All other npm commands (--version, run, etc.) still use the original npm - Always restore the original npm first (safety net)
- Verify pnpm is installed before creating the wrapper
- Only then replace npm with the wrapper - AWS Elastic Beanstalk hardcodes npm install — you can't change it
- Amazon Linux 2023 doesn't include corepack — you must bootstrap it with npm 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 runs npm --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
how-totutorialguidedev.toaimllinuxbashnodegit