| |

How to Install Ghost CMS on Debian 13 with Incus and Nginx Reverse Proxy

Ghost is a powerful, open source publishing platform built on Node.js. It’s fast, clean and purpose-built for content creators who want a modern alternative to WordPress. This guide walks through a production installation of Ghost 6.x on Debian 13 (Trixie) inside an Incus container, with Nginx handling SSL termination on a separate proxy server.

This setup reflects a real production deployment, including several non-obvious issues you won’t find in the official docs.

Prerequisites

Before you start, make sure you have:

  • A host server running Incus on Debian 13
  • An Nginx reverse proxy container or server with SSL termination (using acme.sh or similar)
  • A domain name with DNS pointing to your proxy server’s public IP
  • A valid TLS certificate for your domain
  • An SMTP relay or mail server for Ghost to send transactional emails (staff invitations, password resets, device verification)

Architecture overview

The setup separates concerns across two containers on the same Incus host:

  • web-server (e.g. 10.0.10.10): Nginx reverse proxy handling TLS termination and forwarding requests
  • ghost01 (e.g. 10.0.10.20): Ghost CMS with Node.js and MariaDB

All traffic arrives at the proxy over HTTPS, gets decrypted and is forwarded to Ghost over plain HTTP on the internal bridge network. Ghost itself never handles TLS.

Step 1: Create the Incus container

Launch a fresh Debian 13 container and assign it a fixed IP on your bridge:

incus launch images:debian/13 ghost01 -d eth0,ipv4.address=10.0.10.20

Optionally set a memory limit. Ghost with Node.js and MariaDB runs comfortably in 1 GB:

incus config set ghost01 limits.memory 1GiB

Enter the container:

incus shell ghost01

Step 2: Install dependencies

Update the system and install the required packages:

apt update && apt upgrade -y
apt install -y curl gnupg2 mariadb-server

We use MariaDB instead of MySQL because it’s the default in Debian 13 and is fully compatible with Ghost. Note that we deliberately do not install Nginx inside the container; the outer proxy handles all HTTP traffic.

Install Node.js 22 LTS

Ghost requires Node.js 22 LTS. Install it via the NodeSource repository:

curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs
node -v

Install Ghost CLI

npm install -g ghost-cli

You may see deprecation warnings about glob and a notice about a newer npm version. Both are harmless and can be ignored.

Step 3: Set up MariaDB

Secure the installation:

mariadb-secure-installation

Then create a database and user for Ghost. Generate a random password and use it inline to avoid the interactive MariaDB shell not expanding variables:

GHOST_DB_PASS=$(openssl rand -base64 24)
echo "Save this password: $GHOST_DB_PASS"

mariadb -u root <<EOF
CREATE DATABASE ghost_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER 'ghostuser'@'localhost' IDENTIFIED BY '${GHOST_DB_PASS}';
GRANT ALL PRIVILEGES ON ghost_prod.* TO 'ghostuser'@'localhost';
FLUSH PRIVILEGES;
EOF

Store the password in your password manager immediately.

Important: IPv6 loopback issue

Node.js 22 prefers IPv6 when resolving localhost. If MariaDB only listens on 127.0.0.1, Ghost may fail to connect with ECONNREFUSED ::1:3306. The cleanest fix is to use 127.0.0.1 explicitly as the database host during Ghost setup (instead of localhost). This keeps MariaDB locked to the loopback interface.

Step 4: Create the Ghost user

Ghost CLI refuses to run as root, so create a dedicated user:

adduser --disabled-password --gecos "" ghostadmin

Create the web directory and set ownership:

mkdir -p /var/www/ghost
chown ghostadmin:ghostadmin /var/www/ghost

Step 5: Install Ghost

Switch to the ghostadmin user and start the installation:

su - ghostadmin
cd /var/www/ghost
ghost install

The installer will prompt you for several settings:

PromptValue
Blog URLhttps://yourdomain.com (your public URL, with https)
MySQL hostname127.0.0.1 (not localhost, to avoid the IPv6 issue)
MySQL usernameghostuser
MySQL password(paste from your password manager)
Ghost database nameghost_prod
Set up Nginx?No (the outer proxy handles this)
Set up SSL?No (the outer proxy handles TLS)
Set up systemd?Yes (but it will fail, see below)

Why the blog URL must include https

Even though Ghost itself doesn’t handle TLS, it needs to know the public-facing protocol. Ghost uses this URL for generating all links, canonical URLs, sitemaps, admin panel URLs, and email links. The Nginx proxy passes an X-Forwarded-Proto: https header so Ghost knows the connection is secure.

The sudo problem

The Ghost CLI will attempt to create a ghost system user and a systemd service, both of which require root privileges. Since ghostadmin has no sudo access, these steps will fail. Just press Enter through the sudo password prompts; the CLI will skip those steps and report them as failed, but the Ghost installation itself completes successfully.

Step 6: Create the systemd service manually

Exit back to root. Before creating the service file, set two variables that we’ll reuse throughout the remaining steps. Replace yourdomain.com with your actual domain:

exit

DOMAIN="yourdomain.com"
SERVICE="ghost_$(echo $DOMAIN | tr '.' '-')"

This generates a service name like ghost_yourdomain-com, which avoids dots in the systemd unit name. Now create the service file:

cat > /etc/systemd/system/${SERVICE}.service <<EOF
[Unit]
Description=Ghost CMS - ${DOMAIN}
After=network.target mariadb.service

[Service]
Type=simple
WorkingDirectory=/var/www/ghost
User=ghostadmin
Environment="NODE_ENV=production"
ExecStart=/usr/bin/node current/index.js
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now ${SERVICE}

Verify it’s running:

systemctl status ${SERVICE}

You should see Ghost booted in X.XXXs in the logs.

Step 7: Bind Ghost to all interfaces

By default, Ghost listens on 127.0.0.1:2368, which means the proxy container cannot reach it. Change it to listen on all interfaces:

su - ghostadmin -c "cd /var/www/ghost && ghost config server.host 0.0.0.0"
systemctl restart ${SERVICE}
ss -tlnp | grep 2368

Confirm the output shows 0.0.0.0:2368 instead of 127.0.0.1:2368.

Step 8: The /etc/hosts fix (critical for performance)

This is the most important and least documented step. Without it, your Ghost site will have a Time to First Byte (TTFB) of 5+ seconds.

Ghost 6.x includes ActivityPub and webhook functionality that makes HTTP requests to itself using the configured public URL. On every page render, Ghost tries to reach https://yourdomain.com internally. If this request has to go out to the internet, resolve DNS, hit your proxy and come back, it adds seconds of latency to every single request.

The fix is simple. Add the domain to the container’s /etc/hosts so Ghost resolves it to localhost:

echo "127.0.0.1 ${DOMAIN}" >> /etc/hosts
systemctl restart ${SERVICE}

This brought our TTFB from 5.4 seconds down to 4 milliseconds, and our Largest Contentful Paint (LCP) from 5.2 seconds to 0.38 seconds.

Note: Incus does not override /etc/hosts on container restart, so this change is persistent.

Step 9: Configure mail (must be done before first login)

This step is critical and must be completed before you create your admin account or attempt to log in. Ghost 6.x has a staff device verification feature enabled by default (staffDeviceVerification). When you sign in from a new device, Ghost sends a verification email that you must confirm before the session is activated. If mail is not configured, the verification email never arrives, and you will be silently locked out of the admin panel with a 403 “Authorization failed” error on every request. No error message in the browser tells you what’s wrong; it simply fails.

Edit /var/www/ghost/config.production.json and add the mail block. For an SMTP relay using implicit TLS on port 465:

"mail": {
    "transport": "SMTP",
    "from": "'Your Site Name' <relay@yourdomain.com>",
    "options": {
        "host": "your-mail-server.com",
        "port": 465,
        "secure": true,
        "auth": {
            "user": "relay@yourdomain.com",
            "pass": "your-smtp-password"
        }
    }
},

A note on the secure setting: in Nodemailer (which Ghost uses internally), "secure": true means implicit TLS on port 465, where the connection is encrypted from the first byte. Setting "secure": false with port 587 uses STARTTLS, which upgrades to TLS after the initial plaintext handshake. Both are encrypted in transit. Port 465 with implicit TLS is the recommended option.

Make sure the sending address has proper SPF, DKIM, and DMARC records configured in your DNS.

Mail server on the same Incus host

If your SMTP server runs in another container on the same Incus host, the Ghost container may not be able to resolve the mail server’s public hostname to its internal bridge IP. In that case, add the mail server to /etc/hosts inside the Ghost container:

echo "10.0.10.30 your-mail-server.com" >> /etc/hosts

Replace the IP and hostname with your mail container’s bridge address and the hostname used in the Ghost mail config. Without this entry, Ghost will attempt to resolve the hostname via public DNS, which may point to the host’s external IP, causing it to fail to connect or route unnecessarily through the public interface.

After editing the config, validate the JSON and restart:

cat /var/www/ghost/config.production.json | python3 -m json.tool
systemctl restart ${SERVICE}

If python3 -m json.tool reports an error, fix the JSON before restarting. A missing comma or trailing comma is the most common mistake when editing by hand.

Step 10: Configure the Nginx reverse proxy

On your proxy server, configure the site to forward requests to the Ghost container. Replace any existing static file serving configuration with a reverse proxy.

The configuration uses three server blocks: one for HTTP to HTTPS redirection, one for the www to non-www redirect over HTTPS, and the main block that proxies to Ghost. The www redirect needs its own HTTPS server block because the browser must first complete the TLS handshake before it can see the redirect; a plain HTTP redirect would not cover visitors going directly to https://www.yourdomain.com.

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;
    include snippets/acme.conf;
    location / {
        return 301 https://yourdomain.com$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name www.yourdomain.com;
    ssl_certificate /etc/acme/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/acme/live/yourdomain.com/privkey.pem;
    include snippets/ssl.conf;
    location / {
        return 301 https://yourdomain.com$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name yourdomain.com;
    ssl_certificate /etc/acme/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/acme/live/yourdomain.com/privkey.pem;
    include snippets/ssl.conf;

    access_log /var/log/nginx/yourdomain.com-access.log;
    error_log /var/log/nginx/yourdomain.com-error.log;

    client_max_body_size 50m;

    location / {
        proxy_pass http://10.0.10.20:2368;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Test and reload:

nginx -t && systemctl reload nginx

A note on proxy caching

You may be tempted to add Nginx proxy caching (proxy_cache) in front of Ghost. While this can improve performance for high-traffic sites, it introduces a problem: Ghost has no built-in mechanism to notify the proxy when content changes. If you publish or edit a post, visitors will continue seeing the cached version until it expires. Ghost itself achieves a 4ms TTFB on a modest VPS, so proxy caching is unnecessary unless you’re handling significant traffic. If you do add it later, Ghost’s webhook feature (Settings > Integrations) can be used to trigger cache purges on publish events.

Step 11: Create your admin account

Navigate to https://yourdomain.com/ghost/ in your browser. The setup wizard will prompt you to create your admin account. Complete this immediately; the setup page is open to anyone until you do.

If mail is configured correctly, Ghost will send a device verification email on first login. Confirm it, and you’re in.

Performance tuning

Gzip compression

If not already enabled in your proxy config:

gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
gzip_min_length 1000;

Backups

For a complete backup strategy, include:

  • Content directory: /var/www/ghost/content (themes, images, settings)
  • Database: Schedule regular MariaDB dumps with mysqldump ghost_prod
  • Ghost export: The admin panel also offers a JSON export under Settings > Labs

Include both the content directory and database dump in your regular backup schedule (e.g. Kopia with off-site storage).

Updating Ghost

Updates are handled through the Ghost CLI:

su - ghostadmin
cd /var/www/ghost
ghost update

Since the ghostadmin user has no sudo access, the CLI may report warnings about systemd, but the update itself will complete. Restart the service manually afterwards if needed:

# As root
systemctl restart ghost_yourdomain-com

Summary

This guide covered a complete production Ghost setup on Debian 13 with Incus containerization and Nginx reverse proxy SSL termination. The key takeaways:

  1. Use MariaDB with 127.0.0.1 as the database host to avoid the Node.js 22 IPv6 preference issue.
  2. Do not install Nginx inside the Ghost container; the outer proxy handles all HTTP traffic.
  3. Skip the Ghost CLI’s Nginx, SSL, and systemd setup; create the systemd service manually to avoid granting sudo to a service account.
  4. Bind Ghost to 0.0.0.0 so it’s reachable from the proxy container.
  5. Add your domain to /etc/hosts pointing to 127.0.0.1 to prevent Ghost’s internal self-callbacks from creating a 5+ second latency penalty on every page load.
  6. Configure SMTP mail before creating your admin account. Ghost’s staff device verification requires working email; without it, you will be silently locked out of the admin panel.

With these steps, you get a clean, fast, maintainable Ghost installation that integrates naturally into an existing Incus and Nginx infrastructure.

Similar Posts