Suggested code fixes for Ghost-CLI
Two independent problems surfaced from a single incident (nginx failing to start when ap.ghost.org was briefly unresolvable during an unattended upgrade). Both are small, localized changes in TryGhost/Ghost-CLI.
Issue 1 — ghost doctor reports a running problem as a missing package
File: lib/commands/doctor/checks/system-stack.js
The system-compatibility check decides nginx is present only if its service is running:
js
async function hasService(name) {
try {
const services = await sysinfo.services(name);
return services.some(s => s.name === name && s.running); // running, not installed
} catch (error) {
return false;
}
}
…but a false result is collected into an array named missing and surfaced as:
js
throw new Error(`Missing package(s): ${missing.join(', ')}`);
So an installed-but-stopped nginx (e.g. a failed start after a config/DNS error) is reported identically to an uninstalled nginx. Users are pointed toward reinstalling — which risks clobbering the generated vhost — when the real fix is systemctl start nginx plus a look at the logs.
Fix: distinguish “not installed” from “installed but not running”
execa is already a dependency and is used by sibling checks (pnpm.js, python-setuptools.js), so add a presence check and split the reporting.
diff
const chalk = require('chalk');
const sysinfo = require('systeminformation');
+const execa = require('execa');
const {SystemError} = require('../../../errors');
diff
async function hasService(name) {
try {
const services = await sysinfo.services(name);
return services.some(s => s.name === name && s.running);
} catch (error) {
return false;
}
}
+
+// Whether the program is installed at all (resolvable on PATH).
+// dpkg-query could be used instead for stricter "is the package installed" semantics.
+async function isInstalled(name) {
+ try {
+ await execa('which', [name], {timeout: 5000});
+ return true;
+ } catch (error) {
+ return false;
+ }
+}
diff
const missing = [];
+ const notRunning = [];
if (!(await hasService('systemd'))) {
missing.push('systemd');
}
- if (!(await hasService(nginxProgramName))) {
- missing.push('nginx');
- }
+ if (!(await isInstalled(nginxProgramName))) {
+ missing.push(nginxProgramName);
+ } else if (!(await hasService(nginxProgramName))) {
+ notRunning.push(nginxProgramName);
+ }
if (missing.length) {
throw new Error(`Missing package(s): ${missing.join(', ')}`);
}
+
+ if (notRunning.length) {
+ throw new Error(
+ `Installed but not running: ${notRunning.join(', ')}. ` +
+ `The package is present, so this is usually a failed start rather than a missing dependency. ` +
+ `Check \`systemctl status ${notRunning.join(' ')}\` and ` +
+ `\`journalctl -u ${notRunning.join(' ')} -n 50 --no-pager\` for the cause ` +
+ `(for example a DNS / upstream-resolution error), then start it with ` +
+ `\`systemctl start ${notRunning.join(' ')}\`.`
+ );
+ }
This alone would have told the exact story of this incident instead of sending the user toward a reinstall.
Optional enhancement: surface the actual failure line
When a service is installed but down, a best-effort read of its journal can name the real cause directly (here it would print the host not found in upstream "ap.ghost.org" line). It needs journal-read privilege, so it degrades gracefully to the generic hint above.
js
async function lastServiceError(name) {
try {
const {stdout} = await execa('journalctl', ['-u', name, '-n', '20', '--no-pager'], {timeout: 5000});
return stdout.split('\n').reverse().find(l => /\[emerg\]|\[error\]|failed/i.test(l)) || null;
} catch (error) {
return null; // journalctl unavailable or insufficient privilege
}
}
Append the returned line to the notRunning error message when present.
Tests: the existing system-stack specs should gain a case for the installed-but-stopped branch (isInstalled true, hasService false → “Installed but not running”, not “Missing package(s)”).
Issue 2 — one unresolvable upstream takes down the entire site
Files: extensions/nginx/templates/nginx-ssl.conf and extensions/nginx/templates/nginx.conf
Both templates proxy the ActivityPub paths to a literal upstream hostname:
nginx
location ~ /.ghost/activitypub/* {
...
proxy_ssl_server_name on;
proxy_pass https://ap.ghost.org;
}
nginx resolves a literal proxy_pass host once, at startup, and aborts the whole server if that lookup fails ([emerg] host not found in upstream). The result is that a momentary inability to resolve ap.ghost.org — which only the Fediverse paths depend on — prevents nginx from starting at all and takes the entire site offline. This is easy to trigger, because any security-upgrade cycle that touches nginx or a library it links (e.g. openssl/libssl) restarts the service unattended.
Fix: resolve the upstream at request time
Using a variable in proxy_pass (plus a resolver) defers the lookup to request time. A DNS failure then only 502s the ActivityPub/well-known paths; nginx starts and serves everything else normally — fail-soft instead of fail-hard.
diff
location ~ /.ghost/activitypub/* {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
add_header X-Content-Type-Options $header_content_type_options;
proxy_ssl_server_name on;
- proxy_pass https://ap.ghost.org;
+ proxy_ssl_name ap.ghost.org;
+ resolver 127.0.0.53 valid=30s;
+ set $ap_upstream ap.ghost.org;
+ proxy_pass https://$ap_upstream;
}
Apply the identical change to all four affected blocks: the activitypub and .well-known/(webfinger|nodeinfo) locations in both nginx-ssl.conf and nginx.conf.
What each added line does:
set $ap_upstream ... + proxy_pass https://$ap_upstream; — the variable is what moves resolution from startup to per-request, so a DNS blip can no longer block startup. (No trailing slash, to preserve the current request-URI passthrough.)
resolver 127.0.0.53 valid=30s; — required once proxy_pass uses a variable. 127.0.0.53 is the systemd-resolved stub, the default on the Ubuntu releases Ghost-CLI already requires; valid=30s caches lookups so this isn’t a per-request cost.
proxy_ssl_name ap.ghost.org; — pins SNI / cert-verification to the literal name now that the proxy host is a variable.
Why not upstream { server ap.ghost.org resolve; }
The resolve parameter on an upstream server entry is the other standard approach, but it only landed in open-source nginx in 1.27.3; Ubuntu 24.04 LTS ships nginx 1.24, so it isn’t available on the target platform. The variable + resolver form works on stock Ubuntu nginx.
Caveats for review
resolver 127.0.0.53 assumes systemd-resolved is active (Ubuntu default). If you want to support boxes that disabled it, the resolver address could be templated/configurable, optionally with a public fallback.
- Behavior change is intentional and contained: ActivityPub requests now return 502 during a resolution outage rather than the whole vhost failing to load.
Defense-in-depth (optional, not a substitute)
A Restart=on-failure / RestartSec=15 systemd drop-in for the managed nginx unit would let a transient-failure start recover on its own. It’s a coarser net than the template fix — it shortens but doesn’t eliminate downtime during a blip, since “nginx won’t start” remains all-or-nothing — so it’s worth considering only alongside Issue 2’s fix, not instead of it.
Summary
| Problem |
File |
Change |
| “Missing package(s): nginx” when nginx is installed but stopped |
lib/commands/doctor/checks/system-stack.js |
Split install check from running check; add an “installed but not running” error (optionally surfacing the journal line) |
Whole-site outage when ap.ghost.org can’t be resolved at startup |
extensions/nginx/templates/nginx-ssl.conf, extensions/nginx/templates/nginx.conf |
Resolve the ActivityPub upstream at request time (variable + resolver) so the failure is contained to the Fediverse paths |