PageSourceSearch

https://mattlafrancis.com/securely-self-hosting-your-own-website-using-ghost/

html mattlafrancis.com collected 2026-10-04 08:07:41 UTC 62,115 bytes, 603 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en" class="dark-mode">
3<head>
4
5    <title>Securely Self-Hosting Your Own Website Using Ghost</title>
6    <meta charset="utf-8" />
7    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
8    <meta name="HandheldFriendly" content="True" />
9    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10    
11    <link rel="preload" as="style" href="/assets/built/screen.css?v=c7DvOK7x9POSoPj-" />
12    <link rel="preload" as="script" href="/assets/built/casper.js?v=jwyolN9pHiVDAaqQ" />
13
14    <link rel="stylesheet" type="text/css" href="/assets/built/screen.css?v=c7DvOK7x9POSoPj-" />
15
16    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.12.3/tocbot.css">
17    <style>
18    .gh-content {
19        position: relative;
20     }
21
22    .gh-toc > .toc-list {
23        position: relative;
24    }
25
26    .toc-list {
27        overflow: hidden;
28        list-style: none;
29    }
30
31    @media (min-width: 1300px) {
32         .gh-sidebar {
33            position: absolute; 
34            top: 0;
35            bottom: 0;
36            margin-top: 4vmin;
37            grid-column: wide-start / main-start; /* Place the TOC to the left of the content */
38        }
39   
40        .gh-toc {
41            position: sticky; /* On larger screens, TOC will stay in the same spot on the page */
42            top: 4vmin;
43        }
44    }
45
46    .gh-toc .is-active-link::before {
47        background-color: var(--ghost-accent-color); /* Defines TOC   accent color based on Accent color set in Ghost Admin */
48    } 
49    </style>
50    <link rel="canonical" href="https://mattlafrancis.com/securely-self-hosting-your-own-website-using-ghost/">
51    <meta name="referrer" content="no-referrer-when-downgrade">
52    
53    <meta property="og:site_name" content="mattlafrancis.com">
54    <meta property="og:type" content="article">
55    <meta property="og:title" content="Securely Self-Hosting Your Own Website Using Ghost">
56    <meta property="og:description" content="Let&#x27;s say you want to make your own website. The easiest option would be to use a cloud-based service, which 99% of the time requires some sort of recurring payment to use. This can be very convenient as you can simply press a couple buttons, and then have">
57    <meta property="og:url" content="https://mattlafrancis.com/securely-self-hosting-your-own-website-using-ghost/">
58    <meta property="og:image" content="https://mattlafrancis.com/content/images/size/w1200/2025/11/img1.jpg">
59    <meta property="article:published_time" content="2025-10-17T23:33:13.000Z">
60    <meta property="article:modified_time" content="2025-11-10T15:02:53.000Z">
61    <meta property="article:tag" content="Guide">
62    
63    <meta name="twitter:card" content="summary_large_image">
64    <meta name="twitter:title" content="Securely Self-Hosting Your Own Website Using Ghost">
65    <meta name="twitter:description" content="Let&#x27;s say you want to make your own website. The easiest option would be to use a cloud-based service, which 99% of the time requires some sort of recurring payment to use. This can be very convenient as you can simply press a couple buttons, and then have">
66    <meta name="twitter:url" content="https://mattlafrancis.com/securely-self-hosting-your-own-website-using-ghost/">
67    <meta name="twitter:image" content="https://mattlafrancis.com/content/images/size/w1200/2025/11/img1.jpg">
68    <meta name="twitter:label1" content="Written by">
69    <meta name="twitter:data1" content="Matt LaFrancis">
70    <meta name="twitter:label2" content="Filed under">
71    <meta name="twitter:data2" content="Guide">
72    <meta property="og:image:width" content="1200">
73    <meta property="og:image:height" content="900">
74    
75    
75<script type="application/ld+json">
76{
77    "@context": "https://schema.org",
78    "@type": "Article",
79    "publisher": {
80        "@type": "Organization",
81        "name": "mattlafrancis.com",
82        "url": "https://mattlafrancis.com/",
83        "logo": {
84            "@type": "ImageObject",
85            "url": "https://mattlafrancis.com/favicon.ico",
86            "width": 48,
87            "height": 48
88        }
89    },
90    "author": {
91        "@type": "Person",
92        "name": "Matt LaFrancis",
93        "url": "https://mattlafrancis.com/author/matt/",
94        "sameAs": []
95    },
96    "headline": "Securely Self-Hosting Your Own Website Using Ghost",
97    "url": "https://mattlafrancis.com/securely-self-hosting-your-own-website-using-ghost/",
98    "datePublished": "2025-10-17T23:33:13.000Z",
99    "dateModified": "2025-11-10T15:02:53.000Z",
100    "image": {
101        "@type": "ImageObject",
102        "url": "https://mattlafrancis.com/content/images/size/w1200/2025/11/img1.jpg",
103        "width": 1200,
104        "height": 900
105    },
106    "keywords": "Guide",
107    "description": "Let's say you want to make your own website. The easiest option would be to use a cloud-based service, which 99% of the time requires some sort of recurring payment to use. This can be very convenient as you can simply press a couple buttons, and then have your very own website. Most of the time these types of services even have tools you can use to easily customize your website to fit your needs.\n\nHowever, what we often think of as \"the cloud\" is really just someone else's computer. You can nev",
108    "mainEntityOfPage": "https://mattlafrancis.com/securely-self-hosting-your-own-website-using-ghost/"
109}
110    </script>
110
111
112    <meta name="generator" content="Ghost 6.63">
113    <link rel="alternate" type="application/rss+xml" title="mattlafrancis.com" href="https://mattlafrancis.com/rss/">
114    
115    
115<script defer src="https://cdn.jsdelivr.net/ghost/sodo-search@~1.8/umd/sodo-search.min.js" data-key="a04292ee8bf14e35f72de3976b" data-styles="https://cdn.jsdelivr.net/ghost/sodo-search@~1.8/umd/main.css" data-sodo-search="https://mattlafrancis.com/" data-locale="en" crossorigin="anonymous"></script>
115
116    
117    <link href="https://mattlafrancis.com/webmentions/receive/" rel="webmention">
118    
118<script defer src="/public/cards.min.js?v=ShRHxgy4po8zN-Wf"></script>
118<style>:root {--ghost-accent-color: #bf19ff;}</style>
119    <link rel="stylesheet" type="text/css" href="/public/cards.min.css?v=WwnU9jw5ancNC8Gc">
120
121</head>
122<body class="post-template tag-guide is-head-middle-logo">
123<div class="viewport">
124
125    <header id="gh-head" class="gh-head outer is-header-hidden">
126        <div class="gh-head-inner inner">
127            <div class="gh-head-brand">
128                <a class="gh-head-logo no-image" href="https://mattlafrancis.com">
129                        mattlafrancis.com
130                </a>
131                <button class="gh-search gh-icon-btn" aria-label="Search this site" data-ghost-search><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" width="20" height="20"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg></button>
132                <button class="gh-burger" aria-label="Main Menu"></button>
133            </div>
134
135            <nav class="gh-head-menu">
136                <ul class="nav">
137    <li class="nav-home"><a href="https://mattlafrancis.com/">Home</a></li>
138    <li class="nav-posts"><a href="https://mattlafrancis.com/dashboard/">Posts</a></li>
139</ul>
140
141            </nav>
142
143            <div class="gh-head-actions">
144                        <button class="gh-search gh-icon-btn" aria-label="Search this site" data-ghost-search><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" width="20" height="20"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg></button>
145            </div>
146        </div>
147    </header>
148
149    <div class="site-content">
150        
151
152
153
154<main id="site-main" class="site-main">
155<article class="article post tag-guide ">
156
157    <header class="article-header gh-canvas">
158
159        <div class="article-tag post-card-tags">
160                <span class="post-card-primary-tag">
161                    <a href="/tag/guide/">Guide</a>
162                </span>
163        </div>
164
165        <h1 class="article-title">Securely Self-Hosting Your Own Website Using Ghost</h1>
166
167
168        <div class="article-byline">
169        <section class="article-byline-content">
170
171            <ul class="author-list instapaper_ignore">
172                <li class="author-list-item">
173                    <a href="/author/matt/" class="author-avatar author-profile-image" aria-label="Read more of Matt LaFrancis"><svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M3.513 18.998C4.749 15.504 8.082 13 12 13s7.251 2.504 8.487 5.998C18.47 21.442 15.417 23 12 23s-6.47-1.558-8.487-4.002zM12 12c2.21 0 4-2.79 4-5s-1.79-4-4-4-4 1.79-4 4 1.79 5 4 5z" fill="#FFF"/></g></svg>
174</a>
175                </li>
176            </ul>
177
178            <div class="article-byline-meta">
179                <h4 class="author-name"><a href="/author/matt/">Matt LaFrancis</a></h4>
180                <div class="byline-meta-content">
181                    <time class="byline-meta-date" datetime="2025-10-17">17 Oct 2025</time>
182                        <span class="byline-reading-time"><span class="bull">&bull;</span> 22 min read</span>
183                </div>
184            </div>
185
186        </section>
187        </div>
188
189            <figure class="article-image">
190                <img
191                    srcset="/content/images/size/w300/2025/11/img1.jpg 300w,
192                            /content/images/size/w600/2025/11/img1.jpg 600w,
193                            /content/images/size/w1000/2025/11/img1.jpg 1000w,
194                            /content/images/size/w2000/2025/11/img1.jpg 2000w"
195                    sizes="(min-width: 1400px) 1400px, 92vw"
196                    src="/content/images/size/w2000/2025/11/img1.jpg"
197                    alt="Securely Self-Hosting Your Own Website Using Ghost"
198                />
199                    <figcaption><span style="white-space:pre-wrap">Image is not of a web server but instead of a concert featuring the Swedish metal band called "Ghost".</span></figcaption>
200            </figure>
201
202    </header>
203
204    <section class="gh-content gh-canvas">
205    <aside class="gh-sidebar"><div class="gh-toc"></div></aside> 
206        <p>Let's say you want to make your own website. The easiest option would be to use a cloud-based service, which 99% of the time requires some sort of recurring payment to use. This can be very convenient as you can simply press a couple buttons, and then have your very own website. Most of the time these types of services even have tools you can use to easily customize your website to fit your needs.</p><p>However, what we often think of as "the cloud" is really just someone else's computer. You can never fully know what kind of access the cloud provider has to your data or how well they secure their servers. And on top of that you have to pay them money on a recurring basis! These are several reasons why I chose to self-host my own website (it's actually because I just wanted to and those other reasons are ways to justify that). I control my own website's security, I control my own data, and best of all is that I don't pay anyone anything for it!</p><p>I primarily used <a href="https://fanyangmeng.blog/securing-ghost-blog-my-pers
206onal-strategy/?ref=mattlafrancis.com" rel="noreferrer">this guide</a> as inspiration for this project, but in this guide I will elaborate on what they did based on my own implementation successes/failures, and elaborate in areas that I think will be useful for you.</p><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://fanyangmeng.blog/securing-ghost-blog-my-personal-strategy/?ref=mattlafrancis.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Securing Ghost Blog: My Personal Strategy</div><div class="kg-bookmark-description">Discover how to secure your Ghost blog using UFW, Tailscale, NPM and Authentik SSO. Learn to protect against brute force attacks, secure admin access, and implement CrowdSec integration. A comprehensive guide to hardening your Ghost blog’s security from infrastructure to application level.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://mattlafrancis.com/content/images/icon/profile-picture-2.jpg" alt=""><span class="kg-bookmark-author">Fanyang Meng's Blog</span><span class="kg-bookmark-publisher">Fanyang Meng</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://mattlafrancis.com/content/images/thumbnail/SecuringGhostBlogMyPersonalStrategyFeatureImage.jpeg" alt="" onerror="this.style.display = 'none'"></div></a></figure><p><em>Note: Ghost is an easy to use website application, however it's admin page is unchangeably set to "https://&lt;your public domain&gt;.com/ghost" and does not provide MFA support. This opens up risk to brute force attacks. To help mitigate this, we will use Authentik as an additional authentication method which will require visitors to have a web browser cookie that is received upon successful authentication with Authentik before accessing the Ghost admin page.</em></p><hr><div class="kg-card kg-header-card kg-v2 kg-width-regular " style="background-color: #000000;" data-background-color="#000000">
207            
208            <div class="kg-header-card-content">
209                
210                <div class="kg-header-card-text kg-align-center">
211                    <h2 id="security-advisory" class="kg-header-card-heading" style="color: #FFFFFF;" data-text-color="#FFFFFF"><span style="white-space: pre-wrap;">Security Advisory</span></h2>
212                    <p id="it-is-easy-to-forget-that-when-you-are-plugged-into-the-internet-the-internet-is-also-plugged-into-you" class="kg-header-card-subheading" style="color: #FFFFFF;" data-text-color="#FFFFFF"><span style="white-space: pre-wrap;">"It is easy to forget that when you are plugged into the Internet, the Internet is also plugged into you"</span></p>
213                    
214                </div>
215            </div>
216        </div><p>Running any public service over the Internet opens yourself up to risk. The Internet is a network that spans the entire planet. This means that if your network is like your house, then being connected to the Internet not only invites any person around the world to knock on your door, but also to pick your locks or find other ways to get in to your home. On top of that - in the case of the Internet - it's not just people walking up to your door, but also bots, entire organizations, or even nation-state actors.</p><p>Everything is constantly being poked and pinged on the Internet. Even right now your own home network is constantly being bombarded with traffic from the outside, but it almost never gets through because your home network most likely has a firewall blocking traffic outside from coming in. Running a public service means opening a way from outside into your network which opens yourself up to the risk of malicious actors hacking your device(s), installing malware on your device(s), stealing data from your device(s), and/or permanently damaging your device(s).</p><p><strong><u>In other words, if you host any public service (including a website), you are opening yourself up to risk no matter how much security you implement to protect yourself. If you don't have to, then you shouldn't open yourself up to this risk.</u></strong> </p><p>I am only a technology enthusiast and an aspiring cyber security professional. I am by no means a cyber security subject matter expert and am only creating this guide to showcase my own project with other technology enthusiasts.</p><p>Always remember that you can <strong><u>NEVER</u></strong> not be at risk of compromise when hosting anything publicly. It's never a matter of IF, but only a matter of WHEN. However, you can take certain steps to mitigate and minimize that risk, some of which will be discussed in this guide.</p><h2 id="installation-guide">Installation Guide</h2><h3 id="preparation">Preparation</h3><p>Before starting, it is important to make sure the network your web server will be hosted on is locked down and secure. It is ideal to have multiple mechanisms of blocking intruders and malicious actors, and multiple safety nets of security to slow down incoming threats. I would use the OSI model as a guideline to evaluate your layers of security coverage. I would even use the MITRE ATT&amp;CK Framework to also help inform your security posture.</p><p>For example, place your web server in a DMZ or some other isolated network from the rest of your devices and set appropriate firewall rules to ensure nothing can communicate between your internal network(s) and the DMZ (bi-directional is best). Implement VLANs if needed to ensure layer 2 separation. Maybe even implement an IPS/IDS (Snort for example) that monitors incoming DMZ traffic for additional visibility and security, or a geo-blocker (pfBlocker for example) to block IP addresses from certain geographic areas to reduce potential attack vectors, or a DNS blackhole/sinkhole (pfBlocker or PiHole for example) to prevent DMZ devices from calling out to potentially malicious domains.</p><p>Determine if you have a static public IP or a dynamic public IP with your ISP. You will need to use a DDNS service if you don't have a static IP. DDNS (Dynamic Domain Name System) is a software that runs on your network to update a domain record with your IP address whenever it changes.</p><p>You will also need to purchase a wildcard public domain (wildcard meaning that you can use all it's subdomains). For example, if you bought "example.com", then a wildcard domain would look like "*.example.com" so you could use "auth.example.com" or "website.example.com".</p><p>You will need to create an account with <a href="https://app.crowdsec.net/sign-in?next=%2Fsecurity-engines&ref=mattlafrancis.com" rel="noreferrer">Crowdsec</a> and <a href="https://login.tailscale.com/start?ref=mattlafrancis.com" rel="noreferrer">Tailscale</a>.</p><figure class="kg-card kg-bookmark-card kg-card-hascaption"><a class="kg-bookmark-container" href="https://app.crowdsec.net/sign-in?next=%2Fsecurity-engines&ref=mattlafrancis.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">- CrowdSec Console</div><div class="kg-bookmark-description">CrowdSec is an open-source and collaborative security stack leveraging the crowd power. Analyze behaviors, respond to attacks &amp; share signals across the community. Join the community and let’s make the Internet safer, together.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://mattlafrancis.com/content/images/icon/favicon-1.ico" alt=""><span class="kg-bookmark-author">CrowdSec Console</span></div></div>
216<div class="kg-bookmark-thumbnail"><img src="https://mattlafrancis.com/content/images/thumbnail/opengraph-image" alt="" onerror="this.style.display = 'none'"></div></a><figcaption><p><span style="white-space: pre-wrap;">CrowdSec Console Login/Signup Page</span></p></figcaption></figure><figure class="kg-card kg-bookmark-card kg-card-hascaption"><a class="kg-bookmark-container" href="https://login.tailscale.com/start?ref=mattlafrancis.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Tailscale</div><div class="kg-bookmark-description"></div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://mattlafrancis.com/content/images/icon/favicon-2.ico" alt=""></div></div></a><figcaption><p><span style="white-space: pre-wrap;">Tailscale Login/Signup Page</span></p></figcaption></figure><h3 id="domain-records-and-initial-firewall-rules-configuration">Domain Records and Initial Firewall Rules Configuration</h3><p>Create an A record with your DNS provider pointing to your website if you have a static public IP. Or create DDNS record that your public domain will point to if not.</p><p>Create a subdomain A record for Authentik. It's your choice what you make it, but for this guide I will make it auth.&lt;your public domain&gt;.com</p><p><u>Keep it disabled for now</u>, but configure port forwarding for port 443 and port 80 on your router's public IP address to the same ports on your Ubuntu Linux server. We will enable the port forward once the server is ready to accept the communication.</p><p>It is probably not needed, but I thought I would mention you may also need to temporarily add a firewall rule to your router to allow SSH to the Ubuntu Linux server from your internal network. This firewall rule should be removed once you are able to SSH into the virtual machine using Tailscale later in the guide.</p><h3 id="virtual-machine-install-and-docker-setup">Virtual Machine Install and Docker Setup</h3><p>We will be using a fresh install of Ubuntu Linux running on a virtual machine server. It is best practice to check and make sure that the virtual machine can only communicate on the DMZ network and cannot communicate with the rest of your internal network.<br><em>Note: I recommend running all servers within a virtual machine if not explicitly told otherwise, because virtual machines are much easier to back up, revert, or rebuild in case of compromise (or in case you change something and you accidentally break the virtual machine).</em></p><p><a href="https://ubuntu.com/tutorials/install-ubuntu-server?ref=mattlafrancis.com#1-overview" rel="noreferrer">Install Ubuntu Linux Server</a>. Don't install additional software during the setup. </p><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://ubuntu.com/tutorials/install-ubuntu-server?ref=mattlafrancis.com#1-overview"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Install Ubuntu Server | Ubuntu</div><div class="kg-bookmark-description">Ubuntu is an open source software operating system that runs from the desktop, to the cloud, to all your internet connected things.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://mattlafrancis.com/content/images/icon/f38b9c7e-COF-20apple-touch-icon.png" alt=""><span class="kg-bookmark-author">Ubuntu</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://mattlafrancis.com/content/images/thumbnail/ebdfffbf-Aubergine_suru_background.png" alt="" onerror="this.style.display = 'none'"></div></a></figure><p>After installation, run an update command to install the latest security updates to your Ubuntu server:<br><strong>$ sudo apt-get update &amp;&amp; sudo apt-get upgrade</strong></p><p>Ensure you don't have docker already installed. If so, uninstall docker.</p><pre><code class="language-plaintext">for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done</code></pre><p>Next, <a href="https://docs.docker.com/engine/install/ubuntu/?ref=mattlafrancis.com" rel="noreferrer">install Docker Engine + Docker Compose</a>. I've taken excerpts from the guide and pasted them below for c
216onvenience, but I would highly recommend using the official guide instead in case anything changes or you run into trouble.</p><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://docs.docker.com/engine/install/ubuntu/?ref=mattlafrancis.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Ubuntu</div><div class="kg-bookmark-description">Jumpstart your client-side server applications with Docker Engine on Ubuntu. This guide details prerequisites and multiple methods to install Docker Engine on Ubuntu.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://mattlafrancis.com/content/images/icon/favicon.svg" alt=""><span class="kg-bookmark-author">Docker Documentation</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://mattlafrancis.com/content/images/thumbnail/thumbnail.webp" alt="" onerror="this.style.display = 'none'"></div></a></figure><pre><code class="language-plaintext"># Add Docker's official GPG key:
217sudo apt-get update
218sudo apt-get install ca-certificates curl
219sudo install -m 0755 -d /etc/apt/keyrings
220sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
221sudo chmod a+r /etc/apt/keyrings/docker.asc
222
223echo \
224  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
225  $(. /etc/os-release &amp;&amp; echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
226  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
227sudo apt-get update</code></pre><pre><code class="language-plaintext">sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin</code></pre><p>Test your docker install to ensure it's working:</p><pre><code class="language-plaintext">sudo docker run hello-world</code></pre><h3 id="set-ufw-rules-for-docker-and-change-default-ssh-port">Set UFW Rules for Docker and Change Default SSH Port</h3><p>Next, change your UFW config to block docker ports:<br><strong>$ nano /etc/ufw/after.rules</strong><br>Add the following to the end of the file:</p><pre><code class="language-plaintext"># BEGIN UFW AND DOCKER
228*filter
229:ufw-user-forward - [0:0]
230:ufw-docker-logging-deny - [0:0]
231:DOCKER-USER - [0:0]
232-A DOCKER-USER -j ufw-user-forward
233
234-A DOCKER-USER -j RETURN -s 10.0.0.0/8
235-A DOCKER-USER -j RETURN -s 172.16.0.0/12
236-A DOCKER-USER -j RETURN -s 192.168.0.0/16
237
238-A DOCKER-USER -p udp -m udp --sport 53 --dport 1024:65535 -j RETURN
239
240-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 192.168.0.0/16
241-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 10.0.0.0/8
242-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12
243-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 192.168.0.0/16
244-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 10.0.0.0/8
245-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 172.16.0.0/12
246
247-A DOCKER-USER -j RETURN
248
249-A ufw-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] "
250-A ufw-docker-logging-deny -j DROP
251
252COMMIT
253# END UFW AND DOCKER</code></pre><p>Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.</p><p>Restart the firewall service:<br><strong>$ systemctl restart ufw</strong></p><p>Next, change the Ubuntu SSH port to a random non-standard port of your choosing (for reference, the range of non-standard ports are 49152 - 65535). Edit the sshd_config file using the below command:<br><strong>$ nano /etc/ssh/sshd_config</strong><br>Uncomment <strong>#Port 22 </strong>and change 22 to your chosen non-standard port.<br>Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.</p><p>Next, change your SSH service file. Use the same non-standard port you previously chose:<br><strong>$ nano /etc/services</strong><br>Find the SSH service and set to the previously specified non-standard port.<br>Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.</p><p>Reboot the machine. If you were previously using SSH to remotely connect to the machine, you must now connect to it by using the below command:<br><strong>
253$ ssh -p &lt;non-standard port&gt; &lt;username&gt;@&lt;Ubuntu IP Address&gt;</strong></p><h3 id="configure-tailscale-vpn">Configure Tailscale VPN</h3><p>If you haven't already done so, <a href="https://login.tailscale.com/start?ref=mattlafrancis.com" rel="noreferrer">Create Tailscale account on their website.</a> </p><p><a href="https://tailscale.com/kb/1347/installation?ref=mattlafrancis.com" rel="noreferrer">Install Tailscale VPN</a>. I've taken excerpts from the guide and pasted them below for your convenience, but I would highly recommend using the official guide in case anything changes or you run into trouble.</p><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://tailscale.com/kb/1347/installation?ref=mattlafrancis.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Install Tailscale · Tailscale Docs</div><div class="kg-bookmark-description">Install and uninstall the Tailscale client.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://mattlafrancis.com/content/images/icon/favicon-1.svg" alt=""><span class="kg-bookmark-author">Tailscale</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://mattlafrancis.com/content/images/thumbnail/og-image.png" alt="" onerror="this.style.display = 'none'"></div></a></figure><p>On your own device, run:<br><strong>$ curl -fsSL https://tailscale.com/install.sh | sh<br>$ tailscale up</strong><br>Follow the on screen instructions until complete. </p><p>Repeat the above commands on the Ubuntu Linux server as well. Once complete, you should have Tailscale installed on both your Ubuntu Linux server and your personal device which joins them to the same Tailscale network.</p><p>Next, run this command on your Ubuntu server:<br><strong>$ tailscale ip -4</strong><br>This will output the Tailscale IP of the Ubuntu server. Now SSH into the Ubuntu server using the Tailscale IP to ensure it works. Only proceed with the instructions once this works:<br><strong>$ ssh -p &lt;non-standard port&gt; &lt;username&gt;@&lt;tailscale server IP&gt;</strong></p><h3 id="lock-ssh-access-behind-tailscale-vpn">Lock SSH Access Behind Tailscale VPN</h3><p>On your Ubuntu Linux server, input the below firewall configuration commands:<br><strong>$ ufw allow in on tailscale0<br>$ ufw allow http<br>$ ufw allow https</strong><br>Next, confirm your firewall rules allow HTTP, HTTPS, and tailscale0:<br><strong>$ ufw show added</strong><br>Once confirmed, enable ufw:<br><strong>$ ufw enable</strong></p><p>To summarize, the above steps will set your server to accept direct connections on port 80 and 443, and will set the server to allow all incoming connections using Tailscale (which includes SSH using the non-standard port set earlier).<br><em>Note: If you configured a firewall rule to allow SSH from your internal network to your DMZ, you can now delete that firewall rule since you should be able to SSH into your Ubuntu server using Tailscale.</em></p><h3 id="create-docker-user-docker-group-and-docker-directory">Create Docker User, Docker Group, and Docker Directory</h3><p>The below commands will create a group for running docker commands if not already made, create a user called "ghostuser" for running the docker 
253containers, create a directory to store the docker-compose.yml file, modify the permissions for the directory to be owned by ghostuser, and add ghostuser to the docker group. This is done so that if the ghostuser account is compromised, the user cannot modify any directories other than the ones it already owns:<br><strong>$ groupadd docker<br>$ adduser ghostuser<br>$ mkdir /opt/ghost<br>$ mkdir /opt/npm<br>$ chown ghostuser:ghostuser /opt/ghost<br>$ chown ghostuser:ghostuser /opt/npm<br>$ usermod -aG docker ghostuser</strong></p><h3 id="create-the-initial-docker-compose-file">Create The Initial Docker-Compose File</h3><p>First we will start with creating the NPMPlus &amp; Crowdsec containers. We will be following the <a href="https://github.com/ZoeyVid/NPMplus?tab=readme-ov-file&ref=mattlafrancis.com#quick-setup" rel="noreferrer">NPMPlus guide for initial NPMPlus set up</a>. Then, we will use <a href="https://github.com/ZoeyVid/NPMplus?tab=readme-ov-file&ref=mattlafrancis.com#crowdsec" rel="noreferrer">the Crowdsec section of the guide</a> to add a web application firewall to the website. I've taken excerpts from the guide and pasted them below for your convenience, but I would highly recommend using the official guide in case anything changes or you run into trouble.</p><figure class="kg-card kg-bookmark-card kg-card-hascaption"><a class="kg-bookmark-container" href="https://github.com/ZoeyVid/NPMplus?tab=readme-ov-file&ref=mattlafrancis.com#quick-setup"><div class="kg-bookmark-content"><div class="kg-bookmark-title">GitHub - ZoeyVid/NPMplus: improved fork of nginx-proxy-manager</div><div class="kg-bookmark-description">improved fork of nginx-proxy-manager. Contribute to ZoeyVid/NPMplus development by creating an account on GitHub.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://mattlafrancis.com/content/images/icon/pinned-octocat-093da3e6fa40.svg" alt=""><span class="kg-bookmark-author">GitHub</span><span class="kg-bookmark-publisher">ZoeyVid</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://mattlafrancis.com/content/images/thumbnail/NPMplus" alt="" onerror="this.style.display = 'none'"></div></a><figcaption><p><span style="white-space: pre-wrap;">NPMPlus Install Section of Guide</span></p></figcaption></figure><figure class="kg-card kg-bookmark-card kg-card-hascaption"><a class="kg-bookmark-container" href="https://github.com/ZoeyVid/NPMplus?tab=readme-ov-file&ref=mattlafrancis.com#crowdsec"><div class="kg-bookmark-content"><div class="kg-bookmark-title">GitHub - ZoeyVid/NPMplus: improved fork of nginx-proxy-manager</div><div class="kg-bookmark-description">improved fork of nginx-proxy-manager. Contribute to ZoeyVid/NPMplus development by creating an account on GitHub.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://mattlafrancis.com/content/images/icon/pinned-octocat-093da3e6fa40-1.svg" alt=""><span class="kg-bookmark-author">GitHub</span><span class="kg-bookmark-publisher">ZoeyVid</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://mattlafrancis.com/content/images/thumbnail/NPMplus-1" alt="" onerror="this.style.display = 'none'"></div></a><figcaption><p><span style="white-space: pre-wrap;">CrowdSec Enablement Section of Guide</span></p></figcaption></figure><p>Enter the below commands to create the docker-compose.yml file:<br><strong>$ su ghostuser<br>$ cd /opt/npm<br>$ nano docker-compose.yml</strong><br>Copy and paste the contents of <a href="https://raw.githubusercontent.com/ZoeyVid/NPMplus/refs/heads/develop/compose.yaml?ref=mattlafrancis.com" rel="noreferrer">the NPMPlus Docker compose file</a> into your docker-compose.yml file.<br>Uncomment the CrowdSec portion of the docker-compose file, but leave the openappsec line commented. Adjust the <a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones?ref=mattlafrancis.com" rel="noreferrer">TZ values to your tz database timezone</a> and ACME_EMAIL to your email address.</p><p>Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.</p><p>Next, start your containers:<br><strong>
253$ docker compose up -d</strong></p><p>Check the status of your docker containers using the below command (keep this handy for the rest of the guide):<br><strong>$ docker ps</strong></p><p>Wait until your docker contains are done pulling and initializing. Next, check the logs for your npmplus container to find your temporary web admin password:<br><strong>$ docker logs npmplus</strong><br>Look for the password in the log output, then in your web browser go to the <strong>https://&lt;tailscale IP&gt;:81</strong> and log in with default username of <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="a5c4c1c8cccbe5c0ddc4c8d5c9c08bc6cac8">[email&#160;protected]</a> and previously found password. You will be prompted to change your password. Once complete, bring your containers down in your SSH session:<br><strong>$ docker compose down</strong></p><p>Next edit the docker-compose.yml file again:<br><strong>$ nano docker-compose.yml</strong><br>Uncomment the LOGROTATE line.<br>Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.</p><p>Now start your containers up again:<br><strong>$ docker compose up -d</strong><br>Once back up, edit the the npmplus.yaml file and fill it with the below output:<br><strong>$ nano /opt/crowdsec/conf/acquis.d/npmplus.yaml</strong></p><pre><code class="language-plaintext">filenames:
254  - /opt/npmplus/nginx/*.log
255labels:
256  type: npmplus
257---
258filenames:
259  - /opt/npmplus/nginx/*.log
260labels:
261  type: modsecurity
262---
263listen_addr: 0.0.0.0:7422
264appsec_config: crowdsecurity/appsec-default
265name: appsec
266source: appsec
267labels:
268  type: appsec
269# if you use openappsec you can enable this
270#---
271#source: file
272#filenames:
273# - /opt/openappsec/logs/cp-nano-http-transaction-handler.log*
274#labels:
275#  type: openappsec</code></pre><p>Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.</p><p>Next, find your API key for crowdsec by using the below command. Make sure to keep it handy:<br><strong>$ docker exec crowdsec cscli bouncers add npmplus -o raw</strong></p><p>Edit your crowdsec configuration file:<br><strong>$ nano /opt/npmplus/crowdsec/crowdsec.conf</strong><br>Set ENABLED to true<br>Set API_KEY to the previously saved output<br>Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.</p><p>Restart your docker containers.<br><strong>$ docker compose down<br>$ docker compose up -d</strong></p><h3 id="enroll-your-crowdsec-container-into-crowdsec-security-engine">Enroll Your Crowdsec Container Into Crowdsec Security Engine</h3><p><a href="https://doc.crowdsec.net/u/getting_started/post_installation/console/?ref=mattlafrancis.com" rel="noreferrer">Follow the Crowdsec post-install instructions</a> to enroll the Crowdsec docker container in Crowdsec Security Engine.</p><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://doc.crowdsec.net/u/getting_started/post_installation/console/?ref=mattlafrancis.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">CrowdSec Console | CrowdSec</div><div class="kg-bookmark-description">The CrowdSec Console serves as a web-based interface designed for managing your CrowdSec Security Engines effectively. This console comes equipped with an intuitive dashboard, offering a comprehensive visualization of your engines’ activities, thereby enhancing the ease of monitoring and analysis.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://mattlafrancis.com/content/images/icon/crowdsec_no_txt.png" alt=""><span class="kg-bookmark-author">CrowdSec</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://mattlafrancis.com/content/images/thumbnail/crowdsec_og_image.png" alt="" onerror="this.style.display = 'none'"></div></a></figure><p>For when you get to the Enrollment Key part of the guide, modify the given command like below:<br><strong>$ docker exec crowdsec cscli console enroll &lt;enrollment key&gt;</strong><br>Once complete, refresh the Engines page and accept the new Engine.</p><p>You can use the Crowdsec console to monitor what traffic Crowdsec is blocking. Do note that while Crowdsec console has basic functionality for free, you may 
275need to pay to use additional features. For example, sometimes Crowdsec may lock you out of your own web server if you are messing with HTML. If you pay for Crowdsec, you can unblock your IP address directly from the security console. If you don't, you'll have to SSH into your web server and execute a command in your Crowdsec container to unblock yourself. You can find that command below for your convenience:<br><strong>$ docker exec crowdsec cscli decisions delete --ip &lt;your public ip address&gt;</strong></p><h3 id="create-your-npmplus-hosts">Create Your NPMPlus Hosts</h3><p>Go back to your NPMPlus web admin page at <strong>https://&lt;tailscale IP&gt;:81</strong><br>Next, create a proxy host for your previously created authentication subdomain:<br>Set the scheme to HTTPS, domain/ip to 127.0.0.1, port to 9443.<br>Enable Websockets support.<br>Set TLS to Create new Certificate for your base public domain.<br>Check Force HTTPS, Enable HSTS and security headers, Enable Brotli, Enable HTTP/3-Quic.<br>Click Save.</p><p>Next, create a proxy host for your base public domain:<br>Set scheme to HTTP, domain/ip to 127.0.0.1, port to 8081.<br>Enable Websockets support.<br>Set TLS to use your previously created certificate.<br>Check Force HTTPS, Enable HSTS and security headers, Enable Brotli, Enable HTTP/3-Quic.<br>Under the Advanced tab, enter the below text and then click Save.</p><pre><code class="language-plaintext">location ~ ^/ghost/api/ {
276    proxy_pass          $forward_scheme://$server:$port;
277    proxy_set_header    Host $host;
278  
279    # Add CORS headers for API
280    add_header 'Access-Control-Allow-Origin' '$http_origin' always;
281    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always;
282    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;
283    add_header 'Access-Control-Allow-Credentials' 'true' always;
284  
285    # Handle preflight requests
286    if ($request_method = 'OPTIONS') {
287        add_header 'Access-Control-Allow-Origin' '$http_origin' always;
288        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always;
289        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;
290        add_header 'Access-Control-Allow-Credentials' 'true' always;
291        add_header 'Access-Control-Max-Age' 1728000;
292        add_header 'Content-Type' 'text/plain charset=UTF-8';
293        add_header 'Content-Length' 0;
294        return 204;
295    }
296}
297# Main location block for Ghost admin panel that requires authentication
298location ~ ^/ghost/ {
299    # Exclude /ghost/api from authentication
300    location ~ ^/ghost/api/ {
301        proxy_pass          $forward_scheme://$server:$port;
302        proxy_set_header    Host $host;
303        # Add any other required proxy headers for Ghost API
304        proxy_buffer_size 128k;
305        proxy_buffers 4 256k;
306        proxy_busy_buffers_size 256k;
307    }
308
309    # Authentication for all other /ghost/ paths
310    proxy_pass          $forward_scheme://$server:$port;
311    proxy_set_header Host $http_host;
312    proxy_set_header X-Real-IP $remote_addr;
313    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
314    proxy_set_header X-Forwarded-Proto https;
315
316    ##############################
317    # authentik-specific config
318    ##############################
319    auth_request     /outpost.goauthentik.io/auth/nginx;
320    error_page       401 = @goauthentik_proxy_signin;
321    auth_request_set $auth_cookie $upstream_http_set_cookie;
322    add_header       Set-Cookie $auth_cookie;
323
324    # translate headers from the outposts back to the actual upstream
325    auth_request_set $authentik_username $upstream_http_x_authentik_username;
326    auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
327    auth_request_set $authentik_email $upstream_http_x_authentik_email;
328    auth_request_set $authentik_name $upstream_http_x_authentik_name;
329    auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
330
331    proxy_set_header X-authentik-username $authentik_username;
332    proxy_set_header X-authentik-groups $authentik_groups;
333    proxy_set_header X-authentik-email $authentik_email;
334    proxy_set_header X-authentik-name $authentik_name;
335    proxy_set_header X-authentik-uid $authentik_uid;
336}
337
338# Root location for all other paths
339location / {
340    proxy_pass          $forward_scheme://$server:$port;
341    proxy_set_header    Host $host;
342}
343
344# Authentik outpost configuration
345location /outpost.goauthentik.io {
346    proxy_pass              https://localhost:9443/outpost.goauthentik.io;
347    proxy_set_header        Host $host;
348    proxy_set_header        X-Original-URL $scheme://$http_host$request_uri;
349    add_header              Set-Cookie $auth_cookie;
350    auth_request_set        $auth_cookie $upstream_http_set_cookie;
351    proxy_pass_request_body off;
352    proxy_set_header        Content-Length "";
353}
354
355# Authentication redirect handler
356location @goauthentik_proxy_signin {
357    internal;
358    add_header Set-Cookie $auth_cookie;
359    return 302 $scheme://$http_host/outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
360}</code></pre><p>The above steps tells NPMPlus to direct requests for your authentication subdomain (we are using auth.&lt;your public domain&gt;.com) to port 9443 on your Ubuntu server, which is the port that Authentik will run on. Then it tells NPMPlus to direct requests for your public domain to port 8081 on your Ubuntu server, which is the port that Ghost will run on. It also adds additional reverse proxy configs to lock the Ghost admin page behind authentication with Authentik.</p><h3 id="set-up-your-authentik-container">Set Up Your Authentik Container</h3><p>
360Shut down your docker containers, then edit the docker-compose.yml file.<br><strong>$ docker compose down<br>$ nano /opt/ghost/docker-compose.yml</strong></p><p>Append the below text to the end of the file:</p><pre><code class="language-plaintext">  postgresql:
361    env_file:
362    - .env
363    environment:
364      POSTGRES_DB: ${PG_DB:-authentik}
365      POSTGRES_PASSWORD: ${PG_PASS:?database password required}
366      POSTGRES_USER: ${PG_USER:-authentik}
367    healthcheck:
368      interval: 30s
369      retries: 5
370      start_period: 20s
371      test:
372      - CMD-SHELL
373      - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
374      timeout: 5s
375    image: docker.io/library/postgres:16-alpine
376    restart: unless-stopped
377    volumes:
378    - database:/var/lib/postgresql/data
379  server:
380    command: server
381    depends_on:
382      postgresql:
383        condition: service_healthy
384    env_file:
385    - .env
386    environment:
387      AUTHENTIK_POSTGRESQL__HOST: postgresql
388      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
389      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
390      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
391      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
392    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.0}
393    ports:
394    - ${COMPOSE_PORT_HTTP:-9000}:9000
395    - ${COMPOSE_PORT_HTTPS:-9443}:9443
396    restart: unless-stopped
397    volumes:
398    - ./media:/media
399    - ./custom-templates:/templates
400  worker:
401    command: worker
402    depends_on:
403      postgresql:
404        condition: service_healthy
405    env_file:
406    - .env
407    environment:
408      AUTHENTIK_POSTGRESQL__HOST: postgresql
409      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
410      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
411      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
412      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
413    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.0}
414    restart: unless-stopped
415    user: root
416    volumes:
417    - /var/run/docker.sock:/var/run/docker.sock
418    - ./media:/media
419    - ./certs:/certs
420    - ./custom-templates:/templates
421volumes:
422  database:
423    driver: local</code></pre><p>Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.</p><p>Enter the below commands to generate passwords and change some values for your .env file:<br><strong>$ echo "PG_PASS=$(openssl rand -base64 36 | tr -d '\n')" &gt;&gt; .env<br>$ echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')" &gt;&gt; .env<br>$ echo "AUTHENTIK_ERROR_REPORTING__ENABLED=true" &gt;&gt; .env</strong></p><p>Next, start up your docker containers again:<br><strong>$ docker compose up -d</strong></p><p><u>If you already set up your port forward from your router's public IP to your Ubuntu server, you are now ready to enable it and will need to have that working before proceeding.</u></p><p>Once the containers are fully up and the port forward is working, manage your authentik page by going to <strong>https://auth.&lt;your public domain&gt;.com/if/flow/initial-setup/</strong><br>Set a new password when prompted. Once done, you should be at the Authentik admin page.</p><p>Set the Output settings by going to Applications &gt; Outposts. Make sure "authentik_host" is set to https://auth.&lt;your public domain&gt;.com</p><p>Set the Provider settings by going to Applications &gt; Providers &gt; Create &gt; Proxy Provider<br>Set the Provider name to something like "ghost" (the name is up to you. just make sure you remember it).<br>Select Forward auth (domain level)<br>Set "Authentication URL" to https://auth.&lt;your public domain&gt;.com<br>Set Cookie Domain to &lt;your public domain&gt;.com<br>Open "Advanced Protocol Settings" and add the below to "Unauthenticated URLs"</p><pre><code class="language-plaintext">^/ghost/api/.*
424^/members/.*
425^/sitemap.xml
426^/robots.txt</code></pre><p>Next, create the Application by going to Applications &gt; Applications. Set the name to something like "ghost" (the name is up to you. just make sure you remember it), then set the provider to previously created provider.</p><p>Go to the Outposts tab again and assign the application to the outpost.</p><p>I would also highly recommend changing your admin username to something other than "akadmin", and adding some sort of MFA to your account. Change the name by going to Directory &gt; Users &gt; click "Edit" next to the admin user &gt; change the username. Set up MFA by clicking the gear icon in the top-right &gt; MFA Devices &gt; Enroll. I'd recommend enrolling a TOTP device so you can use an authenticator app on your phone as MFA.</p><h3 id="set-up-your-ghost-container">Set Up Your Ghost Container</h3><p>
426Bring your docker containers down once more, then edit your docker-compose.yml file.<br><strong>$ docker compose down<br>$ nano docker-compose.yml</strong></p><p>Add the below text to the docker-compose file. Make sure you change https://&lt;your public domain&gt;.com to your public domain:</p><pre><code class="language-plaintext">version: "3.3"
427services:
428  ghost-frontend:
429    image: nginx:1.15-alpine
430    container_name: ghost-frontend
431    restart: unless-stopped
432    volumes:
433      - ./nginx/ghost.conf:/etc/nginx/conf.d/ghost.conf
434    ports:
435      - 8081:8081
436    depends_on:
437      - ghost-app
438    networks:
439      - ghost
440
441  ghost-app:
442    container_name: ghost-app
443    image: ghost:latest
444    restart: always
445    depends_on:
446      - ghost-mysql
447    environment:
448      url: https://&lt;your public domain&gt;.com
449      database__client: mysql
450      database__connection__host: ghost-mysql
451      database__connection__user: mysql-user
452      database__connection__password: mysql-password
453      database__connection__database: ghost
454    volumes:
455      - ./content:/var/lib/ghost/content
456    networks:
457      - ghost
458
459  ghost-mysql:
460    container_name: ghost-mysql
461    image: mysql:8
462    restart: always
463    environment:
464      MYSQL_ROOT_PASSWORD: mysql-root-password
465      MYSQL_USER: mysql-user
466      MYSQL_PASSWORD: mysql-password
467      MYSQL_DATABASE: ghost
468    volumes:
469      - ./mysql8:/var/lib/mysql
470    networks:
471      - ghost
472
473networks:
474  ghost:
475    name: ghost
476    driver: bridge</code></pre><p>Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.</p><p>Next, create an "nginx" directory and then create a config file within. Copy the below text and paste it into your ghost.conf file, but make sure to change &lt;your public domain&gt; to your domain that Ghost will run on.<br><strong>$ mkdir nginx<br>$ nano /opt/ghost/nginx/ghost.conf</strong></p><pre><code class="language-plaintext">server {
477        listen 8081;
478        server_name www.&lt;your public domain&gt;.com;
479        return 301 $scheme://&lt;your public domain&gt;$request_uri;
480}
481server {
482        listen         8081;
483        server_name    &lt;your public domain&gt;.com;
484
485    location / {
486        proxy_pass http://ghost-app:2368;
487        proxy_set_header Range $http_range;
488        proxy_set_header If-Range $http_if_range;
489        proxy_set_header X-Real-IP $remote_addr;
490        proxy_set_header Host $host;
491
492# The lines below are commented out for compatibility due to redirect errors when adding a another reverse proxy in fr&gt;
493
494#       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
495#       proxy_set_header X-Forwarded-Proto $scheme;
496    }
497}</code></pre><p>Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.</p><p>Next, bring your docker containers back up.<br><strong>$ docker compose up -d</strong></p><p>Ensure docker containers are all running without issue:<br><strong>$ docker ps</strong></p><h2 id="test">Test</h2><p>Open an incognito/private window in your web browser, then go to your website. Your website should load page with HTTPS and you should see your certificate upon inspection.</p><p>Next, test the Authentik admin page by going to https://&lt;your public domain&gt;.com/ghost<br>You should be automatically redirected to https://auth.&lt;your public domain&gt;.com<br>Log in with your previously created authentik admin credentials.<br>Upon success you should be redirected to the regular ghost web admin page.</p><p>From here, create a web admin password for Ghost. Once complete, you  are now all set and free to set up your website.</p><h2 id="maintenance">Maintenance</h2><p>It is important to review any servers exposed to the Internet and ensure they are not missing any security updates, the logs look okay, and that your server is being backed up. In this case, we would want to make sure Ubuntu has the latest security updates, all the docker 
497containers are up to date (especially Crowdsec), the log activity looks okay, and that there is a good backup plan in place.</p><h3 id="backing-up-your-server">Backing Up Your Server</h3><p>If you can, try to follow the 3-2-1 backup rule. This means that you are keeping at least 3 copies of your data across 2 different storage media with 1 being located off site. Even if you can't achieve this, at least make sure you keep a backup of your data on a separate media (cloud storage may be handy for this if you encrypt the data backup).</p><p>To back up your Ghost data, you can follow this guide:</p><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://docs.ghost.org/faq/manual-backup?ref=mattlafrancis.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">How Can I Backup My Site Data? - Ghost Developer Docs</div><div class="kg-bookmark-description">Learn how to backup your self-hosted Ghost install</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://mattlafrancis.com/content/images/icon/apple-touch-icon.png" alt=""><span class="kg-bookmark-author">Ghost Developer Docs</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://mattlafrancis.com/content/images/thumbnail/image" alt="" onerror="this.style.display = 'none'"></div></a></figure><h3 id="ubuntu-security-updates">Ubuntu Security Updates</h3><p>Before installing updates of ANY kind, always make sure you have a backup readily available. Virtual machine snapshots are a handy way to quickly make a "save point" for you to return to in case security updates break anything.</p><p>To install the latest security updates, SSH into your Ubuntu server, then run the below command:<br><strong>$ sudo apt-get update &amp;&amp; sudo apt-get upgrade</strong></p><p>You may also need to reboot the server once security updates are completed. You will be prompted to do so after updates are completed.</p><h3 id="docker-container-updates">Docker Container Updates</h3><p>Just wanted to mention one more time: before installing updates of ANY kind, always make sure you have a backup readily available. Again, a virtual machine snapshot is a handy way to have a "save point" to return to in case updating your docker containers breaks your virtual machine.</p><p>To install the latest Docker container images, SSH into your Ubuntu server, then run the below command:<br><strong>$ su ghostuser<br>$ cd /opt/ghost &amp;&amp; docker compose down &amp;&amp; docker compose pull &amp;&amp; docker compose up -d<br>$ cd /opt/npm &amp;&amp; docker compose down &amp;&amp; docker compose pull &amp;&amp; docker compose up -d</strong></p><p>The below command is useful after updating your containers as it may clean up some storage space. Make sure your containers are all running before using it as it deletes containers that are not currently in use:<br><strong>$ docker container prune</strong></p><h3 id="review-log-activity">Review Log Activity</h3><p>In a nutshell, Docker containers run as users on your host system. If exploited, then attackers can run commands as the user the docker 
497containers are running as. Checking command logs, successful logins, and service starts/stops are good starting points for ensuring your server is safe. The below guide is a useful starting point that I found to monitor your server logs.</p><figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="https://markaicode.com/audit-system-logs-suspicious-activity/?ref=mattlafrancis.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">How to Audit Ubuntu System Logs for Suspicious Activity (2025 Cybersecurity Guide) | Markaicode</div><div class="kg-bookmark-description">Learn practical techniques to audit Ubuntu system logs and detect security threats. This step-by-step guide shows how to monitor for suspicious activities on Linux servers.</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="https://mattlafrancis.com/content/images/icon/Mark_Logo-192.png" alt=""><span class="kg-bookmark-author">Markaicode</span><span class="kg-bookmark-publisher">Mark</span></div></div><div class="kg-bookmark-thumbnail"><img src="https://mattlafrancis.com/content/images/thumbnail/logo.png" alt="" onerror="this.style.display = 'none'"></div></a></figure><p>You can also review logs on your Crowdsec Engine web page. There you can see all kinds of security information directly related to your web server.</p><p>Other than that, this is where deploying security tools would be useful to further enhance the activity you are able to see and the corrective actions you are able to perform.</p>
498    </section>
499
500
501</article>
502</main>
503
504
505
506
507
508
509
510    </div>
511
512    <footer class="site-footer outer">
513        <div class="inner">
514            <section class="copyright"><a href="https://mattlafrancis.com">mattlafrancis.com</a> &copy; 2026</section>
515            <nav class="site-footer-nav">
516                <ul class="nav">
517    <li class="nav-about-site"><a href="https://mattlafrancis.com/about/">About Site</a></li>
518    <li class="nav-about-me"><a href="https://mattlafrancis.com/about-matt-lafrancis/">About Me</a></li>
519</ul>
520
521            </nav>
522            <div class="gh-powered-by"><a href="https://ghost.org/" target="_blank" rel="noopener">Powered by Ghost</a></div>
523        </div>
524    </footer>
525
526</div>
527
528    <div class="pswp" tabindex="-1" role="dialog" aria-hidden="true">
529    <div class="pswp__bg"></div>
530
531    <div class="pswp__scroll-wrap">
532        <div class="pswp__container">
533            <div class="pswp__item"></div>
534            <div class="pswp__item"></div>
535            <div class="pswp__item"></div>
536        </div>
537
538        <div class="pswp__ui pswp__ui--hidden">
539            <div class="pswp__top-bar">
540                <div class="pswp__counter"></div>
541
542                <button class="pswp__button pswp__button--close" title="Close (Esc)"></button>
543                <button class="pswp__button pswp__button--share" title="Share"></button>
544                <button class="pswp__button pswp__button--fs" title="Toggle fullscreen"></button>
545                <button class="pswp__button pswp__button--zoom" title="Zoom in/out"></button>
546
547                <div class="pswp__preloader">
548                    <div class="pswp__preloader__icn">
549                        <div class="pswp__preloader__cut">
550                            <div class="pswp__preloader__donut"></div>
551                        </div>
552                    </div>
553                </div>
554            </div>
555
556            <div class="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
557                <div class="pswp__share-tooltip"></div>
558            </div>
559
560            <button class="pswp__button pswp__button--arrow--left" title="Previous (arrow left)"></button>
561            <button class="pswp__button pswp__button--arrow--right" title="Next (arrow right)"></button>
562
563            <div class="pswp__caption">
564                <div class="pswp__caption__center"></div>
565            </div>
566        </div>
567    </div>
568</div>
vendor: 109 bytes, lines 568-569
568
569<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script>
569<script
570    src="https://code.jquery.com/jquery-3.5.1.min.js"
571    integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="
572    crossorigin="anonymous">
573</script>
vendor: 1 bytes, line 573
573
574<script src="/assets/built/casper.js?v=jwyolN9pHiVDAaqQ"></script>
vendor: 1 bytes, line 574
574
575<script>
576$(document).ready(function () {
577    // Mobile Menu Trigger
578    $('.gh-burger').click(function () {
579        $('body').toggleClass('gh-head-open');
580    });
581    // FitVids - Makes video embeds responsive
582    $(".gh-content").fitVids();
583});
584</script>
584
585
586<script src="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.12.3/tocbot.min.js"></script>
vendor: 1 bytes, line 586
586
587<script>
588    tocbot.init({
589        // Where to render the table of contents.
590        tocSelector: '.gh-toc',
591        // Where to grab the headings to build the table of contents.
592        contentSelector: '.gh-content',
593        // Which headings to grab inside of the contentSelector element.
594        headingSelector: 'h1, h2, h3, h4',
595        // Ensure correct positioning
596        hasInnerContainers: true,
597    });
598</script>
598
599
600
601<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"81a9f28aef4b49cea1974e24d52f417b","r":1,"spa":2}' crossorigin="anonymous"></script>
601
602</body>
603</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.