Setting up Ghost on a 6$ DigitalOcean droplet
Introduction
I've been tinkering around with the idea of starting a blog for a couple of months now - aimlessly and vaguely coming up with a bunch of ideas to write about and then abandoning them halfway, much like my abandoned tech side projects. I spent a couple of days researching the differences between various blogging platforms available, but the idea of self hosting resonated the most with me- I wanted complete control over my content and since I've long been a supporter of Open Source software, I decided on Ghost.
I have an old Raspberry Pi Zero W set up as a Pi-hole to block ads within my home network. It's been one of the best quality of life on the internet investments I've made in the last few years. I briefly considered turning this raspberry pi into my home server to host Ghost, but the idea of people accessing content on a device in my home through the public internet freaked me out psychologically, even though I knew I could make this perfectly safe. So I rented a DigitalOcean droplet for 6$ a month - 1 GB RAM, 25GB SSD and 1000GB transfer capacity and bought the same domain name as my GitHub tag on Namescheap, installed Ubuntu 22.04 on my droplet and got to work.
Firstly, I registered the domain name on DigitalOcean, and set up custom DigitalOcean name servers on the registrar on Namescheap because I wanted DigitalOcean to handle my DNS settings. Then I set up an A record (address record) that redirects all traffic to blog.snowyhiker.com to my droplet's IP address.
What does this mean? When we access a website on the internet, a domain name needs to be translated into an IP address that the machine can understand and redirect the users to. A DNS or a Domain Name Server does this domain name to IP address translation. So when I register my IP address, Digital Ocean basically tells the world that its servers contains the translation logic to reach my blog. My domain registrar (Namescheap) does two things -
- Registers my domain name to me
- Decides who controls the DNS records
To complete this step, I added DigitalOcean's custom name servers to my Namescheap registrar. The process looks like this:
User types in blog.snowyhiker.com (who controls this domain?) --——> Namescheap replies "Ask DigitalOcean" --——> DigitalOcean responds with the IP that corresponds to the domain name. Now my blog can be pinged!
xyz@-xyzs-Air ~ % ping blog.snowyhiker.com
PING blog.snowyhiker.com (167.172.16.130): 56 data bytes
64 bytes from 167.X.X.X: icmp_seq=0 ttl=45 time=62.446 ms
64 bytes from 167.X.X.X: icmp_seq=1 ttl=45 time=65.220 ms
64 bytes from 167.X.X.X: icmp_seq=2 ttl=45 time=63.282 ms
64 bytes from 167.X.X.X: icmp_seq=3 ttl=45 time=64.803 ms
64 bytes from 167.X.X.X: icmp_seq=4 ttl=45 time=60.329 ms
^C
--- blog.snowyhiker.com ping statistics ---
5 packets transmitted, 5 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 60.329/63.216/65.220/1.759 ms
xyz@xyzs-Air ~ %
Yay! Now my domain is ping-able, but it cannot be reached via a broswer because my droplet doesn't have anything set up to serve this request.
Securing the server
Any public facing IP is relentlessly attacked from the second it is exposed. It's nothing personal. Bot networks repeatedly scan the entire IPv4 address space (around 4 billion addresses) hoping to find abandoned or unprotected entry points to brute force into. You can usually expect the first malicious SSH login attempt to occur within minutes of bringing your server up. So the next order of things was to secure my server.
A lot has been written on how to secure linux servers, both production grade and hobby project environments. Attackers don't care who you are or if you have anything to hide on your servers, they'll try to get in anyway. I looked around for resources I can follow and found this excellent repository documenting each step cleanly and thoroughly. I modified this for my use. Let's go through the important steps one by one.
1. Disable password login and enable login access only through SSH keys
Having password authentication for your login can be hacked. If you used password login to initially set up your server, it's wise to remove this and enable log in through SSH keys. While the server contains the public key, only you can login since you have the private key on your local PC. Make a copy of the private key and save it somewhere secure.
1. Check if cat ~/.ssh/authorized_keys has contains the public SSH-RSA key on the server. If not, generate a pair of keys using ssh-keygen on your PC terminal and upload the public key to your DigitalOcean droplets SSH option.
2. If the key exists, then:
sudo nano /etc/ssh/sshd_config and change the password enable option to no - PasswordAuthentication no
3. Ensure Public Key authentication remains active - PubkeyAuthentication yes
4. Save the file, exit and restart ssh sudo systemctl restart ssh
Ta-da! Now you can only login via SSH Keys.
2. Disable root SSH login
Getting into linux user groups and permissions could take a while, but the basic idea is that by default when you configure a droplet, you login as root. Root permissions are "admin" level privileges, and if anyone gets access to root user on your server, they can wreck havoc on everything. The way to counter this is to create a new user, and allow login only via that user. On the server, add password protection so no "sudo" commands can be executed without explicit approval through a password. This also ensures privilege separation and prevents you from accidentally deleting your own databases, for example. A good idea before trying out the next steps is to keep another terminal logged in so you don't lock yourself out, even by mistake.
1. Login via root and create a new user
adduser user
2. Enter and confirm a password
3. usermod -aG sudo user1 - this appends user user1 to sudo privileges without disturbing the existing list.
4. Go into sudo nano /etc/ssh/sshd_config and edit the configuration such that PermitRootLogin is set to no
5. Add the SSH key to user "user" to enable login via the new user
sudo mkdir -p /home/user1/.ssh
sudo cp /root/.ssh/authorized_keys /home/user1/.ssh/
sudo chown -R user1:user1 /home/user1/.ssh
sudo chmod 700 /home/user1/.ssh
sudo chmod 600 /home/user1/.ssh/authorized_keys
6. Restart SSH sudo systemctl restart ssh
Okay - so now you should be able to login via your new user with root login disabled!
3. Enable firewall
UFW is Ubuntu's firewall. We can configure rules on the firewall to block all incoming traffic except on the ports we allow, so that unnecessary ports are not exposed to the public for exploitation.
1. Install UFW on your droplet
sudo apt install ufw -y
2. Set default permissions - allow all outgoing and deny all incoming traffic
sudo ufw default deny incoming
sudo ufw default allow outgoing
3. Allow SSH traffic
sudo ufw allow OpenSSH
4. Enable web traffic (HTTP and HTTPS) on ports 80 and 443
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
5. Enable these permissions
sudo ufw enable
Review these configs: sudo ufw status verbose
You should see something like this
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere
Incoming: deny
Outgoing: allow
Yay! Now the firewall is up and active.
4. Change default SSH port
Default SSH runs on port 22. Every scanner on earth probes port 22 trying to brute force ssh login. Changing the port of login doesn't prevent someone from hacking in, but it vastly reduces the noise from automated bots. Choose another uncommonly used port for this purpose.
While I was figuring this out, I ran into an interesting problem. Usually, SSH connections work like this:
sshd process
|
Opens port 22
|
Waits for a connection
On modern Ubuntu, it works a little differently:
systemd process
|
Opens port 22
|
Waits for connection
|
Starts sshd process only when connection arrives
In newer Ubuntu versions, systemd's ssh.socket is bound to port 22 before ssh even starts. When a connection arrives, systemd spawns sshd and gives it the already opened port, so sshd never binds with port 22 directly, unlike the earlier case where there is a direct bind. Because of this, just editing sshd_config file will not work because the port mentioned in the config file is not taken into consideration at all (its overriden by port 22 as default by systemd). To solve this, we can disable ssh.socket entirely.
1. Edit sshconfig to change the default port
sudo nano /etc/ssh/sshd_config
Change #Port 22 to Port 2222 (example for alt port)
2. Disable socket activation
sudo systemctl disable ssh.socket
sudo systemctl stop ssh.socket
3. Restart SSH
sudo systemctl restart ssh
4. Check if we are listening on port 2222 now:
sudo ss -tulpn | grep ssh
The output should look like: 0.0.0.0:2222
5. Update firewall instructions:
sudo ufw allow 2222/tcp
6. Delete the previous allow instruction on port 22
sudo ufw delete allow 22/tcp
Now log in via SSH port 2222. It should work!
5. Miscellaneous
There are a lot more things you can do to secure the server. While these were the main steps I took, here are some other safety measures you can (and should) implement -
- Keep all software on your droplet up to date.
- Regularly backup all important data on your droplet and save it outside, like on your laptop, drive or cloud. Always encrypt this data if you are moving it between servers.
- Block repeated malicious attacking IP addresses by using fail2ban.
- Diable unused services.
- Always print the last login date and time when you login, so you can easily recognise if someone other than you has snooped around.
This was fun. For my next experiment, I think it might be interesting to build a geo-spacial map of all the IP addresses trying to get into my droplet. Last I checked, with just 2 days of uptime, there were hundreds of attempts!
Installing Ghost
Now, time to install Ghost! I found Ghost to have excellent documentation, so following these steps were pretty easy. My install process stalled a few times because while 1 GB RAM is good enough to run Ghost, it may not be enough to install Ghost since the install process requires running npm to build dependencies which can temporarily consume a lot of memory in a short duration of time.
To resolve this, I had to create swap space. Swap space is basically a small carving you can create from the disk space to act as overflow memory when RAM fills up. It's slower, but it prevents the system from stalling or crashing when memory spikes occur.
I created a persistent 2 GB swap file, and then the install went through easily.
1. Allocate the file
sudo fallocate -l 2G /swapfile --> allocate 2G of space as a file
2. Restrict permissions
sudo chmod 600 /swapfile --> only root can read/write this file
3. Format it as swap
sudo mkswap /swapfile --> format this file so it can be used as swap
4. Enable the swap file
sudo swapon /swapfile
5. Make the swap persistent
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Choose to install NGINX and SSL to enable HTTPS for your domain as part of the installation process. Now head to https://example.com/ghost and set up your account, choose a theme, and you're ready to publish your first post :)
Architecture
Ghost is up and working now, but how does this actually work?
The architecture of this set up looks something like this:
Internet
│
│ HTTPS (443)
blog.snowyhiker.com
│
▼
┌───────────┐
│ NGINX │
│ Reverse │
│ Proxy │
└─────┬─────┘
│
│ forwards HTTP request
▼
http://127.0.0.1:XX
┌───────────┐
│ Ghost │
│ Node.js │
│ Server │
└─────┬─────┘
│
│ database queries
▼
┌───────────┐
│ MySQL │
│ Database │
└───────────┘
Ghost is a node.js application that runs on an internal port XX that is not visible to the outside world. When a request is made to blog.snowyhiker.com, DNS resolves the request to my droplet's public IP. My droplet receives that request on port 443, where NGINX is listening. Then NGINX takes over as the reverse proxy, and forwards the request to the internal port XX where Ghost is running. Ghost queries the database, renders the page and sends it to NGINX which then passes it to your browser via HTTPS. Pretty cool, right?
To explore how NGINX constructs the logic for forwarding your request, you can check out this file /etc/nginx/sites-available/<your domain>.conf. NGINX is an excellent open source web server, that can handle multiple concurrent connections and serve content in an optimal way. It can act as a load balancer, reverse proxy, mail proxy and HTTP cache.
To check which ports are running internal applications, run this command:
snowyhiker@ubuntu-s:~$ sudo ss -tulpn | grep "127.0.0.1"
tcp LISTEN 0 151 127.0.0.1:XX 0.0.0.0:* users:(("mysqld",pid=30601,fd=54))
tcp LISTEN 0 70 127.0.0.1:YY 0.0.0.0:* users:(("mysqld",pid=30601,fd=21))
tcp LISTEN 0 511 127.0.0.1:ZZ 0.0.0.0:* users:(("node",pid=5813,fd=24))
Conclusion
I wanted a place to write about my side projects as a way to keep myself accountable so that I can measure my progress visibly and actually complete them. I currently have random half abandoned codebases on my laptop and an empty Github so this is my first step toward my goals. Buying a domain, droplet and setting up Ghost felt like a computer networks lab assignment, a course I really enjoyed in college. I dream of building my own servers at home and hosting all my content there some day, because I fundamentally distrust Big Tech having custody of my data. So far, I'm really liking Ghost! Do sign up to get my future blog posts directly in your inbox if you want to follow along on my journey :)