Make your own Blog with Ghost

Do you need a self-hosted blog or home page? Off course – we all need them, and a lot of them. Ghost is my favorite, both as hosted and self-hosted. I prefer to run it in Docker or Kubernetes.

Make your own Blog with Ghost
Photo by Carlos Nunez / Unsplash

Have you ever considered creating your own blog? Have you tried WD and found it to be a great choice, but also somewhat cumbersome and complicated?
Well, I did, and that's how I found Ghost.

Ghost is not to compare with WordPress, but it's great for the personal blog and a little more. You might like it to be hosted (you can easily use Ghost's services) or self-host the blog. This blog is about how to self-host.

  • Complete control over your website and your branding.
  • Launch a custom website and tweak it to perfectly match your brand and style.
  • Hundreds of custom themes in the Ghost marketplace, or you can build your own completely custom design from scratch.
  • Your unique brand sits front and center, while Ghost runs things in the background.

Prerequisites

You need to buy a domain if you do not already have one, and you need to control your DNS to point the blog name to your system. Other things to consider:

  • A fixed IP is ideal. A fix could be to use a DDNS service.
  • You should understand networking and network security.
  • You need a firewall to control what comes in and goes out of your network.
  • Use a Revers Proxy to redirect the stream
  • Use some security system if you like it to be private, Authelia, Authentic …
  • What OS to use, Alpine – Ubuntu, it's your choice.
  • Estimate the size you will need for the files associated with the blog

Set up your system

There is a lot of choice how to do it as always on Linux. You can run Ghost on real rust, in a VM or in Docker. Ghost can utilize the power of a Database, but you can use it without in development mode.

Installation

Docker Installation

We will use the Docker approach running in an Alpine VM.

Set up the VM – Alpine 3.15 and newer

Grab a copy of the latest Alpine ISO. Create a VM and install all the parts you require. Here we only concentrate on the basic system required for the blog.

The basic VM will have: 1 vCPU, 1 GiB RAM and 8 GiB for the system and 32 GiB for the data disk. It runs Docker, Dockge, or Portainer. For the purposes of this blog, we assume that the RPM, authentication, and firewall applications are running in another VM on any of our systems.

Adding the community repo

If you need things like the QEMU-Guest-Agent or sudo, you need to activate the community repo. Edit the file /etc/apk/repositories and remove the # in front of the …/community lines.

Adding QEMU-Guest-Agent service

Use doas or sudo if not running as root.

  • Add the package: apk add qemu-guest-agent
  • Start the service: rc-service qemu-guest-agent start
  • Enable the service on startup: rc-update add qemu-guest-agent
  • Restart the service: rc-service qemu-guest-agent restart

Adding a disk

Format a disk: e.g., by fdisk /dev/sdb add a partition by hitting n and p 1 and enter to the rest, then hit w to write to the disk.

Create a file system on the new disk mkfs.ext4 /dev/sdb1 and doas reboot.

Add a disk to fstab. Find the UUID by blkid and vi/nano etc/fstab, add it as UUID=<UUID> /srv ext4 defaults 0 2. Change /srv to something and the 2 means it's required.

Expanding a disk. First expand the allocated disk in your VM and then SSH in to the VM and do.

  1. First sudo growpart /dev/sdb 1 --update auto
  2. and then sudo resize2fs /dev/sdb1
  3. Use df -h to check for success

Install Docker

Docker packages are in the community repo

  • Install Docker-ce: apk add docker
  • Add a user to the docker group: addgroup username docker
  • Start at boot: rc-update add docker default and service docker start
  • Docker compose: apk add docker-cli-compose

Docker rootless allows unprivileged users to run the docker daemon and docker containers in user namespaces.

  • apk add docker-rootless-extras and rc-update add cgroups
    • see the Docker documentation for the rest
  • You might encounter this message when executing docker info.
    To correct this situation, you must configure cgroups properly.
  • Create your external networks, one for external and one for internal traffic.
    • docker network create -d bridge <external name>
    • docker network create -d bridge <internal name>
  • Make the generic folders you need for your stacks

Install Dockge

Dockge is my favorite, but there is thing you benefit from also having Portainer.

doas docker run -d -p 5001:5001 --name Dockge --restart=unless-stopped -v /var/run/docker.sock:/var/run/docker.sock -v /home/$USER/docker/dockge/data:/app/data -v /home/$USER/docker/stacks:/home/$USER/docker/stacks -e DOCKGE_STACKS_DIR=/home/$USER/docker/stacks louislam/dockge:latest

Dockge stores the compose files in docker/stacks/

Portainer

First, create the volume that Portainer Server will use to store its database:

docker volume create portainer_data

Then, download and install the Portainer Server container:

docker run -d -p 8000:8000 -p 9443:9443 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:latest

HTTP port 9000 open for legacy reasons, add : -p 9000:9000 to your docker run command

# If you use Dockge to install it
version: "3.3"
services:
  portainer-ce:
    ports:
      - 8000:8000
      - 9443:9443
    container_name: portainer
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - portainer_data:/data
    image: portainer/portainer-ce:latest
volumes:
  portainer_data: {}
networks: {}

Point your browser to https://<ServerIP>:9443 and create your admin user.

Portainer stores the docker-compose files at /var/lib/docker/volumes/portainer_data/_data/compose/


Install Ghost

Generic files to use with Dockge/Portainer or use as docker compose -d

The Compose

Change the ports according to your needs, and replace the generic "blog" with the name of the site you are creating for the blog.

version: "3.8"
services:
  db:
    image: mysql:8.0
    container_name: ghost-db
    restart: unless-stopped           # always
    environment:
      MYSQL_ROOT_PASSWORD: ${rootpassword}
      MYSQL_DATABASE: ${database}
      MYSQL_USER: ${user}
      MYSQL_PASSWORD: ${password}
    volumes:
      - /srv/db_mysql:/var/lib/mysql
    networks:
      - backend
  ghost:
    image: ghost:alpine
    container_name: blog # or the name of the blog
    restart: unless-stopped #always
    ports:
      - 2368:2368
    environment:
      database__client: ${client}
      database__connection__host: ${host}
      database__connection__user: ${user}
      database__connection__password: ${password}
      database__connection__database: ${database}
      url: ${url}
      admin: ${admin}
      NODE_ENV: ${mode}
    volumes:
      - /srv/ghost/content:/var/lib/ghost/content
    networks:
      - frontend
      - backend
networks:
  backend:
    external: true
  frontend:
    external: true

The .env

Replace with your info, see this link. Contrary to the default mentioned in the linked documentation, This image defaults to PRODUCTION, if you want to use the development mode you need to set the parameter in the .env file and MySQL isn't needed, lightsql is used by default.

Creating password 20 - 40 are usually fine for home use:

openssl rand -base64 32
  • Run in development mode add
    • mode= development
  • url: use something like
    • http://localhost:2368
    • localhost:8080
    • https://blog.example.com
  • move admin to other url, add
    • admin: https://ghostbuster.example.com
client: mysql
host: db
user: <user name>
password: ogDXCux5ZrqwA0Rh7QAPgcUvDAB5US
rootpassword: 1nlKQaR8duD0wy+1oJYfoIJJOxBXpAKWbQb5vgVUk44=
database: ghost
url: http://localhost:2368

Testing

Activate the port setting in your compose. Set up an SSH tunnel from your machine to the VM ssh -L 8888:<VM IP>:2368 <user>@<VM IP> and point your browser to http://localhost:2368


Add-ons to your setup

Setup with a personal logo (600pxx72px) and a Favicon (60pxx60px). You can also set the Background and Highlight Colors.

Nginx Proxy Manager NPM:

  • Expose your private network Web services and get connected anywhere.
  • Built in Let’s Encrypt support allows you to secure your Web services at no cost to you. The certificates even renew themselves!
version: '3.8'
services:
  app:
    image: 'jc21/nginx-proxy-manager:latest'
    restart: unless-stopped
    ports:
      - '80:80'
      - '81:81'
      - '443:443'
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt

Example configuration

Authentication with 2FA/MFA or Single Sign-on

Hosting secured services calls for an authentication tool like Authelia, Authentik or Traefik.

Code Injection – Theme Source

Some things can be manipulated by inserting code into the Main body, Header and/or the Footer area. Here is code I use for TOC, Share to Social Media and bash code layout. I use many more js snippets

In the header

<!-- stylesheet Shades of Purple -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism-themes/1.9.0/prism-shades-of-purple.min.css" integrity="sha512-HM2OlrR8saZI2M4q2qLhGTYpsV8Oh6ktoHraqnYws8D06R7T8a6zIXi/denZBRNxXGHAgpKbIQA3gbz9rQQuuQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />


<!-- Share on Social -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />


<!-- Table of Contents -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.28.2/tocbot.min.js" integrity="sha512-+9XzRSJjnUN2OI106uAbbVZ3f+z1ncIRZFOr56hEdaxbQeZ8i1+B7OVjdF8tG4YhgxM/rWP73K2SiG93x6UJoQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.28.2/tocbot.css" integrity="sha512-Di7Va5KC5NtXyMi+aEyVe2pUnniyhFoxfCrdCAOj8aSA42Te/bWKTz0iumaj5v1sN1nZdsaX8QTCn0k1nN4aLA==" crossorigin="anonymous" referrerpolicy="no-referrer" />

<<style>
  .gh-content {
    position: relative;
  }

  .gh-content a {
    text-decoration: none;
  }

  .gh-toc > .toc-list {
    position: relative;
  }

  .toc-list {
    overflow: hidden;
    list-style: none;
  }

  @media (min-width: 1300px) {
    .toc-sidebar {
      position: absolute;
      top: 0;
      bottom: 0;
      margin-top: 4vmin;
      grid-column: full-start / main-start;
      margin-left: 2vmin;
      margin-right: 4vmin;
      z-index: 99;
    }

    .gh-toc {
      position: sticky; /* On larger screens, TOC will stay in the same spot on the page */
      top: 4vmin;
    }
  }

  @media (min-width: 1600px) {
    .toc-sidebar {
      margin-left: 16vmin;
    }
  }

  .gh-toc .is-active-link::before {
    background-color: var(--ghost-accent-color); /* Defines TOC   accent color based on Accent color set in Ghost Admin */
    font-weight: bold;
  }
</style>
<!-- Copy to Clipboard -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js" integrity="sha512-/kVH1uXuObC0iYgxxCKY41JdWOkKOxorFVmip+YVifKsJ4Au/87EisD1wty7vxN2kAhnWh6Yc8o/dSAXj6Oz7A==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>


<!-- Table of Contents -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.28.2/tocbot.min.js" integrity="sha512-+9XzRSJjnUN2OI106uAbbVZ3f+z1ncIRZFOr56hEdaxbQeZ8i1+B7OVjdF8tG4YhgxM/rWP73K2SiG93x6UJoQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
    tocbot.init({
        // Where to render the table of contents.
        tocSelector: '.gh-toc',
        // Where to grab the headings to build the table of contents.
        contentSelector: '.gh-content',
        // Which headings to grab inside of the contentSelector element.
        headingSelector: 'h1, h2, h3, h4',
        // Ensure correct positioning
        hasInnerContainers: true,
    });
</script>

Share on Social Media is called by:

<style>
.share-button {
  background-color: #b966bb;
  color: #FFFFFF;
  border: 1px solid #00BFFF;
  padding: 15px 40px;
  border-radius: 20px;
  cursor: pointer;
  font-size: 18px;
  font-weight: bold;
}

.share-button:hover {
  background-color: #b966bb;
  color: #000000;
  border-color: #00BFFF;
}

.share-icons {
  display: none;
  flex-wrap: wrap;
  justify-content: center;
  margin-top: 20px;
}

.share-icons.show {
  display: flex;
}

.share-icons button {
  display: inline-block;
  width: 40px;
  height: 40px;
  text-align: center;
  padding: 0;
  margin: 5px;
  border-radius: 50%;
  background-color: #b966bb;
  border: none;
  cursor: pointer;
  font-size: 20px;
  line-height: 40px;
  color: #FFFFFF;
}

.share-icons button i {
  color: #FFFFFF;
}
</style>

<button id="share-button" class="share-button" onclick="toggleShareDropdown()">
  Share this Blog Post on Social Media
</button>

<div class="share-icons" id="share-icons">
  <button onclick="shareOnFacebook()"><i class="fab fa-facebook-f"></i></button>
  <button onclick="shareOnTwitter()"><i class="fa-brands fa-x-twitter"></i></button>
  <button onclick="shareOnPinterest()"><i class="fab fa-pinterest"></i></button>
  <button onclick="shareOnLinkedIn()"><i class="fab fa-linkedin-in"></i></button>
  <button onclick="shareOnFlipboard()"><i class="fab fa-flipboard"></i></button>
  <button onclick="shareOnTelegram()"><i class="fab fa-telegram-plane"></i></button>
  <button onclick="shareOnWhatsApp()"><i class="fab fa-whatsapp"></i></button>
  <button onclick="shareOnReddit()"><i class="fab fa-reddit"></i></button>
  <button onclick="shareViaEmail()"><i class="fas fa-envelope"></i></button>
  <button onclick="shareNative()"><i class="fas fa-share-alt"></i></button>
</div>

<script>
function toggleShareDropdown() {
  var shareIcons = document.getElementById('share-icons');
  shareIcons.classList.toggle('show');
}

function shareOnFacebook() {
  var sharedURL = location.href;
  var facebookShareURL = 'https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(sharedURL);
  window.open(facebookShareURL, '_blank');
}

function shareOnTwitter() {
  var sharedURL = location.href;
  var twitterShareURL = 'https://twitter.com/intent/tweet?url=' + encodeURIComponent(sharedURL);
  window.open(twitterShareURL, '_blank');
}

function shareOnPinterest() {
  var sharedURL = location.href;
  var pinterestShareURL = 'https://www.pinterest.com/pin/create/button/?url=' + encodeURIComponent(sharedURL);
  window.open(pinterestShareURL, '_blank');
}

function shareOnLinkedIn() {
  var sharedURL = location.href;
  var linkedInShareURL = 'https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(sharedURL);
  window.open(linkedInShareURL, '_blank');
}

function shareOnFlipboard() {
  var sharedURL = location.href;
  var flipboardShareURL = 'https://share.flipboard.com/bookmarklet/popout?v=2&title=&url=' + encodeURIComponent(sharedURL);
  window.open(flipboardShareURL, '_blank');
}

function shareOnTelegram() {
  var sharedURL = location.href;
  var telegramShareURL = 'https://telegram.me/share/url?url=' + encodeURIComponent(sharedURL);
  window.open(telegramShareURL, '_blank');
}

function shareOnWhatsApp() {
  var sharedURL = location.href;
  var whatsAppShareURL = 'whatsapp://send?text=' + encodeURIComponent(sharedURL);
  window.open(whatsAppShareURL, '_blank');
}

function shareOnReddit() {
  var sharedURL = location.href;
  var redditShareURL = 'https://www.reddit.com/submit?url=' + encodeURIComponent(sharedURL);
  window.open(redditShareURL, '_blank');
}

function shareViaEmail() {
  var sharedURL = location.href;
  var emailSubject = 'Check out this link';
  var emailBody = 'I found this interesting link and thought you might like it: ' + sharedURL;
  var mailToLink = 'mailto:?subject=' + encodeURIComponent(emailSubject) + '&body=' + encodeURIComponent(emailBody);
  window.location.href = mailToLink;
}

function shareNative() {
  if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
    // Mobile device, trigger native sharing
    var sharedURL = location.href;
    navigator.share({ url: sharedURL })
      .then(() => console.log('Shared successfully.'))
      .catch((error) => console.log('Error sharing:', error));
  } else {
    console.log('Native sharing not supported on this device.');
  }
}
</script>

Insert HTML code where you want the "Share to Social Media" Button to be

TOC is called by:

<aside class="gh-sidebar toc-sidebar">
  <div class="gh-toc"></div>
</aside>

This will set a TOC on the sidebar. The Theme needs to support this structure.



References

Ghost [1] Alpine Linux [2] Docker [3] Dockge [4] Portainer [5]
Authelia [6] Authentik [7] Traefik [8] Nginx Proxy Manager [9] Code Injection [10]


  1. Ghost, Independent technology for modern publishing. homepage, demo, themes ↩︎

  2. Alpine, a distribution based on musl and BusyBox homepage, wikipedia ↩︎

  3. homepage, Docker Compose ↩︎

  4. Dockge on GitHub ↩︎

  5. Portainer homepage, GitHub9 ↩︎

  6. Authelia, Single Sign-On Multi-Factor portal for web apps homepage ↩︎

  7. authentik, is an open source Identity Provider focused on flexibility and versatility. homepage ↩︎

  8. Traefik is an open-source Edge Router that automatically discovers and routes requests to your services homepage, GitHub ↩︎

  9. NPM, Nginx Proxy Manager, is a Reverse Proxy Manager that lets you expose your private web services on your network with free SSL, Docker, and multiple users. You can configure and manage your proxy hosts with a beautiful UI and a simple Docker image. homepage ↩︎

  10. CDNjs, free and open source CDN is built to make life easier for developers homepage, GitHub, ↩︎