Deploying with SSH

February 9, 2025

This is how I deploy my personal applications (e.g. this site and pubg.sh) to my VPS.
I like it because it’s simple, fast, and reliable.

Triggering

I want deploys to automatically happen on commits to master. I prefer building the new application directly on my VPS to avoid having to transfer files.

A GitHub Action SSHes to my VPS with a restricted command key (sometimes called a single command or single purpose key) for the associated site, which triggers the execution of a script on the VPS. This key can’t do anything else on the server, which is nice in case it’s ever exposed. It’s stored as a secret in the GitHub repository. Here’s the GitHub Action:

name: Deploy

on:
  push:
    branches: [master]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        run: |
          eval `ssh-agent -s`
          ssh-add - <<< "$SSH_PRIVATE_KEY"
          ssh -o StrictHostKeyChecking=no $SSH_USER@$SSH_HOST
        env:
          SSH_USER: ${{ secrets.DEPLOY_SSH_USER }}
          SSH_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
          SSH_HOST: ${{ secrets.DEPLOY_SSH_HOST }}

The other part is at ~/.ssh/authorized_keys on the VPS:

command="/apps/deploy/azzolini.io.sh",no-port-forwarding,no-x11-forwarding,no-agent-forwarding <THE PUBLIC KEY>

Building & Deploying

/apps/deploy/azzolini.io.sh is now invoked on the VPS on all pushes to master.
This script uses Docker to build a newer image and then redeploys the container with docker compose.

#!/bin/bash

set -e

cd /apps/repos/azzolini.io
git pull
docker build . -t azzolini.io:latest

docker compose -f /apps/docker-compose.yml up --build -d azzolini.io

Fully Static Sites

Fully static sites served just by nginx usually often still need a build step. I use Docker to build the site with the following as the last line in the Dockerfile: RUN tar -cf build.tar dist. Then, the deploy script can do this to get the new site to nginx:

docker build . -t azzolini.io:latest
docker run --rm azzolini.io:latest cat build.tar > build.tar

tar -xf build.tar
rm -f build.tar
rm -rf /apps/nginx-sites/azzolini.io
mv dist /apps/nginx-sites/azzolini.io

Docker Compose

All of my services are managed by Docker Compose. nginx serves as a reverse proxy.

services:
  nginx:
    image: nginx
    container_name: nginx
    ports:
      - 80:80
      - 443:443
    volumes:
      - /apps/nginx/nginx.conf:/etc/nginx/nginx.conf
    restart: always

  azzolini.io:
    image: azzolini.io:latest
    container_name: azzolini.io
    restart: always
    ports:
      - 4321:4321

nginx

The reverse proxy configuration in nginx.conf looks like this:

server {
  server_name azzolini.io;
  location / {
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header Host $http_host;
    proxy_pass "http://172.17.0.1:4321";
  }
}

Useful Resources