From Port Forwarding to Cloudflare Tunnel: Moving a Ghost Blog on a Synology NAS
-
Jason Yang - 04 Jul, 2026
- Updated 04 Jul, 2026
- Views —
I run a blog on a Synology NAS at home. Ghost, MySQL, and a visitor-analytics service all run in Docker on the NAS.
Until recently I exposed it the usual way, with port forwarding:
- DDNS points at my home public IP.
- The router forwards ports 80 and 443 to the NAS.
- Synology DSM’s reverse proxy passes requests to the Ghost container.
It worked, but it had downsides:
- I had to open inbound ports on the router.
- My home public IP was exposed through the blog domain’s DNS.
- I had to manage DDNS, certificates, and the DSM reverse proxy together.
This time I moved just the blog to Cloudflare Tunnel.
This post covers:
- How I added
cloudflaredto Docker Compose - A connection problem caused by Docker networking
- What confused me in the Public Hostname and DNS setup
- How I removed the old port-forwarding path and verified it
- How I served
ads.txtwith a free Cloudflare Worker
Why Cloudflare Tunnel
With Cloudflare Tunnel, cloudflared on the NAS connects out to Cloudflare first.
It doesn’t wait for inbound connections from the outside. The NAS makes an outbound connection to Cloudflare, and visitor requests are routed to the internal service over that connection.
So you don’t need to open inbound ports on the router to publish the blog. Cloudflare’s own docs describe Tunnel as an outbound connection and note that you can publish internal services without inbound ports or firewall changes.
The old setup looked like this:
Visitor
↓
Home public IP
↓
Router port forwarding
↓
DSM reverse proxy
↓
Ghost container
After moving to Cloudflare Tunnel it works like this:
Visitor
↓
Cloudflare
↓
Cloudflare Tunnel
↓
cloudflared container
↓
Ghost container
For the blog specifically, this changes:
- No router port forwarding is needed for the blog.
- The blog domain no longer points directly at my home IP.
- Cloudflare forwards requests without going through DDNS.
- TLS certificates are handled on the Cloudflare side.
- The Ghost container’s port doesn’t need to be exposed on the NAS host.
That said, using Cloudflare Tunnel doesn’t make your home public IP disappear entirely.
If another DDNS domain or NAS service still points at your home IP, the IP can be discovered through that path. What this work hid is the home IP that the blog domain used to point at directly.
[Cloudflare] Create the tunnel
First, create a tunnel in Cloudflare and get a run token. This token goes into the cloudflared container later.
You create the tunnel here:
Cloudflare dashboard
→ Networking → Tunnels
→ Create a tunnel
The first time through, you’ll onboard the free Zero Trust plan. It’s the $0 plan and needs no card.
On the tunnel creation screen I went through these steps:
- Choose Cloudflared as the connector type.
- Name the tunnel. I named mine
myhome-nas, after the NAS hostname. - Choose Docker as the environment.
You’ll then see a run command like this:
docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token eyJhI....
All you need is the token string after --token. Since I’ll run this with Docker Compose, I don’t use the docker run command as-is — I just copy the token.
[NAS] Add cloudflared to Docker Compose
These containers already ran on the same Docker bridge network:
- Ghost
- MySQL
- traffic-analytics
I added one cloudflared service:
# docker-compose.yml
services:
# ghost / mysql / traffic-analytics ... (existing services)
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
restart: unless-stopped
depends_on:
- ghost
networks:
- ghost_network
The most important part is this:
networks:
- ghost_network
cloudflared and Ghost must be on the same Docker network so that cloudflared can reach Ghost by its internal Docker name.
I saved the run token I copied earlier into .env:
# .env
TUNNEL_TOKEN=eyJhI....
When the tunnel is Healthy but the site returns 502
Leaving this setting out of cloudflared causes trouble:
networks:
- ghost_network
Without it, the tunnel shows as Healthy in the Cloudflare dashboard, but the blog itself won’t load properly.
Here’s the thing to know:
A Healthy tunnel status only confirms the connection between cloudflared and Cloudflare.
It doesn’t mean cloudflared can reach Ghost, MySQL, or other internal services. Cloudflare’s docs also note that tunnel status doesn’t reflect internal service connectivity, so you can have a Healthy tunnel and still fail to reach your application.
When cloudflared can’t reach the configured internal service, you usually get:
502 Bad Gateway
Unable to reach the origin service
A 502 doesn’t mean the tunnel is down — it means cloudflared can’t connect to the internal service like Ghost.
Putting cloudflared on the same network as Ghost resolves this.
The service address I entered was:
http://ghost1:2368
This is not the NAS IP or a host-exposed port:
ghost1: the container name reached within the Docker network2368: the port Ghost uses inside the container
Even if you previously exposed Ghost with a host mapping like 8080:2368, the Service URL still uses the internal port 2368, not 8080.
[NAS] Run cloudflared
After adding the config, I started the containers on the NAS:
root@nas:~/docker/ghost# docker compose up -d
[+] Running 5/5
✔ Container ghost-mysql Running
✔ Container traffic-analytics Running
✔ Container ghost1 Running
✔ Container cloudflared Started
I checked the logs too:
root@nas:~/docker/ghost# docker compose logs -f cloudflared
cloudflared | INF Starting tunnel tunnelID=e17793f2-...
cloudflared | INF Version 2026.6.1
cloudflared | INF Registered tunnel connection connIndex=0 location=icn05 protocol=quic
cloudflared | INF Registered tunnel connection connIndex=1 location=icn06 protocol=quic
cloudflared | INF Registered tunnel connection connIndex=2 location=icn01 protocol=quic
cloudflared | INF Registered tunnel connection connIndex=3 location=icn06 protocol=quic
cloudflared | INF +-----------------------------------------------------------+
cloudflared | INF | CONNECTIVITY PRE-CHECKS |
cloudflared | INF +-----------------------------------------------------------+
cloudflared | INF | COMPONENT STATUS DETAILS |
cloudflared | INF | DNS Resolution PASS DNS Resolved successfully |
cloudflared | INF | UDP Connectivity PASS QUIC connection successful |
cloudflared | INF | TCP Connectivity PASS HTTP/2 connection successful |
cloudflared | INF | Cloudflare API PASS API is reachable |
cloudflared | INF | SUMMARY: Environment is healthy. Using 'quic'. |
cloudflared | INF +-----------------------------------------------------------+
In my case, four connections registered to Cloudflare’s Seoul data centers.
The log ended with Environment is healthy. Using 'quic'., confirming the NAS-to-Cloudflare path is up. As covered above, that’s separate from whether Ghost itself is reachable — test the blog URL for that. (You may also see a harmless receive buffer size warning; see the appendix.)
[Cloudflare] Set up Public Hostname
This is configured in the Cloudflare dashboard, not on the NAS. You go into the tunnel you created and register hostnames; requests to those addresses are forwarded through the tunnel to the containers on the NAS.
Cloudflare dashboard
→ Networking → Tunnels
→ select the tunnel
→ Public Hostname (or Routes → Add route → Published application)
The blog previously used three hostnames on the DSM reverse proxy:
- the main blog domain
- the
wwwdomain - a
tinybirdsubdomain used for visitor analytics
I registered the same hostnames on Cloudflare Tunnel:
| Public Hostname | Service URL |
|---|---|
example.com | http://ghost1:2368 |
www.example.com | http://ghost1:2368 |
tinybird.example.com | http://traffic-analytics:3000 |
The Service URL must include the scheme:
http://ghost1:2368
You can’t enter it like this:
ghost1:2368
tinybird.example.com points at the separate analytics container, not Ghost.
This isn’t an address that only the Ghost container uses internally. It’s an analytics endpoint called directly from the visitor’s browser, so I registered it as a public hostname.
[Cloudflare] Set the DNS record Target
When you save a Public Hostname, Cloudflare automatically creates the DNS record for that hostname.
At this point the DNS screen shows the record type as Tunnel, with the tunnel’s name in the Target field. At first this made me think the DNS was misconfigured.
The routing works even in this state. But the display was confusing, so I set each record’s Target to the tunnel address directly to make it explicit.
The tunnel address has this form:
<tunnel-id>.cfargotunnel.com
You can find the tunnel ID (UUID) on the tunnel’s Overview page. Mine was:
e17793f2-...-57cd02a95a53.cfargotunnel.com
On the DNS Records screen I edited each record, set the Type to CNAME and the Target to the address above, and saved it as Proxied. All three records point to the same tunnel address:
| Name | Type | Target |
|---|---|---|
example.com | CNAME | <tunnel-id>.cfargotunnel.com |
www.example.com | CNAME | <tunnel-id>.cfargotunnel.com |
tinybird.example.com | CNAME | <tunnel-id>.cfargotunnel.com |
All three hostnames use the same single tunnel, and each request is routed to a different container depending on the hostname.
One more thing to check: if you already have an A, AAAA, or CNAME record with the same name, it can conflict during save. In that case, check for and clean up the existing DNS record first.
[NAS] Check the pushed ingress config
You can confirm from the cloudflared logs that the dashboard config was pushed correctly:
root@nas:~/docker/ghost# docker compose logs cloudflared | grep ingress
cloudflared | INF Updated to new configuration
config="{\"ingress\":[{\"hostname\":\"example.com\",
\"service\":\"http://ghost1:2368\"},{\"service\":\"http_status:404\"}]}"
Here’s what this log tells you:
hostname: example.com
service: http://ghost1:2368
That is, requests to example.com are forwarded to port 2368 on the Ghost container.
At the end there’s this rule:
http_status:404
It means any request that doesn’t match a hostname rule is handled as 404.
Verifying from my desktop
First I checked that DNS returns Cloudflare addresses, not my home public IP:
jason@desktop:~$ dig +short example.com
104.21.32.211
172.67.187.123
Both addresses are Cloudflare proxy edge IPs.
I checked the response headers too:
jason@desktop:~$ curl -sI https://example.com | grep -i cf-ray
cf-ray: a15ea9487c3195a2-YVR
jason@desktop:~$ curl -sI https://www.example.com | grep -i cf-ray
cf-ray: a15ec311efc658f2-YVR
The presence of the cf-ray header means the response went through Cloudflare’s network.
That said, dig results and the cf-ray header alone don’t fully prove you’re using Cloudflare Tunnel — you’d get the same result with a regular Cloudflare proxy.
For the tunnel path, verify these together:
- The Public Hostname’s Service URL is set to
http://ghost1:2368. - The
cloudflaredlog shows that ingress config being pushed. - The blog still loads after you remove the DSM reverse proxy rules.
- The blog still loads after you remove Ghost’s host port mapping.
[NAS] Remove the old DSM reverse proxy
After confirming the blog loaded through the tunnel, I deleted these three rules from the DSM reverse proxy:
example.comwww.example.comtinybird.example.com
Then I removed the host port mappings for Ghost and analytics in Docker Compose. I commented them out rather than deleting them:
ghost:
image: ghost:6.42.0
container_name: ghost1
restart: always
# ports:
# - "8081:2368" # remove host exposure
traffic-analytics:
image: ghost/traffic-analytics:1.0.20
container_name: traffic-analytics
restart: always
# ports:
# - "8082:3000" # remove host exposure
Removing ports leaves the container-internal ports (2368, 3000) intact, but they’re no longer exposed on the host.
Now docker ps shows only the container-internal ports:
root@nas:~/docker/ghost# docker ps | grep -E 'ghost1|traffic-analytics'
... ghost:6.42.0 ... 2368/tcp ghost1
... ghost/traffic-analytics:1.0.20 ... traffic-analytics
Here 2368/tcp means Ghost is using the port inside the container.
There’s no host port mapping shown like this:
0.0.0.0:8081->2368/tcp
So a normal LAN device can’t reach Ghost directly at:
http://NAS-IP:8081
But a container on the same Docker network, like cloudflared, can still reach Ghost.
So rather than “the container disappeared from the LAN entirely,” this is more accurate:
Ghost’s port is not exposed on the NAS host, and external blog requests come in through Cloudflare Tunnel.
How I handled ads.txt
If you use AdSense, it’s good to serve ads.txt at the site’s root:
https://example.com/ads.txt
ads.txt isn’t strictly required, but Google strongly recommends it. It helps ad buyers verify authorized sellers and identify fake ad inventory.
At first I thought Ghost couldn’t serve a root file. It turns out Ghost can — you place the file in the theme’s root directory and re-upload the theme. (I’ve put the details of that approach in the appendix.)
I chose not to do it that way. I wanted to keep ads.txt separate from the theme, so I don’t have to re-check and re-upload it every time I update or swap themes. Instead, I served it with a Worker that handles only /ads.txt requests at the Cloudflare edge.
[Cloudflare] A Worker instead of Snippets
My first idea was Cloudflare Snippets.
But Snippets are currently included in paid plans like Pro, Business, and Enterprise, and aren’t available on the Free plan.
On the free plan you can use a Cloudflare Worker.
The Workers Free plan provides 100,000 requests per day. That’s plenty for serving one small static file like ads.txt.
The Worker code is:
export default {
async fetch(request) {
return new Response(
"google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0\n",
{
status: 200,
headers: {
"content-type": "text/plain; charset=utf-8",
},
}
);
},
};
Replace the pub-XXXXXXXXXXXXXXXX part with your own AdSense publisher ID.
After deploying the Worker, I added this Route:
example.com/ads.txt
I applied the Worker only to /ads.txt requests, not to all blog requests.
After setup I verified with:
jason@desktop:~$ curl https://example.com/ads.txt
google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0
I set the Workers Route’s failure mode to Fail open, and since the Route applies only to /ads.txt, even a Worker problem won’t affect normal blog pages. (More on the failure modes in the appendix.)
Result
The result:
- The blog domain no longer points directly at my home public IP.
- No inbound port forwarding is needed for the blog.
- I removed the DSM reverse proxy rules for the blog.
- I removed the host port mappings for Ghost and analytics.
- Ghost is reachable only from within the Docker network.
ads.txtis served by a free Cloudflare Worker.- Port forwarding for other NAS services is still in place.
Wrap-up
Moving to Cloudflare Tunnel means I no longer need to keep router ports open for the blog.
Before, I had to keep track of DDNS, port forwarding, the DSM reverse proxy, and container port mappings. Now I only need to check the Cloudflare Public Hostname and the Docker internal network.
This doesn’t automatically solve every home-server security concern. If other NAS services are still exposed via port forwarding, those need to be reviewed separately.
Still, moving public web services to Cloudflare Tunnel one at a time reduces the ports you leave open to the outside and keeps each service’s connection path simpler to manage.
Appendix: Serving ads.txt from the Ghost theme
According to Ghost’s official docs, you can serve ads.txt at /ads.txt by placing the file in the active theme’s root directory and re-uploading the theme.
The theme structure looks like this:
my-theme/
├── assets/
├── partials/
├── default.hbs
├── index.hbs
├── package.json
└── ads.txt
This works fine. The reason I didn’t use it is upkeep: every time you update or swap the theme, you have to make sure ads.txt is still in the theme root and re-upload it. Serving it from a Worker keeps ads.txt independent of the theme.
Appendix: Notes and edge cases
Getting a 404 instead of a 502. A 404 means the tunnel reached an origin but the request didn’t match a hostname rule. Check:
- Whether the requested hostname matches a Public Hostname
- Whether the ingress config was pushed correctly
- Whether the request fell through to the final
http_status:404rule
HTTP vs HTTPS origins. The Service URL uses http:// because Ghost speaks plain HTTP inside Docker. Cloudflare Tunnel isn’t limited to HTTP, though — if your internal service runs over HTTPS, use https://. A Published Application can target both HTTP and HTTPS internal services.
The receive-buffer warning. In the cloudflared logs you may see failed to sufficiently increase receive buffer size. In my case all four QUIC connections still registered and requests were handled fine. If you have heavy traffic or QUIC connections dropping often, check the host’s UDP buffer settings rather than ignoring it.
Fail open vs Fail closed. If you exhaust the Workers free quota, Fail open skips the Worker and passes the request to the origin, while Fail closed returns a Cloudflare error page. With Fail open, note that if the Worker is bypassed and Ghost doesn’t serve ads.txt from the theme, the /ads.txt request itself may fail.
Checking response headers. Beyond the body, curl -i https://example.com/ads.txt should return HTTP/2 200 with content-type: text/plain; charset=utf-8.