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.
- 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 aliasghostand listens on port 2368. - Exactly 100 old URLs (posts, tags, authors) now return
410 Gonefrom an nginxmap; the old RSS and sitemap paths redirect to the new ones. - Two traps cost us time: nginx caches
mapresults for the whole request (the custom error page never rendered until we addedvolatile), andrsyncsilently 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:
- Build and upload the new site, and test it in a separate staging container published only on
127.0.0.1:2369(no alias), withcurlagainst every route class. docker stop ghost-blog, thendocker compose up -dfor the new container.- 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:
- A request for an old slug sets
$ailog_goneto 1, andreturn 410fires. error_page 410 /404.htmlperforms an internal redirect to/404.html.- The server-level
if ($ailog_gone)runs again for the redirected request. The variable is still the cached1, even though$uriis now/404.html. - 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
cloudflaredlogs 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
curlone 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
volatileto anymapyou test again inside anerror_pagetarget. - Sync bind-mounted single files with
rsync --inplace, or mount directories, and verify from inside the container.