Securely Self-Hosting Your Own Website Using Ghost

Securely Self-Hosting Your Own Website Using Ghost
Image is not of a web server but instead of a concert featuring the Swedish metal band called "Ghost".

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.

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!

I primarily used this guide 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.

Securing Ghost Blog: My Personal Strategy
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.

Note: Ghost is an easy to use website application, however it's admin page is unchangeably set to "https://<your public domain>.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.


Security Advisory

"It is easy to forget that when you are plugged into the Internet, the Internet is also plugged into you"

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.

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).

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.

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.

Always remember that you can NEVER 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.

Installation Guide

Preparation

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&CK Framework to also help inform your security posture.

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.

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.

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".

You will need to create an account with Crowdsec and Tailscale.

- CrowdSec Console
CrowdSec is an open-source and collaborative security stack leveraging the crowd power. Analyze behaviors, respond to attacks & share signals across the community. Join the community and let’s make the Internet safer, together.

CrowdSec Console Login/Signup Page

Tailscale

Tailscale Login/Signup Page

Domain Records and Initial Firewall Rules Configuration

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.

Create a subdomain A record for Authentik. It's your choice what you make it, but for this guide I will make it auth.<your public domain>.com

Keep it disabled for now, 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.

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.

Virtual Machine Install and Docker Setup

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.
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).

Install Ubuntu Linux Server. Don't install additional software during the setup.

Install Ubuntu Server | Ubuntu
Ubuntu is an open source software operating system that runs from the desktop, to the cloud, to all your internet connected things.

After installation, run an update command to install the latest security updates to your Ubuntu server:
$ sudo apt-get update && sudo apt-get upgrade

Ensure you don't have docker already installed. If so, uninstall docker.

for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done

Next, install Docker Engine + Docker Compose. I've taken excerpts from the guide and pasted them below for convenience, but I would highly recommend using the official guide instead in case anything changes or you run into trouble.

Ubuntu
Jumpstart your client-side server applications with Docker Engine on Ubuntu. This guide details prerequisites and multiple methods to install Docker Engine on Ubuntu.
# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Test your docker install to ensure it's working:

sudo docker run hello-world

Set UFW Rules for Docker and Change Default SSH Port

Next, change your UFW config to block docker ports:
$ nano /etc/ufw/after.rules
Add the following to the end of the file:

# BEGIN UFW AND DOCKER
*filter
:ufw-user-forward - [0:0]
:ufw-docker-logging-deny - [0:0]
:DOCKER-USER - [0:0]
-A DOCKER-USER -j ufw-user-forward

-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16

-A DOCKER-USER -p udp -m udp --sport 53 --dport 1024:65535 -j RETURN

-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
-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
-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
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 192.168.0.0/16
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 172.16.0.0/12

-A DOCKER-USER -j RETURN

-A ufw-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] "
-A ufw-docker-logging-deny -j DROP

COMMIT
# END UFW AND DOCKER

Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.

Restart the firewall service:
$ systemctl restart ufw

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:
$ nano /etc/ssh/sshd_config
Uncomment #Port 22 and change 22 to your chosen non-standard port.
Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.

Next, change your SSH service file. Use the same non-standard port you previously chose:
$ nano /etc/services
Find the SSH service and set to the previously specified non-standard port.
Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.

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:
$ ssh -p <non-standard port> <username>@<Ubuntu IP Address>

Configure Tailscale VPN

If you haven't already done so, Create Tailscale account on their website.

Install Tailscale VPN. 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.

Install Tailscale · Tailscale Docs
Install and uninstall the Tailscale client.

On your own device, run:
$ curl -fsSL https://tailscale.com/install.sh | sh
$ tailscale up

Follow the on screen instructions until complete.

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.

Next, run this command on your Ubuntu server:
$ tailscale ip -4
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:
$ ssh -p <non-standard port> <username>@<tailscale server IP>

Lock SSH Access Behind Tailscale VPN

On your Ubuntu Linux server, input the below firewall configuration commands:
$ ufw allow in on tailscale0
$ ufw allow http
$ ufw allow https

Next, confirm your firewall rules allow HTTP, HTTPS, and tailscale0:
$ ufw show added
Once confirmed, enable ufw:
$ ufw enable

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).
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.

Create Docker User, Docker Group, and Docker Directory

The below commands will create a group for running docker commands if not already made, create a user called "ghostuser" for running the docker containers, 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:
$ groupadd docker
$ adduser ghostuser
$ mkdir /opt/ghost
$ mkdir /opt/npm
$ chown ghostuser:ghostuser /opt/ghost
$ chown ghostuser:ghostuser /opt/npm
$ usermod -aG docker ghostuser

Create The Initial Docker-Compose File

First we will start with creating the NPMPlus & Crowdsec containers. We will be following the NPMPlus guide for initial NPMPlus set up. Then, we will use the Crowdsec section of the guide 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.

GitHub - ZoeyVid/NPMplus: improved fork of nginx-proxy-manager
improved fork of nginx-proxy-manager. Contribute to ZoeyVid/NPMplus development by creating an account on GitHub.

NPMPlus Install Section of Guide

GitHub - ZoeyVid/NPMplus: improved fork of nginx-proxy-manager
improved fork of nginx-proxy-manager. Contribute to ZoeyVid/NPMplus development by creating an account on GitHub.

CrowdSec Enablement Section of Guide

Enter the below commands to create the docker-compose.yml file:
$ su ghostuser
$ cd /opt/npm
$ nano docker-compose.yml

Copy and paste the contents of the NPMPlus Docker compose file into your docker-compose.yml file.
Uncomment the CrowdSec portion of the docker-compose file, but leave the openappsec line commented. Adjust the TZ values to your tz database timezone and ACME_EMAIL to your email address.

Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.

Next, start your containers:
$ docker compose up -d

Check the status of your docker containers using the below command (keep this handy for the rest of the guide):
$ docker ps

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:
$ docker logs npmplus
Look for the password in the log output, then in your web browser go to the https://<tailscale IP>:81 and log in with default username of [email protected] and previously found password. You will be prompted to change your password. Once complete, bring your containers down in your SSH session:
$ docker compose down

Next edit the docker-compose.yml file again:
$ nano docker-compose.yml
Uncomment the LOGROTATE line.
Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.

Now start your containers up again:
$ docker compose up -d
Once back up, edit the the npmplus.yaml file and fill it with the below output:
$ nano /opt/crowdsec/conf/acquis.d/npmplus.yaml

filenames:
  - /opt/npmplus/nginx/*.log
labels:
  type: npmplus
---
filenames:
  - /opt/npmplus/nginx/*.log
labels:
  type: modsecurity
---
listen_addr: 0.0.0.0:7422
appsec_config: crowdsecurity/appsec-default
name: appsec
source: appsec
labels:
  type: appsec
# if you use openappsec you can enable this
#---
#source: file
#filenames:
# - /opt/openappsec/logs/cp-nano-http-transaction-handler.log*
#labels:
#  type: openappsec

Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.

Next, find your API key for crowdsec by using the below command. Make sure to keep it handy:
$ docker exec crowdsec cscli bouncers add npmplus -o raw

Edit your crowdsec configuration file:
$ nano /opt/npmplus/crowdsec/crowdsec.conf
Set ENABLED to true
Set API_KEY to the previously saved output
Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.

Restart your docker containers.
$ docker compose down
$ docker compose up -d

Enroll Your Crowdsec Container Into Crowdsec Security Engine

Follow the Crowdsec post-install instructions to enroll the Crowdsec docker container in Crowdsec Security Engine.

CrowdSec Console | CrowdSec
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.

For when you get to the Enrollment Key part of the guide, modify the given command like below:
$ docker exec crowdsec cscli console enroll <enrollment key>
Once complete, refresh the Engines page and accept the new Engine.

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 need 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:
$ docker exec crowdsec cscli decisions delete --ip <your public ip address>

Create Your NPMPlus Hosts

Go back to your NPMPlus web admin page at https://<tailscale IP>:81
Next, create a proxy host for your previously created authentication subdomain:
Set the scheme to HTTPS, domain/ip to 127.0.0.1, port to 9443.
Enable Websockets support.
Set TLS to Create new Certificate for your base public domain.
Check Force HTTPS, Enable HSTS and security headers, Enable Brotli, Enable HTTP/3-Quic.
Click Save.

Next, create a proxy host for your base public domain:
Set scheme to HTTP, domain/ip to 127.0.0.1, port to 8081.
Enable Websockets support.
Set TLS to use your previously created certificate.
Check Force HTTPS, Enable HSTS and security headers, Enable Brotli, Enable HTTP/3-Quic.
Under the Advanced tab, enter the below text and then click Save.

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;
    }
}
# 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;
    proxy_set_header Host $http_host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;

    ##############################
    # 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              https://localhost:9443/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;
}

The above steps tells NPMPlus to direct requests for your authentication subdomain (we are using auth.<your public domain>.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.

Set Up Your Authentik Container

Shut down your docker containers, then edit the docker-compose.yml file.
$ docker compose down
$ nano /opt/ghost/docker-compose.yml

Append the below text to the end of the file:

  postgresql:
    env_file:
    - .env
    environment:
      POSTGRES_DB: ${PG_DB:-authentik}
      POSTGRES_PASSWORD: ${PG_PASS:?database password required}
      POSTGRES_USER: ${PG_USER:-authentik}
    healthcheck:
      interval: 30s
      retries: 5
      start_period: 20s
      test:
      - CMD-SHELL
      - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
      timeout: 5s
    image: docker.io/library/postgres:16-alpine
    restart: unless-stopped
    volumes:
    - database:/var/lib/postgresql/data
  server:
    command: server
    depends_on:
      postgresql:
        condition: service_healthy
    env_file:
    - .env
    environment:
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.0}
    ports:
    - ${COMPOSE_PORT_HTTP:-9000}:9000
    - ${COMPOSE_PORT_HTTPS:-9443}:9443
    restart: unless-stopped
    volumes:
    - ./media:/media
    - ./custom-templates:/templates
  worker:
    command: worker
    depends_on:
      postgresql:
        condition: service_healthy
    env_file:
    - .env
    environment:
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.0}
    restart: unless-stopped
    user: root
    volumes:
    - /var/run/docker.sock:/var/run/docker.sock
    - ./media:/media
    - ./certs:/certs
    - ./custom-templates:/templates
volumes:
  database:
    driver: local

Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.

Enter the below commands to generate passwords and change some values for your .env file:
$ echo "PG_PASS=$(openssl rand -base64 36 | tr -d '\n')" >> .env
$ echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')" >> .env
$ echo "AUTHENTIK_ERROR_REPORTING__ENABLED=true" >> .env

Next, start up your docker containers again:
$ docker compose up -d

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.

Once the containers are fully up and the port forward is working, manage your authentik page by going to https://auth.<your public domain>.com/if/flow/initial-setup/
Set a new password when prompted. Once done, you should be at the Authentik admin page.

Set the Output settings by going to Applications > Outposts. Make sure "authentik_host" is set to https://auth.<your public domain>.com

Set the Provider settings by going to Applications > Providers > Create > Proxy Provider
Set the Provider name to something like "ghost" (the name is up to you. just make sure you remember it).
Select Forward auth (domain level)
Set "Authentication URL" to https://auth.<your public domain>.com
Set Cookie Domain to <your public domain>.com
Open "Advanced Protocol Settings" and add the below to "Unauthenticated URLs"

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

Next, create the Application by going to Applications > 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.

Go to the Outposts tab again and assign the application to the outpost.

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 > Users > click "Edit" next to the admin user > change the username. Set up MFA by clicking the gear icon in the top-right > MFA Devices > Enroll. I'd recommend enrolling a TOTP device so you can use an authenticator app on your phone as MFA.

Set Up Your Ghost Container

Bring your docker containers down once more, then edit your docker-compose.yml file.
$ docker compose down
$ nano docker-compose.yml

Add the below text to the docker-compose file. Make sure you change https://<your public domain>.com to your public domain:

version: "3.3"
services:
  ghost-frontend:
    image: nginx:1.15-alpine
    container_name: ghost-frontend
    restart: unless-stopped
    volumes:
      - ./nginx/ghost.conf:/etc/nginx/conf.d/ghost.conf
    ports:
      - 8081:8081
    depends_on:
      - ghost-app
    networks:
      - ghost

  ghost-app:
    container_name: ghost-app
    image: ghost:latest
    restart: always
    depends_on:
      - ghost-mysql
    environment:
      url: https://<your public domain>.com
      database__client: mysql
      database__connection__host: ghost-mysql
      database__connection__user: mysql-user
      database__connection__password: mysql-password
      database__connection__database: ghost
    volumes:
      - ./content:/var/lib/ghost/content
    networks:
      - ghost

  ghost-mysql:
    container_name: ghost-mysql
    image: mysql:8
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: mysql-root-password
      MYSQL_USER: mysql-user
      MYSQL_PASSWORD: mysql-password
      MYSQL_DATABASE: ghost
    volumes:
      - ./mysql8:/var/lib/mysql
    networks:
      - ghost

networks:
  ghost:
    name: ghost
    driver: bridge

Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.

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 <your public domain> to your domain that Ghost will run on.
$ mkdir nginx
$ nano /opt/ghost/nginx/ghost.conf

server {
        listen 8081;
        server_name www.<your public domain>.com;
        return 301 $scheme://<your public domain>$request_uri;
}
server {
        listen         8081;
        server_name    <your public domain>.com;

    location / {
        proxy_pass http://ghost-app:2368;
        proxy_set_header Range $http_range;
        proxy_set_header If-Range $http_if_range;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $host;

# The lines below are commented out for compatibility due to redirect errors when adding a another reverse proxy in fr>

#       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#       proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Press CTRL+O to save, ENTER to confirm the file name, then CTRL+X to exit nano.

Next, bring your docker containers back up.
$ docker compose up -d

Ensure docker containers are all running without issue:
$ docker ps

Test

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.

Next, test the Authentik admin page by going to https://<your public domain>.com/ghost
You should be automatically redirected to https://auth.<your public domain>.com
Log in with your previously created authentik admin credentials.
Upon success you should be redirected to the regular ghost web admin page.

From here, create a web admin password for Ghost. Once complete, you are now all set and free to set up your website.

Maintenance

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 containers are up to date (especially Crowdsec), the log activity looks okay, and that there is a good backup plan in place.

Backing Up Your Server

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).

To back up your Ghost data, you can follow this guide:

How Can I Backup My Site Data? - Ghost Developer Docs
Learn how to backup your self-hosted Ghost install

Ubuntu Security Updates

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.

To install the latest security updates, SSH into your Ubuntu server, then run the below command:
$ sudo apt-get update && sudo apt-get upgrade

You may also need to reboot the server once security updates are completed. You will be prompted to do so after updates are completed.

Docker Container Updates

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.

To install the latest Docker container images, SSH into your Ubuntu server, then run the below command:
$ su ghostuser
$ cd /opt/ghost && docker compose down && docker compose pull && docker compose up -d
$ cd /opt/npm && docker compose down && docker compose pull && docker compose up -d

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:
$ docker container prune

Review Log Activity

In a nutshell, Docker containers run as users on your host system. If exploited, then attackers can run commands as the user the docker containers 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.

How to Audit Ubuntu System Logs for Suspicious Activity (2025 Cybersecurity Guide) | Markaicode
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.

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.

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.