Ghost blog is now hosted on main domain

I've finally succeeded in moving our blog to the main domain, which is better for SEO.

We used to have cloud hosting of our Img.vision blog the "Img Handbook".

The last day of 2019 I tried to move the subdomain to the main domain for the SEO benefits it has. I failed back then.

But now I succeeded! 💪

We are using Cloudflare Workers as a Reverse Proxy & a Digital Ocean droplet of just $5.

Here's the detailed journey:

  • Setup DigitalOcean droplet with self-hosted Ghost CMS & Nginx
  • Migration from Ghost (Pro) cloud hosting to self hosted droplet
  • Configured Cloudflare Workers as Reverse Proxy to the droplet
  • Setup updown.io uptime monitoring on droplet
  • Restricted direct access to droplet to Cloudflare, updown & me
  • Setup 301 redirects (had to remove DNS to Ghost Pro first)
  • Initiated site move in Google Search Console


  1. 1

    For anyone reaching this post - I've now also set up Ghost like this, and as a celebration of doing so, wrote a full guide: https://cloak.ist/blog/how-to-put-a-ghost-blog-at-a-subdirectory-using-cloudflare-workers/

  2. 1

    Hi Mathias, I'm really struggling to do this. Do you have any more tips?

    So far:

    • Set up DigitalOcean 1-Click Ghost Droplet
    • Didn't set it up at my URL, just set it up at the IP address for now
    • Set up Cloudflare worker (using the script you linked to) to point /blog* on my website to the IP of the droplet
    • Get a Cloudflare 1003 error

    Any more tips greatly appreciated! Really want to get this working.

    1. 2

      OK @mathias458, actually I finally figured out how to do this. Really interested in this step of yours though:

      Restricted direct access to droplet to Cloudflare, updown and me

      How did you do that?

      Update: I did this using a Cloudflare Firewall rule with URI Full contains 'ghost.cloak.ist'. So I think I've finally got my dream blog set up.

      1. 1

        Hey, nice to see someone try out the steps I did.

        Check out this article regarding limiting access: https://www.digitalocean.com/community/tutorials/how-to-set-up-a-firewall-with-ufw-on-ubuntu-18-04

        1. 1

          Hey @Mathias458, @louisbarclay

          I followed both of your guides. My main website (polymersearch.com) is pointed at some page builder. I want to have polymersearch.com/blog serving the ghost blog, hosted on DO.

          I've set-up the blog at blog.polymersearch.com/blog and CloudFlare workers as reverse proxy.

          However I'm still not being able to access the ghost blog on polymersearch.com/blog

          1. 1

            Hi @ribice

            Here are the steps:

            1. Setup ghost blog on alternativeDomain.com or alternativeSubdomain.domain.com

            2. Add Cloudflare Worker script (provided below)

            3. Change your ghost installation url using cli to where you want to show it, example:

            ghost config url https://preferredSite.com/blog/

            Then restart ghost

            1. Go to Cloudflare, open your domain, go to Workers tab, and add a route, example:

            https://preferredSite.com/blog/* -> to your cloudflare worker script

            Make sure the domain preferredSite.com is "orange" on the DNS tab, so proxied by Cloudflare, so that Cloudflare can manipulate the traffic.

            1. You can now access your ghost at:

            Here is the Edge worker script, replace "alternativeDomain.com" & "preferredSite.com":

            // Website you intended to retrieve for users.
            const upstream = 'alternativeDomain.com'
            // Custom pathname for the upstream website.
            const upstream_path = '/'
            // Website you intended to retrieve for users using mobile devices.
            const upstream_mobile = 'alternativeDomain.com'
            // Countries and regions where you wish to suspend your service.
            const blocked_region = []
            // IP addresses which you wish to block from using your service.
            const blocked_ip_address = []
            // Whether to use HTTPS protocol for upstream address.
            const https = true
            // Whether to disable cache.
            const disable_cache = false
            // Replace texts.
            const replace_dict = {
                '$upstream': '$custom_domain',
                '//alternativeDomain.com': ''
            addEventListener('fetch', event => {
            async function fetchAndApply(request) {
                const region = request.headers.get('cf-ipcountry').toUpperCase();
                const ip_address = request.headers.get('cf-connecting-ip');
                const user_agent = request.headers.get('user-agent');
                let response = null;
                let url = new URL(request.url);
                let url_hostname = url.hostname;
                if (https == true) {
                    url.protocol = 'https:';
                } else {
                    url.protocol = 'http:';
                if (await device_status(user_agent)) {
                    var upstream_domain = upstream;
                } else {
                    var upstream_domain = upstream_mobile;
                url.host = upstream_domain;
                if (url.pathname == '/') {
                    url.pathname = upstream_path;
                } else {
                    url.pathname = upstream_path + url.pathname;
                if (blocked_region.includes(region)) {
                    response = new Response('Access denied: WorkersProxy is not available in your region yet.', {
                        status: 403
                } else if (blocked_ip_address.includes(ip_address)) {
                    response = new Response('Access denied: Your IP address is blocked by WorkersProxy.', {
                        status: 403
                } else {
                    let method = request.method;
                    let request_headers = request.headers;
                    let new_request_headers = new Headers(request_headers);
                    new_request_headers.set('Host', upstream_domain);
                    new_request_headers.set('Referer', url.protocol + '//' + url_hostname);
                    let original_response = await fetch(url.href, {
                        method: method,
                        headers: new_request_headers
                    let original_response_clone = original_response.clone();
                    let original_text = null;
                    let response_headers = original_response.headers;
                    let new_response_headers = new Headers(response_headers);
                    let status = original_response.status;
            	    if (disable_cache) {
                		new_response_headers.set('Cache-Control', 'no-store');
                    new_response_headers.set('Access-Control-Allow-Origin', 'https://*.preferredSite.com');
                    new_response_headers.set('Vary', 'Origin');
                    new_response_headers.set('Host', 'alternativeDomain.com');
                    new_response_headers.set('X-Ghost-Host', 'alternativeDomain.com');
                    new_response_headers.set('X-Forwarded-host', 'preferredSite.com');
                    new_response_headers.set('X-Forwarded-Proto', 'https');
                    new_response_headers.set('X-Forwarded-For', ip_address);
                    if(new_response_headers.get("x-pjax-url")) {
                        new_response_headers.set("x-pjax-url", response_headers.get("x-pjax-url").replace("//" + upstream_domain, "//" + url_hostname));
                    const content_type = new_response_headers.get('content-type');
                    if (content_type != null && content_type.includes('text/html') && content_type.includes('UTF-8')) {
                        original_text = await replace_response_text(original_response_clone, upstream_domain, url_hostname);
                    } else {
                        original_text = original_response_clone.body
                    response = new Response(original_text, {
                        headers: new_response_headers
                return response;
            async function replace_response_text(response, upstream_domain, host_name) {
                let text = await response.text()
                var i, j;
                for (i in replace_dict) {
                    j = replace_dict[i]
                    if (i == '$upstream') {
                        i = upstream_domain
                    } else if (i == '$custom_domain') {
                        i = host_name
                    if (j == '$upstream') {
                        j = upstream_domain
                    } else if (j == '$custom_domain') {
                        j = host_name
                    let re = new RegExp(i, 'g')
                    text = text.replace(re, j);
                return text;
            async function device_status(user_agent_info) {
                var agents = ["Android", "iPhone", "SymbianOS", "Windows Phone", "iPad", "iPod"];
                var flag = true;
                for (var v = 0; v < agents.length; v++) {
                    if (user_agent_info.indexOf(agents[v]) > 0) {
                        flag = false;
                return flag;


            Disclaimer: Using this config your ghost is proxied to a path, but I didn't succeed in getting the members functionality to work. The Send Magic Link fails with 400 Bad Request. This means no visitors can subscribe or login, admin panel is not affected.

            I tested thoroughly and found this reverse proxy is the reason Send Magic Link doesn't work, it works fine before/while not reverse proxying.

            If someone can solve this, let me know.

          2. 1

            Hey @ribice, sorry about that. Feel free to email me (find email on my profile) and I'll happily help you out. Quite confused about why this would be the case - is the route for your Cloudflare worker definitely correct? Also - is there any Page Rule that could be overriding the worker route? Let's discuss over email!

            1. 1

              Hey @louisbarclay I provided exact steps I use above.

              Did you manage to solve members signup/login?
              No email sent trying to sign up as a visitor.

  3. 1

    Those that are interested in doing a Reverse Proxy with Cloudflare Workers, check out this open source code: https://github.com/Berkeley-Reject/Workers-Proxy

    1. 1

      I see the url is gone now, I provided script above.

Trending on Indie Hackers
I bootstrapped GrowSurf, a B2B SaaS, to $30k MRR. AMA. 13 comments I bootstrapped an encyclopedia on nutrition to 7-figure ARR — AMA! 13 comments Do you play a musical instrument? 9 comments Wasted 28 days building an app 8 comments Guide to Analytics for Startups 6 comments I'll code your design for free 2 comments