Skip to main content

2 posts tagged with "PM2"

View all tags

GitHub Actions deploy false failure? pm2 restart race

Β· 7 min read

Push code, the GitHub Actions deploy step turns red and exits β€” yet the service on the server is already the latest version. Another day, the reverse: everything is green locally while pnpm install --frozen-lockfile fails on CI every single time. These two opposite signal failures both live in the deploy pipeline, not in the code.

I hit this while building an e-commerce automated data collection tool for a client β€” bulk-scraping product images, SKUs, prices, and reviews, cleaned and exported as structured data for inventory management and competitor analysis. The tool's server deploys through PM2 + GitHub Actions, and both false failures happened on that pipeline.

TL;DR​

  • CI red but the deploy actually landed: two deploy channels ran pm2 restart concurrently; one looked up the process mid-restart and misreported failure. Signature: pm2.log shows a successful restart and a process already online error within the same second. Fix: collapse deploys to a single channel.
  • Local green but CI always fails: pnpm add ran in a subdirectory, updating only the child package.json while the root pnpm-lock.yaml went stale. Fix: run pnpm install at the workspace root and commit the regenerated lockfile.

Scenario 1: green locally, CI always red β€” pnpm lockfile drift​

This failure has nothing to do with code quality β€” pnpm-lock.yaml simply drifted out of sync with package.json, and CI is the only environment that checks the two strictly.

The symptom​

CI fails on every run at the same step:

ERR_PNPM_OUTDATED_LOCKFILE

Locally, install, build, and tests all pass. That "works on my machine but CI fails" combination tempts you to suspect CI caching or the Node version first β€” both wrong here.

Root cause: pnpm add ran in the wrong directory​

This project is a pnpm workspace monorepo with a single lockfile at the root. The dependency was added like this:

# executed inside client/
pnpm --filter @ccl-ext/client add <pkg>

client/package.json got updated, but the root pnpm-lock.yaml was never regenerated and committed. CI then received a lockfile inconsistent with package.json, and the --frozen-lockfile check refused to install.

Why local never catches it: node_modules already has the packages physically installed, so a local install reuses what is there and never exercises the frozen check. CI starts from a clean environment and compares the lockfile strictly, every time.

There is also a telltale side effect: running pnpm in the wrong cwd leaves a stray client/pnpm-lock.yaml behind. Spotting that file is near-proof that a command ran in the wrong place again.

The fix​

Two steps:

  1. From the workspace root, run pnpm install to regenerate the root lockfile, and commit it with the code;
  2. From now on, run pnpm add / pnpm remove at the workspace root only.

The specifiers diff in the CI failure details names exactly which package's dependency range changed β€” use it to confirm the fix targets the right spot.

A similar "wrong attribution" issue in multi-package workspaces is covered here: npm audit blames the wrong directory? Multi-package deploys audit N package trees.

Scenario 2: Actions reports failure, the deploy actually landed β€” pm2 restart race​

The deploy itself did not fail; the second, colliding restart did β€” PM2 reported that race as a deploy error.

The symptom​

After a push, GitHub Actions "Deploy Server" fails at the pm2 restart step:

[PM2][ERROR] Process 3 not found β†’ exit 1

But on the server: the code is the latest, pm2 list shows the process online, and the health check returns 200. All three deploy essentials pass β€” only Actions believes it failed.

Root cause: two channels restarting the same process​

Deploys had two trigger paths at the time:

  1. The push trigger in deploy.yml;
  2. A local /deploy script (deploy.sh).

One push made both paths run pm2 restart ccl-ext-api. When the restarts collided, one of them queried the process at the exact instant the other was replacing it, found nothing, logged Process not found, and exited 1 β€” PM2 reported the race as a failure.

Verification: the same second in pm2.log​

pm2.log is the hardest evidence. The log shows two kinds of records within the same second:

# one side: the restart completes, process online
Stopping β†’ starting β†’ online

# the other: the colliding restart finds no process
PM2 error: process already online

One restart "in flight" interleaving with another "querying" inside 1 second is the race signature. Combined with the three checks β€” code latest, process online, health 200 β€” the deploy landed and the Actions failure was noise.

The fix: drop the push trigger, single deploy channel​

The change removes the push auto-trigger from deploy.yml, keeping workflow_dispatch for manual fallback:

on:
workflow_dispatch:

Between the two options β€” a concurrency lock versus collapsing to one channel β€” I chose the latter: a lock only makes the two channels queue up, leaving two deploy paths in place. A deploy should have exactly one entry point; who deployed what, when, should originate from a single place. After the change, the false alarms never returned.

For a different take on verifying what is actually live after a deploy, see: Frontend deployed but the site did not update? Troubleshooting stale builds.

Watch out

  • The kept workflow_dispatch trigger can still race a local deploy β€” use it only when no local deploy is in progress.
  • The race window is tiny (about 1 second), but with two channels in place a higher push frequency makes collisions a matter of when, not if.
  • Order of judgment for a suspected false failure: server essentials first (code, process, health), then pm2.log for the same-second double record, and only then consider a re-run.

Side by side: both false failures share one trait β€” a polluted signal source​

Put the two scenarios together and one trait surfaces immediately: what broke was never the deploy result, but the pipeline producing the signal.

Scenario 1Scenario 2
Surface signalCI always red, local greenActions red, server updated
Actual statelockfile genuinely out of syncdeploy completed
Pollution sourcepnpm run in the wrong cwda second deploy channel
Hard evidencespecifiers diff + stray child lockfilesame-second double record in pm2.log

CI's red and green are just the pipeline's output. When the pipeline itself is polluted (two lockfiles, two channels), the signal stops being trustworthy. Fix the pipeline first; read the signal after.

FAQ​

Why does pm2 report 'Process N not found' during a GitHub Actions deploy?​

Two deploy channels ran pm2 restart on the same process at the same time. One restart hits the instant the process is being replaced, finds nothing, and exits 1 β€” while pm2.log shows a successful restart and a 'process already online' error within the same second.

GitHub Actions shows the deploy as failed β€” how do I tell if it is a false alarm?​

Check 3 things: the code on the server is the latest, pm2 list shows the process online, and the health check returns 200. If all three pass and the failure point is the pm2 restart step, it is a concurrency false alarm; collapsing deploys to a single channel removes it.

What causes a pnpm frozen-lockfile error in CI when local installs pass?​

The root cause is pnpm-lock.yaml out of sync with package.json: running pnpm add in a subdirectory updates the child package declaration but leaves the root lockfile stale. Run pnpm install once at the workspace root, commit the regenerated lockfile, and check the specifiers diff in the CI log.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Node.js ESM Dynamic Import Can't Find Module? Check the File Extension

Β· 3 min read

Encountered this issue while building a SaaS analytics platform for a client. Here's the root cause and solution.

TL;DR​

In Node.js ESM mode, import('./path/to/module') doesn't auto-resolve ./path/to/module.js. If the TypeScript build output is missing .js extensions, the module throws ERR_MODULE_NOT_FOUND. When this import is inside deferred logic (timers, conditionals), the app starts fine but crashes later β€” PM2 shows a climbing restart count.

Fix: Ensure all ESM dynamic imports include .js extensions, and automate this in the build pipeline.

Problem​

PM2 showed the app restarting continuously:

β”‚ name             β”‚ β†Ί    β”‚ status β”‚ uptime β”‚
β”‚ analytics-api β”‚ 9 β”‚ online β”‚ 28m β”‚

Error log repeated every few minutes:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/domains/video/cleanup'
imported from /app/dist/server.js

But the file clearly exists:

$ ls dist/domains/video/
cleanup.js executor.js queue.js

Root Cause​

ESM doesn't auto-resolve file extensions​

Node.js CommonJS (require()) automatically tries .js, .json, and other extensions. ESM (import) does not.

// ❌ ESM can't find the module
import('./domains/video/cleanup')
// Node.js looks for: ./domains/video/cleanup (exact path, no extension)
// Actual file: ./domains/video/cleanup.js

// βœ… Must include .js extension
import('./domains/video/cleanup.js')

Deferred imports hide the problem​

The import was inside a deferred execution:

// server.ts β€” doesn't execute immediately on startup
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler(); // triggers seconds later
});

The app starts successfully (DB connection, port binding all fine). When the timer fires, the import fails β†’ process crashes β†’ PM2 restarts β†’ starts fine again β†’ timer fires again β†’ crashes again. This creates a crash loop.

Why did it work before?​

Previous deployment used a build script that included a post-build step to fix import paths. One deployment skipped this step and deployed raw tsc output β€” tsc doesn't modify import paths in output files.

Solution​

1. Write .js extensions in TypeScript source​

TypeScript officially recommends writing .js extensions even in .ts files:

// βœ… Write .js even in .ts source files
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler();
});

2. Automated post-build fix (recommended)​

Add an import-fixing script to the build pipeline:

{
"scripts": {
"build": "tsc && node fix-imports.js"
}
}

Core logic of fix-imports.js:

import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';

function fixImports(dir) {
for (const file of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, file.name);
if (file.isDirectory()) {
fixImports(fullPath);
} else if (file.name.endsWith('.js')) {
let content = readFileSync(fullPath, 'utf8');
// Fix dynamic imports
const fixed = content.replace(
/import\(['"](\.[^'"]+)['"]\)/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
// Fix static imports
const fixed2 = fixed.replace(
/from\s+['"](\.[^'"]+)['"]/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
if (fixed2 !== content) {
writeFileSync(fullPath, fixed2);
}
}
}
}

The build pipeline automatically appends .js to all relative import paths, keeping TypeScript source extension-free while preventing module-not-found errors in ESM deployments.

Note

  • This only affects Node.js ESM mode ("type": "module" or .mjs files). CommonJS is unaffected.
  • Static imports (import ... from './foo') have the same limitation, not just dynamic import(); import hoisting also causes another common issue β€” dotenv runs after the import chain, leaving env vars undefined
  • Using tsx or ts-node in development won't show this error (they auto-resolve extensions), but node dist/server.js in production will fail.
  • PM2 crash loop signature: restart count (β†Ί) keeps growing, uptime never exceeds a few minutes.