Intro: From WordPress to Ghost

After seven years of self-hosting experience, my blogging journey has evolved from a simple WordPress site on a US-based VPS to a refined Ghost implementation. This transition taught me valuable lessons about balancing functionality with simplicity.

WordPress initially attracted me with its straightforward setup and rich ecosystem of themes and plugins. However, these advantages gradually became liabilities. Despite trying various servers and optimizations, persistent performance issues meant spending more time troubleshooting than writing. When page loads consistently took 4-5 seconds, I knew it was time for a change.

Ghost offered the solution I needed - a platform focused on writing with essential features built in. The clean, distraction-free writing experience allowed me to finally focus on content creation rather than constant technical maintenance.

Now, with my Ghost blog properly secured and running smoothly on the public internet, I'm ready to share the security measures that made this possible.

Deployment: Docker or Source?

Choosing the right deployment is crucial, as both options will ultimately provide a functioning blog, but they differ significantly in future maintenance requirements, and migration could present challenges.

How to install Ghost, the official guide
Everything you need to know about working with the Ghost professional publishing platform.

My Ghost journey began with a traditional source installation, requiring MySQL server, Node.js, and careful user permission configuration. While functional, this setup became a maintenance burden. Server updates and service management often led to availability issues, making it difficult to ensure consistent uptime during traffic spikes.

Docker Compose transformed this experience. With Docker as the only dependency, deployment became remarkably straightforward. Key advantages include:

  • Automated Updates: Watchtower handles Ghost and MySQL updates automatically
  • Simple File Management: The content folder mounts directly to my local directory
  • Flexible Configuration: Custom configs can be mounted to the container as needed
  • Equivalent Functionality: No compromise on features compared to source installation

Unless you need to modify Ghost's core functionality, Docker Compose offers all the benefits of source installation with significantly less overhead. For my needs, it's the perfect solution.

Server Security

Deploying a blog on the public internet requires thinking about security from the ground up. Server security isn't just an optional layer – it's the foundation everything else builds upon. Just as a skyscraper's strength depends on its foundation, a blog's security relies first and foremost on the underlying server's protection. Before we delve into Ghost-specific security measures, we need to ensure our server's foundation is rock-solid.

Limit Port Access with VPN tunnel

An exposed server with open ports is like a house with unlocked doors and windows – it's an invitation to intruders. Malicious actors constantly scan public IPs, looking for vulnerabilities to exploit, steal data, or hijack resources.

My security solution combines two powerful tools:

  1. UFW (Uncomplicated Firewall): Acts as my first line of defense by managing IP tables and controlling port access. Its simplicity makes it perfect for basic firewall management.
  2. Tailscale: Provides an additional security layer by creating an encrypted WireGuard VPN tunnel between my devices and the server. This ensures all connections are private and secure.

This dual-layer approach significantly reduces the server's attack surface while maintaining convenient access for legitimate users.

Running a public blog requires a thoughtful approach to port security. Your website needs ports 80 and 443 open to the world - that's non-negotiable for serving content to your readers.

Administrative access, however, demands stronger protection. The default SSH port 22 is a prime target for automated attacks, so moving it to a non-standard port adds a simple but effective security layer. Taking this further, restricting SSH access to your Tailscale VPN tunnel creates a private, secure channel for server management.

Use UFW to lock down an Ubuntu server · Tailscale Docs
Learn how to accept connections from Tailscale and ignore internet traffic to a server.

This same principle extends to any additional services you run. Whether it's databases or monitoring tools, protect these ports by limiting access to your Tailscale network only. While the technical setup is straightforward and well-documented online, understanding these core concepts helps you build a more secure server.

UFW cannot block Docker containers' Ports: Solution

Have you ever blocked a port with UFW only to find it still accessible from the internet? This seemingly puzzling behavior stems from a fundamental conflict: UFW and Docker both modify the same iptables rules on your Linux system, but they don't play nicely together. Docker's modifications often bypass UFW's carefully crafted rules, potentially exposing your containers to the public internet regardless of your UFW settings.

Fortunately, there's a solution. Chai Feng's ufw-docker tool (available at chaifeng/ufw-docker on GitHub) specifically addresses this security gap. The tool ensures Docker respects UFW's ruleset, restoring proper firewall control.

In my setup, I've taken a conservative approach: all Docker container ports are blocked from public access. Instead, I access them exclusively through my Tailscale VPN. This eliminates unnecessary exposure while maintaining convenient access when needed.

Web Server Security

Securing the web server that serves the domain is now necessary. As a long-time Nginx user, I've opted for the reputable Nginx Proxy Manager to maintain a clean separation of my blog's security components. However, I've chosen not the standard NginxProxyManager/nginx-proxy-manager, but a modified version: ZoeyVid/NPMplus. This variant provides the feature I need most: CrowdSec integration.

Secure the NPM Plus

Nginx Proxy Manager (NPM) offers a convenient web UI for managing your websites, but its default configuration requires port 81 to be open. While this port enables easy management, leaving it publicly accessible creates an unnecessary security risk.

Here's how to secure it with Tailscale and UFW:

sudo ufw route allow from 100.64.0.0/10 to any port 81

This simple command restricts port 81 access to only your Tailscale network. You can then access NPM's admin interface using either your Tailscale IP or domain, keeping management capabilities private while maintaining full functionality.

CrowdSec Integration

CrowdSec is an open-source, lightweight software that detects and blocks malicious actors from accessing your systems at various levels, using log and HTTP Requests analysis with threat patterns called scenarios. You can view the detailed introduction here:

Introduction | CrowdSec
The CrowdSec Security Engine is an open-source, lightweight software that detects and blocks malicious actors from accessing your systems at various levels, using log and HTTP Requests analysis with threat patterns called scenarios.

While the official Nginx Proxy Manager has dropped CrowdSec support, NPMplus includes built-in CrowdSec integration, offering a robust three-layer security approach:

  1. Real-Time Request Screening (AppSec) NPMplus consults CrowdSec during each request, immediately blocking malicious attempts before they reach your services.
  2. Intelligent Log Analysis CrowdSec continuously monitors your logs, identifying and banning IPs that demonstrate suspicious behavior patterns, even after their initial requests.
  3. Proactive Request Filtering (Bouncers) Before processing requests, NPMplus checks CrowdSec's API to block previously identified threats, creating an automatic defense against known bad actors.

This integrated security approach makes NPMplus a more robust choice than standard NPM for protecting your web services.

To implement it with NPMplus, it is pretty easy, you can simply follow the introduction in the README.md from its GitHub repo:

GitHub - ZoeyVid/NPMplus: Docker container for managing Nginx proxy hosts with a simple, powerful interface
Docker container for managing Nginx proxy hosts with a simple, powerful interface - ZoeyVid/NPMplus

Admin Portal Security: Authentik SSO

Ghost's admin portal presents two significant security challenges in 2024: the unchangeable /ghost URL and the lack of built-in two-factor authentication. For a modern content management system, these limitations are concerning.

Enter Authentik SSO (Single Sign-On). This lightweight yet powerful authentication system adds crucial security layers to Ghost's admin portal:

  • Multi-factor authentication
  • Centralized access control
  • Enhanced login security
Welcome | authentik
Bring all of your authentication into a unified platform.

While Ghost itself hasn't implemented these essential security features, integrating Authentik provides the robust authentication system modern web applications demand, all while keeping the setup process straightforward.

For the installation, simply follow the official docker compose deployment documentation and you should be good to go.

Set up Provider and Application

Once Authentik is running, you'll need to configure it to protect your Ghost blog's admin interface. In the Authentik Admin Interface, start by creating a new Proxy Provider and select "Forward auth (domain level)" - this allows Authentik to act as a security middleware for your blog.

Let's say your Authentik instance runs on auth.example.com and your blog on blog.example.com. You'll set the Authentication URL to your Authentik domain and the Cookie domain to example.com. This configuration ensures proper authentication handling across your domains.

To maintain normal blog functionality, you'll need to allow some URLs to bypass authentication. Add these patterns to the Unauthenticated URLs:

^/ghost/api/.*
^/members/.*
^/sitemap.xml
^/robots.txt

After finish setting up the provider, you can easily set up an application and link to this provider you just created.

⚠️
Do not forget to go to the Outposts tab and add this provider to the outpost, so that Authentik can successfully handle the request without errors.

NPMplus Configuration

To activate Authentik's protection for your Ghost admin portal, you'll need to configure your blog's domain settings in Nginx Proxy Manager. This configuration ensures that anyone attempting to access /ghost gets redirected to Authentik for authentication before reaching the Ghost login page.

Add this to your blog domain's advanced settings in NPM:

# Main location block for Ghost admin panel that requires authentication
location ~ ^/ghost/ {
    # Exclude /ghost/api from authentication
    location ~ ^/ghost/api/ {
        proxy_pass          $forward_scheme://$server:$port;
        proxy_set_header    Host $host;
        # Add any other required proxy headers for Ghost API
        proxy_buffer_size 128k;
        proxy_buffers 4 256k;
        proxy_busy_buffers_size 256k;
    }

    # Authentication for all other /ghost/ paths
    proxy_pass          $forward_scheme://$server:$port;

    ##############################
    # authentik-specific config
    ##############################
    auth_request     /outpost.goauthentik.io/auth/nginx;
    error_page       401 = @goauthentik_proxy_signin;
    auth_request_set $auth_cookie $upstream_http_set_cookie;
    add_header       Set-Cookie $auth_cookie;

    # translate headers from the outposts back to the actual upstream
    auth_request_set $authentik_username $upstream_http_x_authentik_username;
    auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
    auth_request_set $authentik_email $upstream_http_x_authentik_email;
    auth_request_set $authentik_name $upstream_http_x_authentik_name;
    auth_request_set $authentik_uid $upstream_http_x_authentik_uid;

    proxy_set_header X-authentik-username $authentik_username;
    proxy_set_header X-authentik-groups $authentik_groups;
    proxy_set_header X-authentik-email $authentik_email;
    proxy_set_header X-authentik-name $authentik_name;
    proxy_set_header X-authentik-uid $authentik_uid;
}

# Root location for all other paths
location / {
    proxy_pass          $forward_scheme://$server:$port;
    proxy_set_header    Host $host;
}

# Authentik outpost configuration
location /outpost.goauthentik.io {
    proxy_pass              http://localhost:9000/outpost.goauthentik.io;
    proxy_set_header        Host $host;
    proxy_set_header        X-Original-URL $scheme://$http_host$request_uri;
    add_header              Set-Cookie $auth_cookie;
    auth_request_set        $auth_cookie $upstream_http_set_cookie;
    proxy_pass_request_body off;
    proxy_set_header        Content-Length "";
}

# Authentication redirect handler
location @goauthentik_proxy_signin {
    internal;
    add_header Set-Cookie $auth_cookie;
    return 302 $scheme://$http_host/outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
}

When protecting Ghost with Authentik, you'll need to configure CORS properly to ensure all content loads correctly. Add these directives at the beginning of your domain's advanced settings in NPM:

location ~ ^/ghost/api/ {
    proxy_pass          $forward_scheme://$server:$port;
    proxy_set_header    Host $host;
  
    # Add CORS headers for API
    add_header 'Access-Control-Allow-Origin' '$http_origin' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always;
    add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
  
    # Handle preflight requests
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '$http_origin' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always;
        add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Type' 'text/plain charset=UTF-8';
        add_header 'Content-Length' 0;
        return 204;
    }
}

This configuration ensures your Ghost site's content loads properly while maintaining secure authentication through Authentik. Place these directives before the previously configured authentication settings.

Adding CAPTCHA Stage and Login Attempt Limits

Authentik's security is built around flows and stages, which can be customized through the admin interface. While the default authentication flow provides basic security, you will need to add CAPTCHA and login attempt limits manually.

Authentik already has documentation for how to add a captcha stage, simply follow it and you will be good to go:

Captcha stage | authentik
This stage adds a form of verification using Google’s reCAPTCHA or compatible services.

While CAPTCHA adds a layer of protection to your login page, determined attackers might still attempt brute force attacks. Authentik's Reputation Policy provides an additional defense layer by monitoring and limiting login attempts based on IP addresses or usernames.

To implement IP-based blocking in Authentik, navigate to the default authentication flow page. Create a new Reputation Policy and bind it to your flow. Two critical settings make this effective:

  1. Enable Negate result in the binding options - this ensures the policy blocks access when the threshold is reached
  2. Set your threshold value (e.g., -3) - each failed login attempt decreases the score by 1, so a threshold of -3 means blocking occurs after three failures

This simple configuration creates a powerful defense: any IP address that fails three login attempts gets automatically blocked, even if they solve the CAPTCHA correctly on subsequent attempts.

Conclusion

While these security measures create a robust defense for your Ghost blog - combining UFW, Tailscale, NPMplus with CrowdSec, and Authentik with enhanced authentication - they represent just one approach to securing your online presence. The world of web security offers many additional layers of protection, including CDN integration, which deserves its own detailed exploration.

I'll be sharing my experiences with CDN integration in a future post. This additional layer not only enhances security but also improves performance and reliability.

If you've found this guide helpful in securing your own Ghost blog, I'd love to hear about your experience in the comments. What security measures have you implemented? What challenges did you face? Your feedback and experiences could help others on their journey to better blog security.

Stay tuned for more insights into blog security and performance optimization!