Ghost 6 on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers deploying and using the Ghost 6 on Ubuntu 24.04 LTS image from cloudimg on Microsoft Azure. The image runs Ghost 6.73.0.
Ghost is a free and open source publishing platform written in Node.js: a fast editor, posts and pages, themes, tags, staff accounts with roles, and built-in members and newsletters. Its admin lives at /ghost/ on your site.
The cloudimg image adds:
- No open setup wizard, no shared database, no shared keys. A stock Ghost asks the first visitor to
/ghost/to create the site owner, so whoever reaches a new site first owns it. This image ships no database at all (the MySQL data directory is empty) and no secret in Ghost's configuration. Your VM's first boot initialises its own MySQL, creates Ghost's database user with a password generated on your VM, runs Ghost once on a random local port so its migrations generate this site's session secrets, member and staff signing keys and API keys, and creates the owner account through Ghost's own setup API over the local loopback interface, with a password generated on your VM. It then checks that Ghost reports setup as complete and refuses a second setup. Only after that do Ghost and nginx start; if first boot cannot finish, nothing is served on ports 80 and 443. - Secrets of your VM's own, never on a command line. The owner password, the database password, Ghost's keys and the TLS key are generated on your VM; nothing from the image build is reused. Passwords travel in files only root can read (or on standard input), never as command line arguments. The database password reaches Ghost as a systemd credential, so it is not in
config.production.jsonor in Ghost's environment. - The current, verified release. Ghost 6.73.0 is checked file for file against the official npm release, pinned by its checksum. It includes body-parser 1.20.9, the fix for GHSA-27r3-jxmh-p828, and every Ghost security fix up to this release. Node.js 22.23.4 (the October 2026 security release) comes from NodeSource's signed repository.
- HTTPS from the first boot. nginx serves Ghost on port 443 with a certificate generated on your VM's first boot. It is a self-signed certificate for this VM only (it cannot vouch for any other site). Port 80 redirects to HTTPS. Ghost's own address is pinned to your VM's public IP address (or the name you set in Step 11), so every link, e-mails included, uses it.
- No calls home and no third-party scripts. Ghost's daily update check, Gravatar, structured data pings, the social web (ActivityPub), Unsplash, web analytics and the Explore ping are off. Portal (sign-up and sign-in), Search, Comments and Ghost's other front-end scripts load from your own VM (pinned releases) instead of from jsDelivr, and the default cover images Ghost points at static.ghost.org are removed. Ghost's admin, in your staff's own browsers, still fetches a few public assets from other servers: Ghost's changelog from ghost.org, default profile pictures from static.ghost.org, font previews from fonts.bunny.net (in Settings > Design & branding) and, when the editor's emoji picker is opened, emoji data from jsDelivr.
- Webmention receipt off. Ghost fetches the source address of every webmention anyone sends it; the image refuses incoming webmentions (Step 9 shows how to turn them back on).
- Members sign-up by invitation until you configure e-mail. Ghost's members sign in with e-mailed links, and the image has no mail service. Sign-up is set to invite-only, and staff device verification (an e-mailed code on sign-in from a new browser) is off so the owner is never locked out. Step 8 shows how to turn both on once e-mail works.
- A rate-limited sign in. Staff sign in, its verification step, password reset, setup, invitation acceptance and the members magic-link and one-time-code routes share one budget of 20 requests a minute per address (after a burst of 20), in any spelling of the address; Ghost's own lock-out applies on top. nginx uses the address of the connection, never a header a client sends.
- Safe uploads. Files under
/content/images,/content/filesand/content/mediaare sent withnosniffand a sandboxing content security policy; only passive types (images, PDF, fonts, audio, video) open in the browser and anything else, SVG and HTML included, is sent as a download. The rest of thecontentfolder is never served. - Staff sessions not usable from other sites. Ghost accepts an admin request that carries neither an
Originnor aRefererheader as if it came from the admin itself. nginx requires same-site proof for every admin API request that carries a staff session, so a link or image on another site cannot run admin actions (such as Ghost's server-side link preview fetch) with a signed-in editor's session. Admin API integrations, which sign requests with a key, are unaffected. - Hardened defaults. Ghost runs as its own
ghostaccount that can write only thecontentfolder; the code, configuration and ghost-cli record are read only to it. MySQL has no network listener (Ghost uses its local socket), itsrootaccount signs in only through the socket as the operating system's root, and its binary and general logs are off. Ghost, nginx, MySQL and the first boot service are blocked from the Azure Instance Metadata Service and the Azure WireServer (the Azure agent and cloud-init keep their access), and requests for a host name the VM does not serve are dropped. - Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.
What is on the image: Ghost 6.73.0 with ghost-cli 1.34.0, Node.js 22.23.4, MySQL 8.0, nginx, Ubuntu 24.04 LTS, cloud-init and the Azure Linux agent.
Recommended VM size: Standard_B2s (2 vCPU, 4 GiB) for a typical blog or newsletter; a D-series size for busy sites.
Prerequisites
- An Azure subscription, an SSH key pair and a virtual network subnet.
- A network security group that allows TCP 22 (SSH), TCP 443 (Ghost) and TCP 80 (redirects to HTTPS) from the networks that should reach them. Keep 22 to your own networks. Open 443 to your readers for a public site, or only to your team while you set it up.
Step 1: Deploy the VM
In the Azure portal, open the Ghost 6 on Ubuntu 24.04 LTS listing from cloudimg, choose Create, pick Standard_B2s, your SSH public key and a network security group as above, and create the VM. Copy its public IP address from the VM's Overview page.
With the Azure CLI (replace the values in angle brackets):
az vm create --resource-group <resource-group> --name ghost \
--image <the image URN from the listing> --size Standard_B2s \
--admin-username azureuser --ssh-key-values ~/.ssh/id_rsa.pub \
--public-ip-sku Standard --nsg-rule NONE
az network nsg rule create --resource-group <resource-group> --nsg-name ghostNSG --name ghost-web \
--priority 1000 --access Allow --protocol Tcp --destination-port-ranges 80 443 --source-address-prefixes <your-cidr>
First boot takes about a minute or two after the VM is running (Ghost builds its database on the first start).
Step 2: Connect to your VM
ssh azureuser@<public-ip>
Use the admin user name you chose when you created the VM (azureuser in this guide).
Step 3: Check the services
MySQL, Ghost (the ghost_cloudimg service) and nginx should all be active:
systemctl is-active mysql ghost_cloudimg nginx
active
active
active
Ghost is managed with ghost-cli from its folder, as your admin user (first boot gave the ghost-cli files to the account you created with the VM):
cd /var/www/ghost && ghost version
Ghost-CLI version: 1.34.0
Ghost version: 6.73.0 (at /var/www/ghost)
Only SSH, HTTP and HTTPS listen on the network; Ghost listens on the loopback interface behind nginx and MySQL has no TCP listener at all:
sudo ss -tlnH | awk '{print $4}' | sort -u
0.0.0.0:22
0.0.0.0:443
0.0.0.0:80
127.0.0.1:2368
127.0.0.53%lo:53
127.0.0.54:53
[::]:22
[::]:443
[::]:80

Step 4: Get your sign-in details
The credentials file holds the address Ghost is served as (your VM's public IP address by default), the admin address, the owner's sign-in e-mail and password, all generated on this VM, and the SHA-256 fingerprint of the VM's certificate. Show it with:
sudo cat /root/ghost-credentials.txt
# Ghost on this instance (cloudimg). Root-only (0600). Generated at first boot: 2026-10-28T16:20:41Z
GHOST_URL=https://<public-ip>/
GHOST_ADMIN_URL=https://<public-ip>/ghost/
GHOST_OWNER_EMAIL=owner@example.invalid
GHOST_OWNER_PASSWORD=<GHOST_OWNER_PASSWORD>
...
GHOST_TLS_SHA256=<64 hex digits separated by colons>
The owner signs in with the e-mail owner@example.invalid (a placeholder that can never receive mail). Change it to your own address under Settings > Staff once you are in (Step 5).
Step 5: Visit the site and sign in
Open GHOST_URL in a browser. The certificate is self signed, so the browser warns once. Before you continue, compare the certificate's SHA-256 fingerprint in the browser's certificate viewer with GHOST_TLS_SHA256 from Step 4; if they match you are talking to your own VM. Do not add this certificate to your computer's trusted certificates; Step 11 replaces it with one from a certificate authority. Visiting the VM by another of its addresses redirects to the configured one.


Open GHOST_ADMIN_URL, sign in with the owner e-mail and password, and you are in Ghost's admin: Posts, Pages, Tags, Members and Settings. First:
- Settings > Staff: open your own profile, set your name and your real e-mail address, and change the password if you like.
- Settings > General: set the site title, description and time zone.
- Settings > Design & branding: choose and customise a theme (Source is active).

Write a post with Posts > New post, then Publish. It appears on the home page at once.


Step 6: Check HTTPS and the setup state
Port 80 redirects to HTTPS on the configured address:
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1/
301 https://<public-ip>/
The setup API reports setup as complete and refuses to run again, so nobody can take over the site:
H=$(sed -n 's/^set \$ghost_host "\(.*\)";$/\1/p' /etc/nginx/ghost-host.conf)
curl -sk --resolve "$H:443:127.0.0.1" "https://$H/ghost/api/admin/authentication/setup/"; echo
curl -sk --resolve "$H:443:127.0.0.1" -o /dev/null -w 'second setup: %{http_code}\n' -H 'Content-Type: application/json' \
-d '{"setup":[{"name":"x","email":"x@example.invalid","password":"Xx1234567890abc","blogTitle":"x"}]}' \
"https://$H/ghost/api/admin/authentication/setup/"
{"setup":[{"status":true}]}
second setup: 403

Step 7: Your data
Ghost keeps its content in two places: the MySQL database ghost_prod (posts, members, settings and keys) and the folder /var/www/ghost/content (uploaded images, files and media, and your themes). Back up both, for example with an Azure Backup policy or a snapshot of the VM's OS disk, or:
sudo mysqldump --single-transaction ghost_prod | gzip > ghost_prod-$(date +%F).sql.gz
sudo tar -C /var/www/ghost -czf ghost-content-$(date +%F).tar.gz content
Ghost's own export (Settings > Advanced > Migration tools) saves posts and settings as JSON. Keep backups private: the database holds your members' e-mail addresses and the keys that sign sessions.
Step 8: E-mail, members sign-up and device verification
Ghost sends staff password resets, member sign-in links and newsletters by e-mail. The image does not configure a mail service, so set one up first. For transactional mail, add an SMTP server (for example Azure Communication Services Email, SendGrid or Mailgun) to /var/www/ghost/config.production.json, replacing the "mail" block:
"mail": {
"transport": "SMTP",
"from": "Your Site <noreply@your-domain>",
"options": {
"host": "<smtp-host>",
"port": 587,
"secure": false,
"auth": { "user": "<smtp-user>", "pass": "<smtp-password>" }
}
}
Azure blocks outbound TCP 25 for most subscriptions; use port 587. Then restart Ghost and test a password reset:
cd /var/www/ghost && ghost restart
Newsletters use Mailgun, set under Settings > Newsletters (Ghost's own documentation explains it).
Once e-mail works:
- Members sign-up: Settings > Membership > Access, Subscription access, is Invite-only. Choose Anyone can sign up to open it.
- Staff device verification (an e-mailed code when staff sign in from a new browser) is off. To turn it on, make sure every staff account has a real e-mail address, add
"security": { "staffDeviceVerification": true }toconfig.production.json(replace the existingsecurityblock) and runghost restart. Leave it off without working mail, or staff are locked out.
Step 9: The closed-by-default settings
These are in /var/www/ghost/config.production.json (it holds no password, so ghost-cli can manage it):
| Setting | Image value | To restore Ghost's default |
|---|---|---|
privacy.useTinfoil |
true: no Gravatar, structured data or IndexNow pings |
remove the privacy block |
updateCheck.url |
a closed local port: no daily update check | remove the updateCheck block (Ghost sends only its version and shows release and security notices in the admin) |
portal, sodoSearch, comments, announcementBar, signupForm, adminToolbar |
served from /cloudimg-apps/ on your VM |
remove the blocks to load them from jsDelivr |
In the database (change them in the admin; the settings search box finds each): the social web (Settings > Growth > Network), Unsplash and Pintura (Settings > Advanced > Integrations) and web analytics (Settings > General settings > Analytics) are off. Run ghost restart after editing the configuration.
The front-end scripts the image serves, and their versions:
cat /usr/local/share/cloudimg/ghost-apps/VERSIONS
portal 2.72.8
sodo-search 1.9.6
announcement-bar 1.1.679
comments-ui 1.6.448
signup-form 0.4.3
admin-toolbar 0.1.663
Webmentions. Receiving them is refused by nginx. To accept them, remove the if ($ghost_path_refused) line from /etc/nginx/sites-available/cloudimg-ghost (or drop the webmentions line from the map in /etc/nginx/conf.d/cloudimg-ghost-guard.conf) and run sudo nginx -t && sudo systemctl reload nginx. Ghost refuses to fetch private network addresses either way.
Step 10: Updating Ghost
ghost-cli updates Ghost in place, as your admin user, after a snapshot or backup:
cd /var/www/ghost && ghost update
Front-end scripts. After an update, compare the versions in /var/www/ghost/current/core/shared/config/defaults.json (portal.version and the others) with /usr/local/share/cloudimg/ghost-apps/VERSIONS. If Ghost now asks for a newer line (for example portal 2.73), either remove the matching block from config.production.json (Step 9) or place that release's umd files in the folder.
Keep Node.js up to date with sudo apt-get update && sudo apt-get upgrade (the NodeSource repository is configured).
Step 11: Your own DNS name and certificate
Point a DNS record at the VM's public IP address, then set the name Ghost is served as. This mints a new self-signed certificate for the name, pins it in nginx (the VM's IP addresses redirect to it), sets Ghost's url, restarts Ghost, updates /root/ghost-credentials.txt and keeps the name across reboots:
sudo ghost-cloudimg-set-hostname <your-domain>
Then replace the self-signed certificate with one issued by a certificate authority for your name (for example with certbot, or one from your organisation), copy it over the generated pair and reload nginx (running ghost-cloudimg-set-hostname again replaces it with a self-signed one):
sudo install -m 0644 <your-domain>.crt /etc/nginx/ssl/ghost.crt
sudo install -m 0600 <your-domain>.key /etc/nginx/ssl/ghost.key
sudo nginx -t && sudo systemctl reload nginx
Once browsers trust the certificate you can also add an HSTS header to /etc/nginx/snippets/cloudimg-ghost-headers.conf. The image does not send one: browsers ignore HSTS from a site whose certificate they do not trust.
Behind Azure Front Door or Application Gateway: terminate TLS on it and forward to port 443 on the VM with the configured host name. nginx always uses the connection's address as the client address, so with a proxy in front every request appears to come from the proxy; Ghost's per-address limits then apply to the proxy as a whole.
The VM's public IP address is also added to its loopback interface at every boot (ghost-selfroute.service), so when Ghost reads its own images (it does this to size the images on a page) the request stays on the VM instead of leaving through Azure's network address translation.
Step 12: Security checks
The owner password signs in, a wrong one is refused with Ghost's own error, and the checks below show the defaults in force:
sudo ghost-cloudimg-cred-roundtrip
OK https 201 200 422
H=$(sed -n 's/^set \$ghost_host "\(.*\)";$/\1/p' /etc/nginx/ghost-host.conf)
R=(curl -sk --resolve "$H:443:127.0.0.1" -o /dev/null)
"${R[@]}" -w 'webmention %{http_code}\n' -d 'source=https://example.com/&target=https://example.org/' "https://$H/webmentions/receive/"
"${R[@]}" -w 'upper-case upload path %{http_code}\n' "https://$H/CONTENT/images/x.svg"
"${R[@]}" -w 'unknown host %{http_code}\n' -H 'Host: other.example' https://127.0.0.1/ ; echo
webmention 404
upper-case upload path 404
unknown host 000
(000 means nginx closed the connection without an answer.) The certificate is an end-entity certificate for this VM, not a certificate authority:
sudo openssl x509 -in /etc/nginx/ssl/ghost.crt -noout -ext basicConstraints,keyUsage,extendedKeyUsage
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Key Usage: critical
Digital Signature, Key Encipherment
X509v3 Extended Key Usage:
TLS Web Server Authentication

Ghost, nginx and MySQL are blocked from the Azure Instance Metadata Service and the WireServer:
for u in ghost_cloudimg nginx mysql; do printf '%s: ' $u; systemctl show -p IPAddressDeny --value $u | tr ' ' '\n' | sort | tr '\n' ' '; echo; done
ghost_cloudimg: 168.63.129.16/32 169.254.169.254/32 ::ffff:168.63.129.16/128 ::ffff:169.254.169.254/128 fd00:ec2::254/128
nginx: 168.63.129.16/32 169.254.169.254/32 ::ffff:168.63.129.16/128 ::ffff:169.254.169.254/128 fd00:ec2::254/128
mysql: 168.63.129.16/32 169.254.169.254/32 ::ffff:168.63.129.16/128 ::ffff:169.254.169.254/128 fd00:ec2::254/128

The credentials and the database password are readable by root only, Ghost's configuration holds no secret, the content folder belongs to Ghost and the code to your admin user, who runs ghost-cli:
sudo stat -c '%a %U %n' /root/ghost-credentials.txt /etc/ghost-cloudimg/db-password /var/www/ghost/content
600 root /root/ghost-credentials.txt
600 root /etc/ghost-cloudimg/db-password
775 ghost /var/www/ghost/content
Step 13: Passwords
ghost-cloudimg-set-password gives the owner a new random password with Ghost's own password hashing, signs out every staff session, clears sign-in lock-outs and records the password in /root/ghost-credentials.txt. Run it without arguments, or pass your own password (12 to 96 letters and digits) on standard input. Staff change their own password under Settings > Staff.
sudo ghost-cloudimg-set-password
printf '%s\n' '<your-new-password>' | sudo ghost-cloudimg-set-password --stdin
Step 14: Server components
| Component | Path |
|---|---|
Ghost (ghost-cli instance cloudimg, owned by your admin user) |
/var/www/ghost |
| Configuration (no secrets) | /var/www/ghost/config.production.json |
Uploads and themes (owned by ghost) |
/var/www/ghost/content |
| Ghost service | /lib/systemd/system/ghost_cloudimg.service |
| Database password (root only, a systemd credential for Ghost) | /etc/ghost-cloudimg/db-password |
| Database (MySQL datadir) and settings | /var/lib/mysql, /etc/mysql/mysql.conf.d/99-cloudimg-ghost.cnf |
| nginx site, request limits and cross-site rules | /etc/nginx/sites-available/cloudimg-ghost, /etc/nginx/conf.d/cloudimg-ghost-guard.conf, /etc/nginx/conf.d/cloudimg-ghost-xsite.conf, /etc/nginx/conf.d/cloudimg-xsite-guard.conf, /etc/nginx/snippets/cloudimg-ghost-*.conf |
| Front-end scripts served locally | /usr/local/share/cloudimg/ghost-apps/ |
| Certificate and key | /etc/nginx/ssl/ |
| The VM's addresses from Azure metadata (every boot) | ghost-address.service, /run/cloudimg-ghost/address.env |
| First boot and helpers | /usr/local/sbin/ghost-firstboot, ghost-cloudimg-set-hostname, ghost-cloudimg-set-password, ghost-cloudimg-cred-roundtrip |
| Licence, release and image notes | /usr/share/doc/ghost-cloudimg/ |
| Credentials | /root/ghost-credentials.txt (mode 0600) |
Step 15: Security recommendations
- Allow port 22 in the network security group from your own networks only.
- Set your real e-mail on the owner account and keep its password secret (Step 13). Give each writer their own staff account with the least role they need (Contributor, Author, Editor).
- Configure e-mail (Step 8), then consider turning on staff device verification.
- Use your own DNS name and a certificate from a certificate authority (Step 11).
- Keep Ghost on the newest release (Step 10) and install only themes you trust.
- Keep the operating system patched; unattended security upgrades are on, and
sudo apt-get update && sudo apt-get upgradeapplies everything else (Node.js included).
If you deployed before 28 October 2026
Images published before this date ran Ghost 6.36.0, which is affected by published Ghost security advisories (among them GHSA-62q6-4hv4-vjrw, rated critical), and opened Ghost's setup wizard on plain HTTP: until someone completed it, the first visitor to /ghost/ could create the site owner. To check a VM from that image:
grep '"version"' /var/www/ghost/current/package.json
sudo grep '^MYSQL_ROOT_PASSWORD=' /stage/scripts/ghost-credentials.log
mysql -u root -p ghost_prod -e 'select email, status, created_at from users'
(mysql asks for the password; paste the MYSQL_ROOT_PASSWORD value the second command shows.)
If the version is older than 6.73.0, or there is a staff account you did not create, deploy a new VM from the current image and move your content to it with Ghost's export and import (Settings > Advanced > Migration tools) and a copy of /var/www/ghost/content/images, then delete the old VM. Change the passwords of every staff account on the new site.
Support and licensing
Ghost is free software under the MIT licence (a copy is at /usr/share/doc/ghost-cloudimg/LICENSE.ghost); the bundled libraries carry their own open source licences. There is no per user or per CPU licence fee. This image installs the unmodified upstream release, plus the cloudimg first boot, settings and helpers described above. Ghost is a trademark of the Ghost Foundation; cloudimg is not affiliated with or endorsed by it. The image price covers cloudimg's packaging, hardening, first boot setup and support.
- Email: support@cloudimg.co.uk