Skip to content

Building a Self-Hosted Blog: A Journey of Patience, Docker, and Coffee

The WhyRecently, I felt the urge to share my knowledge with others, but I wasn’t sure how. Being someone who avoids social media like the plague and has a natural aversion to cameras (seriously, who enjoys seeing themselves on video?), I opted for the written format.## The WhatOnce I decided on text, I dove into research. Holy cow, there were too many options! My first filter was simple: it had to be free because this is a hobby, not a money-making scheme (and let’s be honest, Portuguese salaries aren’t exactly breaking records).When I first arrived in Portugal, spending three months without my wife, I needed something to keep my mind occupied. I discovered the wonderful world of self-hosted solutions - a way to have complete control over technology, run it on cheap hardware, and improve my IT skills in networking, programming, and most importantly, patience.## The DecisionWith filters set to text-based, open-source, and self-hosted, the options narrowed significantly. After countless YouTube videos (my browser history was a mess), it came down to WordPress vs. Ghost. Both could run on a Raspberry Pi 5, but being a complete website noob, I chose the simpler option: Ghost.For those curious, Ghost is an open-source project similar to WordPress, offering monetization options and easy configurations (perfect for a frontend-challenged person like me who considers UI a dark art). Check out Ghost’s website if you want to dive deeper.## The RequirementsBefore we dive into the hell that awaits (involving Docker, certificates, networking, nginx, and hours of chair-warming), here’s what you’ll need:1. A Raspberry Pi (I went with the 5 with 8GB RAM because why not?)

  1. A domain (unless you want to keep your brilliant thoughts to yourself)
  2. Ubuntu Server 24 LTS running on the Pi (yes, I’m an Ubuntu person)
  3. Internet connection (duh)
  4. The patience of a saintSo look, I was that person who always installed stuff straight on my computer, even though Docker kept giving me the eye. Then, with absolutely zero knowledge about websites or Docker, I went full YOLO and decided to build a website using both Docker AND Ghost. Because why not make things extra spicy?Before we dive in, you’ll need a Raspberry Pi running Ubuntu Server 24.04. You also need to SSH into it (and no, I’m not teaching you that - Google exists for a reason). Oh, and if you’re still using password login instead of SSH keys… I have nothing to say to you.Sure, you could spend the next century reading the endless Docker docs over at Ghost’s Docker Hub. But let’s be real - you’re here because you want the TL;DR version, and I got you fam.## The Adventure BeginsFirst things first, let’s be smart about your network setup. Set a fixed IP for your device (in this case, the Raspberry Pi) in your local network. Trust me, you don’t want to play “find my Pi” every time you need to access your apps. With that sorted, let’s move on to the fun stuff.### Domain & DNS SetupI chose Cloudflare for my domain because digital security was on my study list (trying to be a responsible adult here). After some research and being super careful with pricing because, let’s face it, spending money sucks, I got theobredemann.com for just 10 dollars per year. That’s less than a dollar per month - even my cheap self could live with that!The cool thing about Cloudflare? They’re massive. We’re talking about handling 80.9% of all websites (according to Google). So if someone hacks them, my little blog will be the least of their concerns. Plus, they offer tons of security features and have so much documentation and tutorials that you can find help for literally anything. Seriously, whatever you need, it’s there.For DNS configuration, I added two records:1. An A record for theobredemann.com pointing to my home IP (find yours at checkip.amazonaws.com)
  5. A CNAME record for www pointing to theobredemann.comBoth with Proxied option enabled because security is cool.Easy peasy, right? Well… maybe not. What happens when your IP changes because your router decided to take a break, or because your internet provider just loves to mess with you? Don’t worry - we can handle this using Cloudflare’s APIs, and of course I’ll help you because I’m such a nice person.### Automatic IP UpdatesSince ISPs love to change our IPs at the most inconvenient times, we need an automatic update solution. Here’s how:1. Create a Cloudflare API token (Profile -> API Tokens -> Create Token -> Edit zone DNS) with read and write permissions
  6. Get your Zone_ID from the domain dashboard
  7. Grab each DNS record ID using:bash curl https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records \ -H "X-Auth-Email: $CLOUDFLARE_EMAIL" \ -H "X-Auth-Key: $CLOUDFLARE_API_KEY" Create an update script - Thank me in your thoughts, I kind suffered a lot to make it work:bash sudo nano /usr/local/bin/update_cloudflare_dns.sh#!/bin/bash# Cloudflare API configuration AUTH_TOKEN="your_token" ZONE_ID="your_zone_id"# DNS Records to update - add your records here declare -A DNS_RECORDS=( ["dummy.com"]="blablablabla" )# Get current public IP CURRENT_IP=$(curl -s -X GET https://checkip.amazonaws.com)# Update each DNS record for DOMAIN in "${!DNS_RECORDS[@]}"; do RECORD_ID="${DNS_RECORDS[$DOMAIN]}"curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \ -H "Authorization: Bearer $AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"type\":\"A\", \"name\":\"$DOMAIN\", \"content\":\"$CURRENT_IP\", \"ttl\":1, \"proxied\":true }"echo "$(date): Updated $DOMAIN to $CURRENT_IP" >> /var/log/cloudflare_update.log done sudo chmod +x /usr/local/bin/update_cloudflare_dns.sh Add it to crontab to never touch it again:bash sudo crontab -e */5 * * * * /usr/local/bin/update_cloudflare_dns.sh ## Docker Setup### Directory StructureFirst, let’s create our workspace (I chose /srv because why not):bash sudo mkdir -p /srv/containers/{ghost,nginx} sudo mkdir -p /srv/containers/ghost/{data,settings} sudo chown $USER:$USER -R containers ### Network SecurityTo make potential hackers’ lives a bit harder (because we’re nice like that), I created separate frontend and backend networks. The backend hosts Ghost and NGINX, while the frontend only has NGINX for local configuration. Having NGINX and Ghost on the same network also makes my life easier when setting things up.### Docker-ComposesFor Ghost, it’s pretty straightforward without any complicated configurations to mess with.Since I was already playing with docker-compose, I decided to set up NGINX too. I went with NPM (NGINX Proxy Manager) because it has a nice GUI and it’s way more fun to click buttons than type commands.```yaml

Custom Docker Networks

networks: frontend: driver: bridge backend: driver: bridgeservices:

NPM (NGINX Proxy Manager)

npm: image: ‘jc21/nginx-proxy-manager:latest’ container_name: npm restart: unless-stopped ports:

  • ‘80:80’
  • ‘443:443’
  • ${WEBPORT}:${WEBPORT} environment: DB_SQLITE_FILE: “/data/database.sqlite” DISABLE_IPV6: ’true'

Security Settings

SECURE_PROXY_SSL_HEADER: “true” REAL_IP_HEADER: “X-Real-IP” REAL_IP_RECURSIVE: “true”

Optimization

WORKER_PROCESSES: “auto” WORKER_CONNECTIONS: “1024” volumes:

  • ${CONTAINER_PATH}/nginx/data:/data
  • ${CONTAINER_PATH}/nginx/letsencrypt:/etc/letsencrypt
  • ${CONTAINER_PATH}/nginx/logs:/var/log/nginx networks:
  • frontend
  • backend healthcheck: test: [“CMD”, “/usr/bin/check-health”] interval: 30s timeout: 3s retries: 3# Ghost Blog ghost: image: ‘ghost:latest’ container_name: ghost restart: unless-stopped environment:

Blog URL

url: ${BLOG_URL}

General configs

NODE_ENV: ‘production’

SQLite DB Configs

database__client: ‘sqlite3’ database__connection__filename: ‘/var/lib/ghost/content/data/ghost.db’

Server Configs

server__port: ${SERVER_PORT} server__host: ${SERVER_HOST}

Limits and Timeouts

privacy__useTinfoil: true privacy__useUpdateCheck: true

Paths

paths__contentPath: ‘/var/lib/ghost/content’

Logging

logging__level: ‘info’ logging__rotation__enabled: true logging__rotation__count: 10

Cache Optimizations

caching__frontend__maxAge: 3600 caching__images__maxAge: 31536000

Security configs

http__responseHeader__powered: false http__responseHeader__server: false volumes:

  • ${CONTAINER_PATH}/ghost:/var/lib/ghost/content networks:
  • backenddepends_on:
  • npm ### Port ConfigurationOpen necessary ports (assuming UFW is active and SSH is already allowed) - I recommend always using comments at it - *Pro tip: ALWAYS add comments when opening ports in UFW (assuming you have it enabled and port 22 open for SSH). Trust me, when you have a bunch of stuff running, trying to remember what each port is for becomes a nightmare. Here's how to open them*:bash sudo ufw allow 80/tcp comment “NGINX” sudo ufw allow 443/tcp comment “NGINX” sudo ufw allow 81/tcp comment “NGINX” After following all these detailed steps, just run everything:bash cd /srv/containers/ && docker-compose up
2.  Choose LetsEncrypt (because who has time for manual configuration?)
3.  Add your domain
4.  Select DNS challenge
5.  Generate and forget about it for 3 months### Site ConfigurationTo make your site accessible to the outside world (and not just within my cozy little local network), I need to configure the host. In NPM, follow these steps:In NPM, go to Hosts -> Proxy Hosts -> Add proxy host:1.  Basic setup:
-   Domain Names: your.domain.com
-   Scheme: http
-   Forward Hostname: ghost (container name)
-   Enable all the fancy options (Cache assets, Block exploits, WebSocket support)
2.  SSL configuration:
-   Select your certificate
-   Enable all security options (Force SSL, HTTP/2, HSTS)
3.  Advanced configuration (because we're paranoid):```js
location / {
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Content-Security-Policy "upgrade-insecure-requests";
proxy_pass $forward_scheme://$server:$port;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_set_header Host $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 $scheme;
}
```## Security Headers Explained (For The Curious)-   **X-Frame-Options**: Prevents clickjacking attacks by controlling iframe embedding
-   **X-Content-Type-Options**: Stops browsers from getting creative with MIME types
-   **X-XSS-Protection**: Adds extra XSS protection because we're cautious
-   **Referrer-Policy**: Controls information sharing between sites
-   **Content-Security-Policy**: Forces HTTPS because it's 2025, come on## Proxy Configuration Breakdown-   **proxy\_pass**: Tells traffic where to go
-   **proxy\_http\_version**: Uses HTTP/1.1 for persistent connections
-   **Various headers**: Maintains original request information and enables real-time communication## Final StepsAccess your site's admin panel at yourdomain.com/ghost and create your admin account. Please use a strong password unless you want random internet people redecorating your blog.After that, it's all about making it pretty - themes, colors, and whatever else floats your boat.If you've made it this far, congratulations! You're either really determined or really bored. Either way, you now have a secure, self-hosted blog running on a Raspberry Pi. Go forth and share your wisdom with the world!*Remember: If something breaks (and it will), there's always ChatGPT (or DeepSeek if you are Chinese / China fan) to help you debug. Because let's face it, finding blogs at 2025 is too old school.*