Missing packages: (nginx) when running ghost doctor

All,

I received the following error when trying to run ghost doctor after discovering that my ghost blog was unresponsive:

One or more errors occurred. 1) Checking system compatibility Message: System stack checks failed with message: ‘Missing package(s): nginx’

  • Debug Information: OS: Ubuntu, v24.04.4
  • LTS Node Version: v22.22.3
  • Ghost Version: 6.44.1
  • Ghost-CLI Version: 1.29.3
  • Environment: production Command: ‘ghost doctor’

Claude Opus 4.8 to the rescue! Claude helped me identify the issue and it was some kind of momentary DNS failure after an upgrade or update. Restarting the nginx service fixed the issue and I’ll put the fix and code Claude recommended below in case you want to make use of it for your servers.

sudo systemctl start nginx
sudo systemctl status nginx

That brought my server back up and Claude recommended the following changes to ensure it wouldn’t happen again:

sudo systemctl edit nginx

In the editor, put the following near the top of the file:

[Service]
Restart=on-failure
RestartSec=15

This was the entry from journalctl -u nginx [edited] that indicated that it was a DNS failure that caused the initial issue:

[emerg] host not found in upstream "ap.ghost.org" in /etc/nginx/sites-enabled/nolongersmartest.org-ssl.conf:24

If you have the above error when you run ghost doctor, before you panic and consider backing up your server and attempting to reinstall or mess with nginx, try restarting the nginx first. If that fixes it, consider making the change to the nginx systemctl config to stop it from happening in the future.

A little feedback to the Ghost team:

  1. That error message from ghost doctor sounds pretty scary and catastrophic compared to what’s going on. Is it possible to have the ghost doctor dig a little to see if a simple restart may do the trick? I’d be happy to contribute if someone could point me in the right direction.
  2. Any potential down side to making the systemctl nginx config changes recommended by Claude? If not, would it make sense to incorporate this? If so, any other fixes you recommend?

I hope this helps people going forward.

Kind regards,

Ted

I asked Claude for specific recommendations for updating Ghost instead of applying the band-aid. Here’s Claude’s suggestion:

nginx fails to start on an unresolvable ap.ghost.org upstream → ghost doctor reports it as “Missing package(s): nginx”

Environment

  • Ghost 6.44.1 · Ghost-CLI 1.29.3 · Node v22.22.3 · MySQL
  • Ubuntu 24.04.4 LTS · self-hosted, production · installed via Ghost-CLI

Summary

After an unattended nginx security upgrade restarted the service, nginx failed to start because it couldn’t resolve the ap.ghost.org ActivityPub upstream at that moment. Since nginx resolves literal proxy_pass hostnames once at startup and aborts if the lookup fails, the entire site went down — not just the Fediverse path. ghost doctor then reported this as Missing package(s): nginx, even though the nginx package was installed the whole time. The message points toward reinstalling nginx, when the actual fix was a single systemctl start nginx once DNS recovered.

Reproduction

  1. Standard Ghost-CLI production install. Ghost-CLI writes an ap.ghost.org upstream into <site>-ssl.conf (added in Ghost-CLI #1963).
  2. Restart nginx while ap.ghost.org is momentarily unresolvable — e.g. the auto-triggered restart of an unattended-upgrades nginx update landing on a brief DNS gap (more likely on home/rural connections).
  3. nginx aborts startup; the site is fully down.
  4. ghost doctorChecking system compatibility … Missing package(s): nginx, despite dpkg -l nginx showing ii.

Evidence

Package is present and the binary works:

$ dpkg -l nginx | grep nginx
ii  nginx  1.24.0-2ubuntu7.11  amd64  ...
$ nginx -v
nginx version: nginx/1.24.0 (Ubuntu)

Service is failed — note the config test (ExecStartPrenginx -t) passes; only the real start fails:

ExecStartPre=/usr/sbin/nginx -t ...   (code=exited, status=0/SUCCESS)
ExecStart=/usr/sbin/nginx ...         (code=exited, status=1/FAILURE)
Active: failed (Result: exit-code)

The actual cause, from journalctl -u nginx:

[emerg] host not found in upstream "ap.ghost.org" in /etc/nginx/sites-enabled/<site>-ssl.conf:24

The split is the tell: nginx -t validates config and loads certs but does not resolve proxy_pass upstreams, so only the real start hits the DNS failure.

Root cause

  1. Config — a literal upstream hostname makes a momentary DNS failure fatal to the whole web server, and ties a self-hosted install’s availability to a Ghost-operated hostname being resolvable at the exact moment nginx restarts.
  2. Diagnosticsghost doctor collapses “package not installed”, “installed but not running”, and “installed but failed to start” into one misleading Missing package(s) message, and hides the journal line that actually explains the failure.

Recommendations (prioritized)

Ghost-CLI config generation

  1. Generate the ActivityPub upstream for request-time resolution — a resolver directive plus a variable in proxy_pass (set $ap ``ap.ghost.org``; proxy_pass ``https://$ap``;) instead of the literal hostname. Highest-impact change: a DNS blip then degrades only the ActivityPub path (502 on those requests) instead of preventing nginx from starting. Converts a site outage into a contained feature degradation.
  2. Gate the upstream on the ActivityPub feature being enabled (or otherwise fail soft), so installs not using the Fediverse don’t carry a hard startup dependency for zero benefit.

ghost doctor

  1. Separate the checks — package presence (dpkg/binary) vs service active (systemctl is-active) vs failed-to-start — and report the true state instead of Missing package(s).
  2. Surface the underlying error — echo the last journalctl -u nginx line so the real cause (host not found in upstream …) is visible immediately.
  3. Add a targeted hint for the known host not found in upstream "ap.ghost.org" signature: suggest checking DNS / restarting nginx before any reinstall.
  4. Re-word the message/severity so “installed but failed to start” isn’t presented as a missing package — that framing is what pushes users toward reinstalling nginx (which risks clobbering the generated vhost config).

Underlying principle

Self-hosted installs shouldn’t fail hard on resolution of a Ghost-operated hostname. External dependencies added to generated configs should fail soft — degrade the dependent feature rather than block the whole server.

Local workaround for others hitting this

First, just restart — the site is usually one command from recovery:

sudo systemctl start nginx

To auto-recover from future transient blips, add a systemd drop-in (sudo systemctl edit nginx):

[Service]
Restart=on-failure
RestartSec=15

Caveats worth knowing: this is a coarser mitigation than the config fix above — it shortens but doesn’t eliminate downtime during a blip (nginx-won’t-start is all-or-nothing), and it can make a genuine persistent failure quieter by looping instead of resting in failed, so monitor the live site rather than just systemctl is-active. The 15-second spacing is deliberate: it sits above systemd’s default limit of 5 starts per 10 seconds, so the retry loop won’t trip the rate limiter and park in a failed state.


Happy to open a PR for the request-time-resolution config change and/or the ghost doctor reporting split if a maintainer can point me at the right spots.

I read this a couple times and cant figure out what the actual issue was.
What was in the ghost server logs?

Hi disgustipated,

Sorry it’s unclear — here’s the sequence as best I can tell. My Ghost blog stopped responding, so I SSH’d in and ran ghost doctor, which reported Missing package(s): nginx. But nginx was installed the whole time. What actually happened: It appears that an unattended system update upgraded nginx and restarted the service, and that restart happened to coincide with a momentary DNS hiccup. nginx couldn’t resolve the ap.ghost.org upstream in its config, so it aborted startup entirely — taking the whole site down. Once resolution recovered, a plain systemctl start nginx brought everything back.

So two issues:

  1. ghost doctor reports the nginx package as missing when nginx is actually installed but failed to start (here, a temporary name-resolution failure for an upstream). The message points users at the wrong fix.
  2. A momentary failure to resolve a single proxied upstream (ap.ghost.org) stops nginx from starting at all, so the entire site goes down rather than just the ActivityPub feature degrading. The unattended update isn’t the root cause — it’s just what triggered the restart that exposed this — but it does mean the outage can strike unattended, with no one around to restart.

Here’s the evidence, with timestamps that tie the whole chain together.

Triggerunattended-upgrades upgraded nginx (alongside openssl and others) and restarted the service, at 06:34:36 on 2026-06-10:

2026-06-10 06:34:36,287 INFO Packages that will be upgraded: libssl3t64 nginx nginx-common openssl vim vim-common vim-runtime vim-tiny xxd

Failure — 16 seconds later, the restart aborted because nginx couldn’t resolve the ap.ghost.org upstream, taking the whole site down:

Jun 10 06:34:52 … systemd[1]: nginx.service: Deactivated successfully.
Jun 10 06:34:52 … systemd[1]: Stopped nginx.service …
Jun 10 06:34:52 … nginx[83309]: 2026/06/10 06:34:52 [emerg] 83309#83309: host not found in upstream "ap.ghost.org" in /etc/nginx/sites-enabled/nolongersmartest.org-ssl.conf:24
Jun 10 06:34:52 … systemd[1]: nginx.service: Control process exited, code=exited, status=1/FAILURE
Jun 10 06:34:52 … systemd[1]: nginx.service: Failed with result 'exit-code'.
Jun 10 06:34:52 … systemd[1]: Failed to start nginx.service …

Full chain: unattended security upgrade → automatic nginx service restart → transient failure to resolve ap.ghost.org → nginx aborts startup → entire site down, until a manual sudo systemctl start nginx once resolution recovered. Throughout, ghost doctor reported Missing package(s): nginx even though the package was installed (ii) the whole time.

Evidence nginx was installed:

dpkg -l '*nginx*'
...ii nginx 1.24.0-2ubuntu7.11 amd64 small, powerful, scalable web/proxy server

Sorry, no time stamp there, but I ran it when troubleshooting and nginx has not been removed since server creation.

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

Can confirm that this is a problem. I don’t have time to read a big wall of what appears to be AI-generated text, but I’ll link the issue here:

Hi Cathy_Sarisky,

Yes. AI helped me troubleshoot an issue where my site unnecessarily goes down after unattended security updates, and ghost doctor mistakenly reports that nginx isn’t installed instead of just recommending a service restart. Ideally, the server wouldn’t fail to start because of a transient DNS issue caused by application of an update, and even if it did, it would be great if ghost doctor at a minimum, accurately reported that the nginx service is down and recommending trying a restart instead of saying nginx is not installed.

I’m trying to help others. I know I’ve experienced this before, but I never dug in to see what happened and how it could be avoided. The server always came back up after a reboot.

Thanks for having a look.