- servers
- knowlege-backup
- meta
- cd
- forgejo
- git
How This Blog is Deployed
I mentioned last month I had changed the underlying tech driving this blog to be primary written in golang. I may release the structure of that application at some point, but for now I thought I would document how I deploy this service to my VPS.
My VPS is hosted on hetzner, I try to host most of my external-facing things there to avoid my in home NAS from being too much of an attack surface. My previous deployment process was faily simple but not pipeline-driven.
Previous Deploys
- Write new content or add a new feature
- run
make build - run
make sync
This did a docker build which exported the contents of the static site generator’s output to a local folder, then make sync ran rsync to push the changes to a folder on my vps. My site at that time was a nginx service sitting behind my nginx proxy.
I wanted to keep a similiar process with the rewrite, but I wanted it to be backed by a pipeline, so I could write anywhere with access to my git server, and deploy with a push. I also wanted this to be much more secure and locked down than my previous action, which involved my primary user copying files to the service. This user had full sudo access to the machine, which is not ideal security practice.
After some research and experimentation, here is where things stand.
Server User Setup
- I have a user
deployon my VPS. My VPS is namedext(yes this is a departure from my naming system). This user has a home directory in my service folder.
sudo useradd -M -d /var/docker-service/blog/ -s /usr/sbin/nologin deploy
sudo chown -R deploy:deploy /var/docker-service/blog/
- Then I created a wrapper script named
/usr/local/bin/blog-composefor running docker-compose commands in that directory. `
#!/bin/bash
set -e
cd /var/docker-service/blog
exec docker compose -f /var/docker-service/blog/docker-
compose.yml "$@"
- Make this script executable
sudo chmod +x /usr/local/bin/blog-compose
- Now you need to allow that script to be run with sudo as your user. This allows this user to perform docker commands without needing to be a member of the docker group
sudo visudo -f /etc/sudoers.d/blog
### contents
deploy ALL=(root) NOPASSWD: /usr/local/bin/blog-compose
deploy ALL=(root) NOPASSWD: /usr/local/bin/blog-compose *
- Finally, you need to generate an ssh key and add it to
/var/docker-service/blog/.ssh/authorized_keyfile. I added a passphrase for additional security. There are 1 million articles on this on the web, so I won’t repeat those details.
Docker Compose
I run all my services as docker containers. This is partly for encapsulation and partly because it makes adding and removing services simple. I front the containers with nginx-proxy and it’s acme-companion, which makes SSL super low maintenance. Because of this, the site is run as a Docker container. Here is the corresponding compose file.
Notes: I use scratch as my base image for running the site, this makes for a smaller image and reduces potential attack surfaces. Because of this I am mounting the root SSH keys and timezone data into the container. Without these, requests to my mealie instance fail as it cannot validate the ssh key and using a timezone to gatekeep post publishing fails as it cannot find timezone data.
services:
connermccall.com:
# I run a docker registry internally for personal projects
image: registry.connermccall.me/personal/connermccall.com:latest
environment:
- "URL=connermccall.com"
# Recipe retrieval requires a key
- "MEALIE_API_KEY=${MEALIE_API_KEY}"
# there are the important things, this tell the proxy and companion what domains we are serving this site on.
- "LETSENCRYPT_EMAIL=conner@connermccall.com"
- "LETSENCRYPT_HOST=connermccall.com,www.connermccall.com"
- "VIRTUAL_HOST=connermccall.com,www.connermccall.com"
- "VIRTUAL_PORT=8080"
networks:
- network-nginx-proxy
restart: "unless-stopped"
volumes:
- "/etc/localtime:/etc/localtime:ro"
- "/etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro"
- "/usr/share/zoneinfo:/usr/share/zoneinfo:ro"
logging:
driver: syslog
options:
tag: "docker/connermccall-com"
# I mark this as external as it is created with tofu elsewhere. The container has to be on this network for the proxy and companion to recognize it
networks:
network-nginx-proxy:
external: true
CI Setup
I use Forgejo as my forge. It has been rock solid for the past several years and I have a runner configured on my local NAS. This gives me more compute than my VPS provides along with plenty of disk space and access to local resources.
I will walk through the pertinent parts of my script, the full version is at the end of this post.
The first five steps are checking out the container, setting up build details, and actually building and pushing the container. Once that is done we need to update the running container on my VPS, which we do through SSH.
I chose to protect my ssh key with a passphrase. This passphrase is stored in 1Password. They provide a github action for fetching secrets. This takes a Service Token and then a map of keys to values. In this case we map the password field from the secret named ssh_passphrase in the forgejo vault to the output SSH_KEY_PASSPHRASE.
- name: Fetch Passphrase
id: fetch_passphrase
uses: https://github.com/1password/load-secrets-action@v4
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ env.OP_SERVICE_ACCOUNT_TOKEN }}
SSH_KEY_PASSPHRASE: op://forgejo/ssh_passphrase/password
Once we have the passphrase we need to update the image and recreate the container on my vps. I used an ssh action to accomplish this. We need to pass the ssh key, the passphrase, the hostname, user, and ideally the host fingerprint to the command. Finally the command must be run with sudo so that the user has access to the necessary permissions for interacting with docker.
- name: Update compose
uses: https://github.com/appleboy/ssh-action@v1.2.5
with:
username: ${{ vars.USER }}
host: ${{ secrets.HOST }}
key: ${{ secrets.SSH_KEY }}
passphrase: ${{ steps.fetch_passphrase.outputs.SSH_KEY_PASSPHRASE }}
fingerprint: ${{ secrets.HOST_FINGERPRINT}}
script: |
sudo /usr/local/bin/blog-compose up -d --pull always --force-recreate
Gotchas
- Ensure you set the permissions correctly on the .ssh directory and authorized key file.
ext:~$ chmod 700 .ssh/
ext:~$ chmod 600 .ssh/authorized_keys
- I technically named my user something different which included a dot in their username. This tripped up sudo when I added the file user
visudoers.dwith the actual username.sudoignores files with dots in their name. The easy fix was renaming the file with a-instead of a dot. But to confirm that you successfully granted the user permission you can run.
sudo -l -U deploy
- You may need to try a couple different host keys to figure out which one will work. I initially used the
ssh_host_ecdsa_keyfingerprint and the pipeline failed. I settled on thessh_host_ed25519_key.
ext:~$ ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
256 SHA256:XXXX....XX root@ext (ED25519)
You need the 2nd string, including the SHA256 part. SHA256:XXXX....XX
Full Pipeline
name: Build and Deploy New Version
env:
IMAGE_NAME: connermccall.com
REPO: registry.connermccall.me/personal
on:
push:
branches:
- 'main'
jobs:
docker-build:
name: Build and push docker image
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REPO }}/${{ env.IMAGE_NAME }}
flavor: latest=auto
tags: |
type=ref,event=branch
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Resolve version info
id: version
run: |
echo "GIT_HASH=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: .
cache-from: type=registry,ref=${{ env.REPO }}/${{ env.IMAGE_NAME }}:latest
cache-to: type=inline
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
ASSET_HASH=${{ env.GIT_HASH }}
- name: Fetch Passphrase
id: fetch_passphrase
uses: https://github.com/1password/load-secrets-action@v4
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ env.OP_SERVICE_ACCOUNT_TOKEN }}
SSH_KEY_PASSPHRASE: op://forgejo/ssh_passphrase/password
- name: Print masked secret
run: 'echo "Secret: ${{ steps.fetch_passphrase.outputs.SSH_KEY_PASSPHRASE }}"'
- name: Update compose
uses: https://github.com/appleboy/ssh-action@v1.2.5
with:
username: ${{ vars.USER }}
host: ${{ secrets.HOST }}
key: ${{ secrets.SSH_KEY }}
passphrase: ${{ steps.fetch_passphrase.outputs.SSH_KEY_PASSPHRASE }}
fingerprint: ${{ secrets.HOST_FINGERPRINT}}
script: |
sudo /usr/local/bin/blog-compose up -d --pull always --force-recreate