Swapping Ghost for a Static Astro Site Behind a Cloudflare Tunnel

How we replaced a Ghost blog with a static Astro site on the same tunnel without touching its config: a Docker network alias, 410s for old URLs, and two nginx traps.

By the ailog editors · Published Oct 1, 2026 · 7 min read · How we work
In short
  • This site used to be a Ghost blog running in Docker on our production server, reached through a token-based Cloudflare Tunnel whose only route pointed at http://ghost:2368.
  • We replaced it with a static Astro build served by nginx:alpine, without changing the tunnel at all: the new container joins the same Docker network under the alias ghost and listens on port 2368.
  • Exactly 100 old URLs (posts, tags, authors) now return 410 Gone from an nginx map; the old RSS and sitemap paths redirect to the new ones.
  • Two traps cost us time: nginx caches map results for the whole request (the custom error page never rendered until we added volatile), and rsync silently broke a single-file Docker bind mount.

Why we moved off Ghost

The old site had two problems, and neither was Ghost’s fault. The content had been produced in bulk and had serious duplication issues (we wrote up the cleanup separately), and the stack was heavier than the job: a Node.js app plus a MySQL 8 container to serve pages that change a few times a week. We decided to start over with a site that is plain files on disk.

We picked Astro because it builds Markdown/MDX content collections into static HTML with no client-side framework by default, which keeps pages fast and leaves almost nothing to patch. The whole site — layouts, a cost calculator and the articles — is a git repository. Publishing is npm run build followed by an rsync.

The new build also does things Ghost could not do for us. Before Astro runs, a script re-reads the official pricing pages of the AI providers we cover and fails the build if any number in our pricing table no longer matches. After Astro runs, a check script fails the build if an article is under 900 words, contains something that looks like a private IP address, an email address or an API key, or links to a post that does not exist.

The constraint: a tunnel we did not want to touch

The production server has no inbound ports open for this site. Traffic arrives through a Cloudflare Tunnel run by a cloudflared container, and the tunnel is the token-based kind: its routes (ingress rules) live in the Cloudflare dashboard rather than in a config file on the server. Changing them is easy but is one more moving part, in an account that also hosts other sites.

The cloudflared logs told us exactly where the tunnel sends traffic:

ERR ... ingressRule=0 originService=http://ghost:2368

ghost here is not a hostname in DNS. It is the Compose service name, which Docker’s embedded DNS resolves for any container on the same user-defined network. Inspecting the Ghost container confirmed it carried two aliases on that network, its container name and ghost:

docker inspect ghost-blog --format '{{json .NetworkSettings.Networks}}'
# {"ghost-blog_ghost-net":{ ... "Aliases":["ghost-blog","ghost"] ... }}

So the swap needed no Cloudflare change at all. Whatever answers to ghost:2368 on that network is the website.

The swap: a network alias and port 2368

The new container is a stock nginx:1.27-alpine that listens on 2368 and joins the existing network with the alias ghost:

services:
  web:
    image: nginx:1.27-alpine
    container_name: ailog-web
    restart: unless-stopped
    volumes:
      - ./site:/usr/share/nginx/html:ro
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./gone.map:/etc/nginx/gone.map:ro
    networks:
      ghost-net:
        aliases: [ghost]
networks:
  ghost-net:
    external: true
    name: ghost-blog_ghost-net

One detail matters: two containers must never hold the same alias at the same time. Docker’s DNS would then return both addresses and the tunnel would alternate between the old and new site. The order was therefore:

  1. Build and upload the new site, and test it in a separate staging container published only on 127.0.0.1:2369 (no alias), with curl against every route class.
  2. docker stop ghost-blog, then docker compose up -d for the new container.
  3. Check the public URLs.

The gap between steps 2 and 3 was a few seconds. We did not delete anything: the MySQL container keeps running, and a full mysqldump plus a tarball of Ghost’s content/ directory were taken first. We also ran docker update --restart=no ghost-blog so that a server reboot cannot bring the old container back and reintroduce the duplicate alias. Rolling back is two commands: stop ailog-web, start ghost-blog.

We hit one practical snag on the way. Our deploy user can manage Docker but has no passwordless sudo, and the server’s /data directory is owned by root, so we could not create a new top-level directory for the site. The new site lives in a subdirectory of the old Ghost project folder, which our user already owned. That fits anyway, since it replaces that project.

Retiring 100 old URLs with 410 Gone

The old site had 76 published posts, several drafts and pages, 21 tag pages and 3 author pages. None of them were coming back. For those URLs we wanted 410 Gone rather than 404 Not Found: Google’s documentation treats the two similarly, but 410 states plainly that the removal is deliberate, and it keeps our own logs honest.

We exported every post, tag and author slug from the Ghost database and dropped the four that exist on the new site too (about, contact, privacy, terms). That left exactly 100 paths. They go into an nginx map file, one line each:

# gone.map (generated at deploy time from deploy/gone.txt)
/some-old-post-slug/ 1;
/tag/ai-news/ 1;
map $uri $ailog_gone { volatile; default 0; include /etc/nginx/gone.map; }

server {
  listen 2368;
  root /usr/share/nginx/html;

  if ($ailog_gone) { return 410; }
  location = /rss/ { return 301 /rss.xml; }
  location = /sitemap.xml { return 301 /sitemap-index.xml; }
  location ~ ^/(ghost|members|p|tag|author|page|content/images)/ { return 410; }

  location / { try_files $uri $uri/ $uri.html =404; }
  error_page 404 /404.html;
  error_page 410 /404.html;
}

The old feed and sitemap paths get a 301 to their new equivalents, and Ghost’s own system paths (/ghost/, /members/, /content/images/) are gone wholesale. In Search Console we submitted the new sitemap index and removed the old one.

Trap 1: nginx caches map values for the whole request

After the first deploy, the 410s worked, but every one of them returned nginx’s bare 143-byte default error page instead of our styled “this page isn’t here” page. Unknown URLs, which go through the same error_page mechanism with a 404, rendered correctly.

The cause is how map variables are evaluated. nginx computes a mapped variable the first time it is used and caches the result for the rest of the request, including internal redirects. Here is the sequence:

  1. A request for an old slug sets $ailog_gone to 1, and return 410 fires.
  2. error_page 410 /404.html performs an internal redirect to /404.html.
  3. The server-level if ($ailog_gone) runs again for the redirected request. The variable is still the cached 1, even though $uri is now /404.html.
  4. nginx refuses to recurse into another error page and falls back to its built-in one.

The fix is the volatile parameter of the map directive, which tells nginx not to cache that variable. With it, the redirected /404.html request re-evaluates $uri, gets 0, and the custom page is served with the 410 status.

Trap 2: rsync and single-file bind mounts

While fixing trap 1, we hit a stranger problem. We edited nginx.conf, synced it to the server, and ran nginx -t && nginx -s reload inside the container. The test passed and nothing changed. A later edit with a typo that broke the map line also passed nginx -t, which should have been impossible.

The container was not reading our file at all. Docker bind-mounts a single file by its inode. By default rsync writes a temporary file and renames it over the target, which creates a new inode at the same path. The container’s mount still pointed at the old inode, so the old file kept living inside the container and nginx kept testing the old, valid config. You can see it directly:

docker exec ailog-web sed -n 3p /etc/nginx/conf.d/default.conf   # still the old line

There are two fixes, and we use both. rsync --inplace rewrites the existing file instead of replacing it, so the inode survives. The deploy script also restarts the container instead of only reloading nginx, because a restart re-resolves the bind mount. Mounting a directory instead of a single file also avoids the issue. The broader lesson: when a config reload “succeeds” and nothing changes, check what the process can actually see before you debug the config.

Checklist if you are doing the same swap

  • Read the tunnel’s actual origin from the cloudflared logs or dashboard before planning. If it targets a Compose service name, an alias swap needs no tunnel change.
  • Stage the new container without the alias, on a loopback-only port, and curl one URL from every route class: home, article, static file, retired URL, redirect, unknown URL.
  • Never let two containers share the alias. Stop the old one first, and disable its restart policy so a reboot cannot resurrect it.
  • Back up the database and uploads before stopping anything, and keep the old containers and volumes until the new site has been live for a while.
  • Return 410 for content you have retired on purpose, 301 for things that moved, and submit the new sitemap.
  • Add volatile to any map you test again inside an error_page target.
  • Sync bind-mounted single files with rsync --inplace, or mount directories, and verify from inside the container.
Sources
  1. Cloudflare — Cloudflare Tunnel overview
  2. nginx — ngx_http_map_module (volatile parameter)
  3. Docker — Networking overview (network aliases)
  4. Astro — Content collections
  5. Google Search Central — HTTP status codes and how they affect Google Search

Related