[Blog](https://deploywithox.com/blog) [Guides](https://deploywithox.com/blog#c-guides) # How to self-host Ghost on your own VPS without Docker Run Ghost v6.71.0 on your own Ubuntu server with ox: MySQL, a pnpm and nx build, and an owner account made from two variables. The config that ran. Saurav Sharma Oct 17, 2026 14 min read On this page - [What you need](https://deploywithox.com/blog/self-host-ghost#what-you-need) - [The steps](https://deploywithox.com/blog/self-host-ghost#steps) - [The ox.toml, line by line](https://deploywithox.com/blog/self-host-ghost#config) - [MySQL, and what is not set up](https://deploywithox.com/blog/self-host-ghost#services) - [Updates and backups](https://deploywithox.com/blog/self-host-ghost#updates) - [Frequently asked questions](https://deploywithox.com/blog/self-host-ghost#faq) To self-host Ghost you need an Ubuntu server, a fork of the Ghost repo with an `ox.toml` and two helper files added, and 17 variables, of which only the owner's email and password are required. In our run the first deploy took about 3 minutes, and ox started a MySQL-compatible database for it. **How we tested** On Oct 17, 2026, ox deployed Ghost v6.71.0 from commit [`2b047c78`](https://github.com/TryGhost/Ghost/commit/2b047c78d5fcb6f13b6ef1b81f85dc26d44ffc25) on a fresh DigitalOcean server with 8 vCPUs and 16 GB of memory. The run then redeployed it, backed it up and rebooted the server. Then a separate test agent, one that did not write the config, signed in, published a post and saw it on the public home page and in the admin after a reload, and checked the app's services and its backup. [Ghost](https://github.com/TryGhost/Ghost) is a publishing platform for blogs, newsletters and membership sites. It is a Node.js app under the MIT license. Staff write in an admin panel at `/ghost/`, and readers see the public site at `/`. Posts and members live in MySQL, and images and themes live in a content folder on disk. ox deployed Ghost v6.71.0 (commit 2b047c78) on Oct 17, 2026 on a fresh DigitalOcean server with 8 vCPUs and 16 GB of memory; the first deploy took 3 minutes and a redeploy took 54 seconds. - **First deploy:** 3 minutes, from adding the project until the app passed the run's first checks. - **Redeploy of the same commit:** 54 seconds, until the app passed the checks again. - **Server:** DigitalOcean, 8 vCPUs and 16 GB of memory (`s-8vcpu-16gb`), with this app alone on it. Every one of the 85 apps we deployed ran on this one size, so it was not picked for this app. - **Memory in use after the reboot:** 938 MB of 15986 MB, for the whole server: Ubuntu, the app, its services, Caddy and the ox agent. On a server with no apps, ox's tests on real servers hold the agent to 40 MB or less and Caddy to 50 MB or less. - **Services ox runs for it:** `mysql`. - **Source:** [github.com/TryGhost/Ghost](https://github.com/TryGhost/Ghost) at v6.71.0, commit [`2b047c78`](https://github.com/TryGhost/Ghost/commit/2b047c78d5fcb6f13b6ef1b81f85dc26d44ffc25). The public home page of the test Ghost site My Ghost Site The Ghost admin Analytics page after signing in as the site owner The published Ghost post Hello from the ox verify run on the public site ## What you need - An Ubuntu LTS server. The run used the size in the facts above, with Ghost alone on it. Ghost 6 pins Node in `.nvmrc` and pnpm in `package.json`, and ox reads those pins. - A GitHub account to fork [`TryGhost/Ghost`](https://github.com/TryGhost/Ghost), and an ox account, which you open by signing in with GitHub. - An email address and a password for the site's owner. Ghost mails that address a six-digit code when the owner signs in from a new browser. The run sent its mail to a test mail catcher and read the code there. Real email was not tested. ## The steps Open the ox dashboard and press Add a server. It prints one install command that holds a fresh token. Run it as root on your server to join it to your account. Fork `TryGhost/Ghost` on GitHub and check out the tag `v6.71.0`, the release the run deployed. Put the `ox.toml` and the two files shown below at the root of the fork, then commit and push. Next write `ghost.env` on your laptop, with one `KEY=value` line for each key in the variables list below, plus the two `unset` lines it describes. With the [ox CLI](https://deploywithox.com/docs/cli) signed in, run: Shell ```bash ox new /Ghost ox review ghost --from-file ghost.env --wait ``` ox names the project after the repo, so the commands call it `ghost`. The review saves the values and starts the first deploy, and `--wait` prints the run until it ends. The [quickstart](https://deploywithox.com/docs/quickstart) shows the console's version. When the deploy is green, open the project's address and add `/ghost/`. Sign in with the owner's email and password, then type the mailed code. To publish, click Posts, then New post, write a title and a line of text, and click Publish, "Continue, final review" and "Publish post, right now". The test agent did this and saw the post on the public home page and, after a reload, listed as Published in the admin. The run served Ghost on its preview address only. A domain of your own was not part of it, and the [domains page](https://deploywithox.com/docs/operate/domains) says how ox adds one. ## The ox.toml, line by line TOML ```toml # Ghost 6, as Dockerfile.production builds its server and CI builds its # admin (job_build_admin), with MySQL (MariaDB) and the content folder kept # (the image's VOLUME). Ghost reads its config from `__` variables: url, # database__*, mail__* and logging are in ox.vars. # fontconfig is the image's runtime package; jemalloc is preloaded in start. packages = ["libjemalloc2", "fontconfig"] # The owner is made at the first start from these, through the setup # screen's own API call, so no first visitor can claim the site. required_variables = ["GHOST_OWNER_EMAIL", "GHOST_OWNER_PASSWORD"] [app] start = "exec bash ox-ghost.sh start" health = "/ghost/api/admin/site/" [build] # The whole workspace: admin's nx build fans out across the frontend graph. # The workspace links node_modules into pnpm's global store, which the # Dockerfile and CI turn off so the app is self-contained. install = "pnpm_config_enable_global_virtual_store=false pnpm install --frozen-lockfile" commands = [ # The Dockerfile's server build: the workspace packages ghost runs, its # TypeScript (compiled in place) and its assets. "pnpm --filter-prod 'ghost^...' -r run build", "pnpm --filter ghost run build:tsc", "pnpm --filter ghost run build:assets", # CI's admin build: writes ghost/core/built/admin and embed-renderer. # CI's settings for nx, and no Nx Cloud. "NX_NATIVE_COMMAND_RUNNER=false NX_DAEMON=false NX_NO_CLOUD=true pnpm nx run @tryghost/admin:build", ] # Ghost would also migrate as it boots; run it first, so a first deploy's # schema is not made inside the start's health wait. migrate = "bash ox-ghost.sh migrate" [services] mysql = {} [storage] # Ghost's contentPath: images, media, files, themes, settings (routes.yaml). # The bundled Casper and Source themes are seeded into it. keep = ["ghost/content"] ``` Ghost 6 is a pnpm and nx monorepo. Its Dockerfile builds the server and its CI builds the admin panel, and this config runs both builds. - `packages` adds `libjemalloc2`, the memory allocator Ghost's production image preloads, and `fontconfig`, that image's runtime package. - `required_variables` names the owner's email and password, which ox asks for before the first deploy. - `[app]` starts Ghost through `ox-ghost.sh`. `health` is a path of Ghost's Admin API, and ox switches to a new release only once it answers. - `install` puts `pnpm_config_enable_global_virtual_store=false` in front of the frozen-lockfile install. Ghost's workspace links `node_modules` to a pnpm store outside the app folder. Its Dockerfile and CI turn that off, so the app folder holds all it runs on. - The four build `commands` run in order. Three are the Dockerfile's server build: the workspace packages the server runs, the TypeScript compile, and the assets. The fourth is CI's admin build, which writes the admin panel into `ghost/core/built` with CI's nx settings (no daemon, no Nx Cloud). - `migrate` runs knex-migrator through `ox-ghost.sh`, as Ghost-CLI does before it starts Ghost. It creates the schema on a first deploy and migrates it on an update, ahead of the health check. - `[services]` asks for `mysql`. `[storage]` keeps `ghost/content` across deploys: images, media, files, themes and settings. The bundled Casper and Source themes are seeded into it. There are no workers or cron jobs, because Ghost runs its jobs in memory inside the web process. `ox-ghost-owner.mjs` Code ``` // Ghost's owner account on ox, made from GHOST_OWNER_* the way the setup // screen (/ghost/#/setup) makes it: one call to the Admin API's setup // endpoint, which also names the site and seeds the default tier, // newsletter and theme settings. Run from ghost/ by ox-ghost.sh. // state prints "yes" when the site is set up, "no" when it is not: // Ghost's own test (User.isSetup), the owner's status, read // from the database Ghost's config names // setup PID PORT waits for the Ghost started as PID to answer on PORT, // then posts the setup unless the site is set up already, and // turns the Network feature off (see networkOff) import http from 'node:http'; import {createRequire} from 'node:module'; import path from 'node:path'; // Ghost's dependencies and config, resolved from ghost/ (the cwd) const require = createRequire(path.join(process.cwd(), 'index.js')); const setupPath = '/ghost/api/admin/authentication/setup/'; const sessionPath = '/ghost/api/admin/session/'; const settingsPath = '/ghost/api/admin/settings/'; async function state() { const config = require('./core/shared/config'); const mysql = require('mysql2/promise'); const {host, port, user, password, database} = config.get('database:connection'); const conn = await mysql.createConnection({host, port, user, password, database}); try { const [rows] = await conn.query( 'SELECT u.status FROM users u JOIN roles_users ru ON ru.user_id = u.id JOIN roles r ON r.id = ru.role_id WHERE r.name = ?', ['Owner'] ); if (rows.length !== 1) { throw new Error(`expected one owner in the users table, found ${rows.length}`); } console.log(rows[0].status === 'inactive' ? 'no' : 'yes'); } finally { await conn.end(); } } function request(port, method, urlPath, body, cookie) { const {PUBLIC_HOST, PUBLIC_SCHEME, PUBLIC_URL} = process.env; const data = body ? JSON.stringify(body) : undefined; return new Promise((resolve, reject) => { const req = http.request({ host: '127.0.0.1', port, method, path: urlPath, timeout: 30000, headers: { // As ox's proxy calls it, so Ghost serves the request instead of // redirecting it to the site's public URL Host: PUBLIC_HOST, 'X-Forwarded-Proto': PUBLIC_SCHEME, 'X-Forwarded-For': '127.0.0.1', Origin: PUBLIC_URL, Accept: 'application/json', ...(cookie ? {Cookie: cookie} : {}), ...(data ? {'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data)} : {}) } }, (res) => { let text = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { text += chunk; }); res.on('end', () => resolve({status: res.statusCode, headers: res.headers, text})); }); req.on('timeout', () => req.destroy(new Error(`${method} ${urlPath} timed out`))); req.on('error', reject); req.end(data); }); } function alive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } } async function setup(pid, port) { const {GHOST_OWNER_EMAIL, GHOST_OWNER_PASSWORD, GHOST_OWNER_NAME, GHOST_SITE_TITLE} = process.env; if (!GHOST_OWNER_EMAIL || !GHOST_OWNER_PASSWORD) { throw new Error('GHOST_OWNER_EMAIL and GHOST_OWNER_PASSWORD must be set: they are the owner\'s sign-in'); } const deadline = Date.now() + 5 * 60 * 1000; let last = 'no answer'; for (;;) { if (!alive(pid)) { throw new Error(`Ghost stopped before it answered ${setupPath}, so no owner was made`); } try { const res = await request(port, 'GET', setupPath); if (res.status === 200) { if (JSON.parse(res.text).setup[0].status === true) { console.log('ox-ghost: the site is set up already'); return; } break; } last = `${res.status} ${res.text.slice(0, 200)}`; } catch (err) { last = err.message; } if (Date.now() > deadline) { throw new Error(`Ghost did not answer ${setupPath} in 5 minutes (last: ${last}), so no owner was made`); } await new Promise((resolve) => { setTimeout(resolve, 1000); }); } const res = await request(port, 'POST', setupPath, { setup: [{ name: GHOST_OWNER_NAME || 'Owner', email: GHOST_OWNER_EMAIL, password: GHOST_OWNER_PASSWORD, blogTitle: GHOST_SITE_TITLE || 'Ghost' }] }); if (res.status !== 201) { throw new Error(`the setup answered ${res.status}: ${res.text.slice(0, 500)}`); } console.log(`ox-ghost: made the owner ${GHOST_OWNER_EMAIL}`); await networkOff(port, GHOST_OWNER_EMAIL, GHOST_OWNER_PASSWORD); } // networkOff: Ghost's Network feature (social_web, on by default) needs // Ghost's ActivityPub service, a separate app this deploy does not run. On, // the admin calls /.ghost/activitypub/ on every page (404) and the boot // logs a failed fetch, so the site starts with it off; the owner can turn it // on in Settings, Network. An owner who never signed in gets a session // without the mailed code, so this sign-in is the owner's first. async function networkOff(port, email, password) { const signin = await request(port, 'POST', sessionPath, {username: email, password}); if (signin.status !== 201) { throw new Error(`the owner's first sign-in answered ${signin.status}: ${signin.text.slice(0, 500)}`); } const cookie = (signin.headers['set-cookie'] || []).map((c) => c.split(';')[0]).join('; '); if (!cookie) { throw new Error('the owner\'s first sign-in set no session cookie'); } const res = await request(port, 'PUT', settingsPath, {settings: [{key: 'social_web', value: false}]}, cookie); if (res.status !== 200) { throw new Error(`turning Network off answered ${res.status}: ${res.text.slice(0, 500)}`); } console.log('ox-ghost: turned Network (ActivityPub) off'); } const [mode, pid, port] = process.argv.slice(2); try { if (mode === 'state') { await state(); } else if (mode === 'setup' && Number(pid) > 0 && Number(port) > 0) { await setup(Number(pid), Number(port)); } else { throw new Error('usage: ox-ghost-owner.mjs state | setup PID PORT'); } } catch (err) { console.error(`ox-ghost: ${err.message}`); process.exit(1); } ``` `ox-ghost.sh` Code ``` #!/usr/bin/env bash # Ghost on ox, run as Ghost-CLI and the Docker image run it: the server in # ghost/, configured by Ghost's own `__` variables (url, database__*, # mail__*), with jemalloc in front. # migrate knex-migrator, as Ghost-CLI runs it before a start: makes the # schema on the first deploy and migrates it on an upgrade # start make the owner from GHOST_OWNER_* when the site is not set up # yet, then run Ghost set -euo pipefail cd ghost export NODE_ENV=production migrate() { exec pnpm exec knex-migrator-migrate --init --mgpath . } # make_owner: Ghost makes its owner only through its running server (the # setup screen's API call), so run it in the background, complete the # setup, and stop it again. It listens on a free port of its own, not on # $PORT, where ox's health check would take it for the started release. make_owner() { local pid port rc=0 port=$(node -e 'const s = require("node:net").createServer(); s.listen(0, "127.0.0.1", () => { console.log(s.address().port); s.close(); });') server__port=$port node index.js & pid=$! if ! node ../ox-ghost-owner.mjs setup "$pid" "$port"; then kill "$pid" 2>/dev/null || true wait "$pid" || true exit 1 fi kill "$pid" wait "$pid" || rc=$? # 143 is a stop by SIGTERM before Ghost's own handler took it. if [ "$rc" != 0 ] && [ "$rc" != 143 ]; then echo "ox-ghost: Ghost ended with status $rc after the owner was made" >&2 exit 1 fi } start() { local jemalloc state jemalloc=/usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 [ -f "$jemalloc" ] || { echo "ox-ghost: no $jemalloc (the libjemalloc2 package)" >&2; exit 1; } export server__host=127.0.0.1 server__port=$PORT state=$(node ../ox-ghost-owner.mjs state) case $state in yes) ;; no) make_owner ;; *) echo "ox-ghost: the setup check answered '$state'" >&2; exit 1 ;; esac # The Docker image preloads jemalloc for the server (ENV LD_PRELOAD). exec env LD_PRELOAD="$jemalloc" node index.js } case "${1:-}" in migrate) migrate ;; start) start ;; *) echo "usage: ox-ghost.sh migrate|start" >&2; exit 2 ;; esac ``` Ghost makes its owner only through its running server: the setup screen at `/ghost/#/setup` posts to the Admin API, so the first visitor to a public address would own the site. These files make the owner at the first start instead, from your variables. The `start` mode of `ox-ghost.sh` asks `ox-ghost-owner.mjs state` whether the owner exists, by reading Ghost's database. If not, it runs Ghost in the background on a free port of its own, not `$PORT`, so ox's health check cannot take it for the new release, and `ox-ghost-owner.mjs setup` posts the setup call to it. The script then stops that Ghost and starts the real one with jemalloc preloaded, as Ghost's Docker image does. The setup also signs the owner in once and turns off Ghost's Network feature, which needs a separate ActivityPub service this deploy does not run. With it on, the admin gets a 404 from `/.ghost/activitypub/` on every page. An owner who has never signed in gets a session without the mailed code, so that first sign-in needs no mail. Shell ```bash GHOST_OWNER_EMAIL GHOST_OWNER_PASSWORD GHOST_OWNER_NAME GHOST_SITE_TITLE url database__client database__connection__host database__connection__port database__connection__user database__connection__password database__connection__database logging__transports mail__transport mail__from mail__options__host mail__options__port mail__options__secure ``` In Ghost's variables a double underscore stands for nesting: `database__connection__host` is `database.connection.host`. - `GHOST_OWNER_EMAIL` and `GHOST_OWNER_PASSWORD` are the owner's sign-in. `GHOST_OWNER_NAME` and `GHOST_SITE_TITLE` set the owner's display name and the site's title. - `url` is `${PUBLIC_URL}`, a reference ox fills in with the project's address. Ghost builds its links from it and redirects a request that arrives on any other host to it. See [variables](https://deploywithox.com/docs/operate/variables) for how references work. - `database__client` is mysql. The five `database__connection__*` keys are the references `${mysql.host}`, `${mysql.port}`, `${mysql.user}`, `${mysql.password}` and `${mysql.database}`. They replace Ghost's production defaults of a local server, the root user and an empty password. - `logging__transports` is stdout alone, so Ghost's lines go to the service's journal, which `ox logs ghost` reads, instead of to files. - `mail__transport` is SMTP, and `mail__from` is the sender address (an example.com one in the run). `mail__options__host`, `mail__options__port` and `mail__options__secure` describe the run's test mail catcher. `mail__options__auth__user` and `mail__options__auth__pass` are the login of a mail server that wants one. The catcher needs none, so the run marked both not needed, with an `unset` line each in `ghost.env`. Real mail was not tested. ## MySQL, and what is not set up ox runs a MariaDB 11.8 server, which speaks MySQL, for this project alone, as a systemd unit beside the app's. Ghost sends newsletters through Mailgun and draws the traffic charts of its Analytics page from Tinybird. The run configured neither, so that page stays mostly empty and no newsletter was sent. Publishing to the site works without them, and that is what the run did. ## Updates and backups To move to a new Ghost release, merge its tag into your fork and push, or run `ox deploy ghost --wait`. ox builds the new release beside the running one, runs the migrate step, and switches once the health path answers. The run redeployed the same commit, and a newer Ghost version was not tested. Read Ghost's [update guide](https://docs.ghost.org/update/) before a major version. Backups need no setup. The run made two with Back up now, one of the MySQL data and one of the kept `ghost/content` folder, and `ox backups ghost` lists them. A restore was not tested. The [backups page](https://deploywithox.com/docs/operate/backups) covers the daily schedule and keeping copies in an S3 bucket. ## Frequently asked questions ### Do I need Docker to self-host Ghost? No. Ghost publishes a Docker image and its own CLI installer, but the app is a Node.js server. ox builds it from your fork on the server with pnpm and runs it as a system service, with MySQL beside it. An update is a push to your fork or one deploy command. ### Which database does Ghost need? Ghost's production settings use MySQL, and the run used it. ox starts a MariaDB 11.8 server that only this project uses, and Ghost reaches it through the database variables. The content folder, with images and themes, stays on disk across deploys. Other databases were not tested. ### How do I sign in to Ghost the first time? Set GHOST_OWNER_EMAIL and GHOST_OWNER_PASSWORD before the first deploy. The first start makes that owner, so no visitor can claim the site at the setup screen. Open /ghost/, sign in, then type the six-digit code Ghost mails to the owner. Real email was not tested. ### How much memory does self-hosted Ghost use? After the reboot, the whole server used the memory the facts above show. That counts Ghost, its MySQL server, Caddy and the ox agent. The build runs on the server too. ox takes any server from 1 GB up and says so when memory is why an app stopped. New to running apps on your own server? [How ox self-hosts your apps](https://deploywithox.com/self-hosting) shows what ox sets up on your server. On this page - [What you need](https://deploywithox.com/blog/self-host-ghost#what-you-need) - [The steps](https://deploywithox.com/blog/self-host-ghost#steps) - [The ox.toml, line by line](https://deploywithox.com/blog/self-host-ghost#config) - [MySQL, and what is not set up](https://deploywithox.com/blog/self-host-ghost#services) - [Updates and backups](https://deploywithox.com/blog/self-host-ghost#updates) - [Frequently asked questions](https://deploywithox.com/blog/self-host-ghost#faq) - [Do I need Docker to self-host Ghost?](https://deploywithox.com/blog/self-host-ghost#faq-docker) - [Which database does Ghost need?](https://deploywithox.com/blog/self-host-ghost#faq-database) - [How do I sign in to Ghost the first time?](https://deploywithox.com/blog/self-host-ghost#faq-signin) - [How much memory does self-hosted Ghost use?](https://deploywithox.com/blog/self-host-ghost#faq-memory) Written by Saurav Sharma Builds ox. Writes about running apps on servers you own, without Docker and without a platform bill. [Older post How to self-host Ghostfolio on your own VPS without Docker](https://deploywithox.com/blog/self-host-ghostfolio) [Newer post How to self-host FreshRSS without Docker on your own VPS](https://deploywithox.com/blog/self-host-freshrss) ## Keep reading - Guides [Self-Hosted Open-Source Apps Deployed with ox: All 85](https://deploywithox.com/blog/self-hosted-open-source-apps-deployed-with-ox) ox deployed 85 open-source apps from pinned commits on fresh servers, with no Docker. The stacks, services, deploy times and a table of every app. Saurav Sharma Oct 17, 2026 16 min read - Guides [How to self-host AFFiNE on your own VPS without Docker](https://deploywithox.com/blog/self-host-affine) Run AFFiNE v0.27.4 on your own Ubuntu server with ox: Postgres with pgvector, Redis, a Rust and Node build, and an admin made from two variables. Saurav Sharma Oct 17, 2026 12 min read - Guides [How to self-host Akaunting on your own VPS without Docker](https://deploywithox.com/blog/self-host-akaunting) Run Akaunting 3.2.4 on your own Ubuntu server with ox: PHP, MariaDB, a scheduler and the first company and admin made on deploy, from the config that ran. Saurav Sharma Oct 17, 2026 10 min read ## Deploy to a server you own ox sets up systemd, Caddy and PostgreSQL on your Ubuntu server and deploys on every push. Free for your first project. [Sign up with GitHub](https://deploywithox.com/login/github) [Read the docs](https://deploywithox.com/docs)