Running ghost blog with nginx reverse proxy and let’s encrypt
I’ve been using the blog platform ghost for hosting tadbit, and now twdspodcast, this is a quick brain dump on how I host the blog. It’s simply nginx reverse proxy, ghost platform backed by mysql, and now the reverse proxy gets it’s cert from let’s encrypt. This is let’s get this to work fast and re-iterate later.
I create a gcp instance only running container optimized OS, allow http/https with a public IP. This public IP becomes A record in my domain records.
@ A 1h ##.##.##.###
I ssh into the instance in the UI
Environment
First I create a .env
file to keep all the variables I need for my setup.
domain=example.org
email="admin@$domain"
mysql_local_pass=STUFFMAN
data_path="/tmp/data"
domain_short=$(echo $domain | tr '.-' '_')
quick note: /tmp/ is not a good path for saving data… in Running Ghost on GCP I create a disk that I mount to /mnt/disks/$VM_NAME-data, I omit this in this setup.
source .env
Now that this is sourced, let’s move on to running the blog platform itself.
NOTE: mail provider here is mailgun.com, create a domain and get username and password. More info
House keeping
We create a network, this network is super helpful so we don’t manage ips and routes for container to container
docker network create znet
mkdir -p "$data_path"
sudo chown $USER "$data_path"
sudo chmod 755 "$data_path"
mkdir -p "$data_path/${domain_short}_ghost"
mkdir -p "$data_path/mysql"
Ghost
Run an instance of mysql first.
docker run --name=mysql --restart=always -d -p 3306:3306 \
--net=znet \
-v $data_path/mysql:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD=$mysql_local_pass mysql
use those credentials to then run the blog
docker run --name=${domain_short}_ghost --restart=always -d \
-v $data_path/${domain_short}_ghost:/var/lib/ghost/content \
-p 3001:2368 \
--net=znet \
-e url=http://$domain \
-e database__client=mysql \
-e database__connection__host=mysql \
-e database__connection__user=root \
-e database__connection__password=$mysql_local_pass \
-e database__connection__database=${domain_short}_ghost \
-e mail__transport="SMTP" \
-e mail__from="$mail_name <$mail_username>" \
-e mail__options__service="SMTP" \
-e mail__options__host="$mail_provider" \
-e mail__options__port="587" \
-e mail__options__auth__user="$mail_username" \
-e mail__options__auth__pass="$mail_password" \
ghost
quirk of mysql
I did run into a small issue with how ghost authenticates against mysql, it’s well documented: https://github.com/mysqljs/mysql/issues/1507
this was the quick fix
cat << EOF >> $data_path/1507-fix.sql
ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY '$mysql_local_pass';
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '$mysql_local_pass';
SELECT plugin FROM mysql.user WHERE User = 'root';
commit;
EOF
run quick fix file
docker exec -i mysql sh -c 'mysql -u root -p'"${mysql_local_pass}"'' < $data_path/1507-fix.sql
in my case after the quick fix, restart ghost docker restart ${domain_short}_ghost
there’s a new problem that occurs in the newer versions of ghost. It thinks we have performed a migration, here’s a quick fix for it.
cat << EOF >> $data_path/migration-lock-fix.sql
USE ${domain_short}_ghost;
UPDATE migrations_lock set locked=0 where lock_key='km01';
commit;
EOF
run quick fix file
docker exec -i mysql sh -c 'mysql -u root -p'"${mysql_local_pass}"'' < $data_path/migration-lock-fix.sql
in my case after the quick fix, restart ghost docker restart ${domain_short}_ghost
Nginx + certbot
First we download the recommended ssl configs for nginx provided by certbot and ssl params, then we make a make belief cert that we later delete, we configure nginx and start it with make belief cert. Now when that’s complete we use certbot to gain staging cert, when successful we get production certs.
Download SSL Conf and ssl-dhparams.pem
Download required files from github.
mkdir -p "$data_path/ssl"
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/ssl/options-ssl-nginx.conf"
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/ssl/ssl-dhparams.pem"
make belief certificate
mkdir -p "$data_path/ssl/live/$domain"
use the nginx pod to run openssl for obtaining make belief cert
docker run --rm -it \
-v $data_path/ssl:/etc/letsencrypt -v $data_path/www:/var/www/certbot \
nginx sh -c "mkdir -p /etc/letsencrypt/live/$domain && openssl req -x509 -nodes -newkey rsa:2048 -days 1\
-keyout '/etc/letsencrypt/live/$domain/privkey.pem' \
-out '/etc/letsencrypt/live/$domain/fullchain.pem' \
-subj '/CN=localhost'"
configure nginx reverse proxy for ghost w/ ssl
Setup the file
mkdir -p $data_path/nginx/
vi $data_path/nginx/$domain_short.conf
Few things to know
http://${domain_short}_ghost:2368
is the path direct to the ghost container, because they shareznet
network- many settings look like they are missing but they come from
options-ssl-nginx.conf
. as we downloaded earlier. - in the config below please make sure to replace
example.org
by your domain andexample_org
by your domain short
server {
listen 443 ssl;
server_name example.org;
# this include is the recommended ssl settings by let's encrypt
include /etc/letsencrypt/options-ssl-nginx.conf;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomain" always;
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
# this is the dhparam we downloaded from the onset
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
ssl_certificate /etc/letsencrypt/live/example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.org/privkey.pem;
# sometimes exporting this is useful too
access_log /var/log/nginx/example.org.access.log;
location / {
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;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Fix the “It appears that your reverse proxy set up is broken" error.
proxy_pass http://example_org_ghost:2368;
proxy_read_timeout 90;
proxy_redirect http://example_org_ghost:2368 https://example.org;
}
}
start nginx
this stage is important because here we are testing the vailidity of our nginx reverse proxy settings. So we test that the certs are placed in the correct place and that requests to reverse proxy reaches the app.
docker run --name=nginx --restart=always -d \
-v $data_path/ssl:/etc/letsencrypt -v $data_path/www:/var/www/certbot \
-v $data_path/nginx:/etc/nginx/conf.d \
-p 80:80 -p 443:443 \
--net=znet \
nginx
now we delete the dummies with confidence
docker exec -it nginx sh -c "rm -Rvf /etc/letsencrypt/live/$domain && \
rm -Rvf /etc/letsencrypt/archive/$domain && \
rm -Rvf /etc/letsencrypt/renewal/$domain.conf"
configure nginx pre-stage
Setup the file
mkdir -p $data_path/nginx/
vi $data_path/nginx/validate.conf
we configure nginx to allow for a callback to /.well-known/acme-challenge/
. this will allow the staging command to verify ownership of the domain
server {
listen 80;
server_name _;
location / {
return 301 https://$host$request_uri;
}
location /.well-known/acme-challenge/ {
root /var/www/certbot;
allow all;
try_files $uri =404;
}
}
stage cert
We run through one example in staging
using --register-unsafely-without-email --agree-tos
. This confirms to us letsencrypt ability to generate a valid cert for the site, and also guards against quotas.
docker run -it --rm \
-v $data_path/ssl:/etc/letsencrypt -v $data_path/www:/var/www/certbot \
certbot/certbot \
certonly --webroot \
--webroot-path=/var/www/certbot \
--register-unsafely-without-email --agree-tos \
--staging \
-d $domain -d www.$domain
prod cert
we now run this finally command to get the actual cert
docker run -it --rm \
-v $data_path/ssl:/etc/letsencrypt -v $data_path/www:/var/www/certbot \
certbot/certbot \
certonly --webroot \
--webroot-path=/var/www/certbot \
--email $email --agree-tos --no-eff-email \
-d $domain -d www.$domain
prod cert renew
docker run -it --rm -v $data_path/ssl:/etc/letsencrypt -v $data_path/www:/var/www/certbot \
certbot/certbot \
renew --webroot --webroot-path=/var/www/certbot
docker exec -it nginx nginx -s reload
docker logs nginx -f
At this stage I could docker run with a shell command wrapped in sleep to keep validating the cert and auto renew it, but since there’ll have to be a subsequent post where I try to automate this, we’ll leave it as it for now.
Temp automated cert renewal
I got tired of having to log in and do this, so this is a good way to fake cron on container optimized os..
Run docker, with entrypoint, and sleep..
docker run --name renew -d -v $data_path/ssl:/etc/letsencrypt -v $data_path/www:/var/www/certbot --entrypoint="/bin/sh" certbot/certbot -c 'trap exit TERM; while :; do certbot renew --webroot --webroot-path=/var/www/certbot; sleep 12h & wait ${!}; done;'