Limit access to sshd to github actions IPs for deployment

I've been using GitHub Actions more and more for my CI/CD pipelines. It's a great way to automate builds, tests, and deployments. One of my favorite use cases is deploying applications to my own VPS. It gives me the flexibility of a custom server setup with the convenience of an automated deployment process.

However, opening up your server to the internet for deployment always comes with security risks. In this post, I'll share my approach to securing SSH access for GitHub Actions deployments.

A dedicated user for GitHub Actions

The first step is to create a dedicated user for GitHub Actions on your VPS. This user should have minimal privileges and only be used for deployments. This way, if the user's credentials are ever compromised, the damage is contained.

You can create a new user with a home directory like this:

sudo adduser --disabled-password --gecos "" github-actions

We use the --disabled-password flag because we will be using SSH keys for authentication.

Sudoers for limited command execution

The github-actions user will likely need to execute some commands with sudo, for example, to restart a service or pull a new docker image. Instead of giving the user full sudo access, we can use the /etc/sudoers file to specify exactly which commands the user is allowed to run.

To do this, open the sudoers file with sudo visudo and add the following lines at the end:

# Allow the github-actions user to run specific commands
github-actions ALL=(ALL) NOPASSWD: /usr/bin/docker-compose -f /path/to/your/docker-compose.yml pull, \
                                  /usr/bin/docker-compose -f /path/to/your/docker-compose.yml up -d, \
                                  /usr/bin/systemctl restart your-service

This configuration allows the github-actions user to run docker-compose pull, docker-compose up -d, and systemctl restart your-service with sudo without being prompted for a password. Make sure to replace the paths and service names with your actual values.

Traefik middleware for IP whitelisting

The most effective way to secure SSH access is to only allow connections from a trusted set of IP addresses. Since GitHub Actions runners use a wide range of IP addresses, we need a way to dynamically update our firewall rules.

I use Traefik as a reverse proxy, and it has a great feature for this: middlewares. I can create a middleware that whitelists the official GitHub Actions IP ranges.

First, let's look at the router configuration. We will apply the github-actions-whitelist middleware to our ssh router.

tcp:
  routers:
    ssh-router:
      entryPoints:
        - "ssh"
      rule: "HostSNI(`*`)"
      service: "sshd-service"
      middlewares:
        - "github-actions-whitelist"
      tls: {}

  services:
    sshd-service:
      loadBalancer:
        servers:
          - address: "172.17.0.1:22"

The github-actions-whitelist middleware itself is now managed in a separate file, which will be dynamically generated. This separation keeps the main configuration clean and allows for automated updates.

Automating IP List Updates

To keep the IP list current, we can use a simple bash script that fetches the latest IP ranges from the GitHub API and generates the middleware configuration file.

Here is the script to generate traefik/config/dynamic/middleware_ipAllowList_github_actions.yml:

#!/usr/bin/env bash
set -euo pipefail

# Generates a Traefik dynamic config YAML with GitHub Actions IP allow list
# Output: traefik/config/dynamic/middleware_ipAllowList_github_actions.yml
# Requires: curl, jq

SCRIPT_DIR="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
OUT_FILE="$SCRIPT_DIR/config/dynamic/middleware_ipAllowList_github_actions.yml"
TMP_FILE="${OUT_FILE}.tmp"

if ! command -v curl >/dev/null 2>&1; then
  echo "Error: curl is required" >&2
  exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
  echo "Error: jq is required" >&2
  exit 1
fi

mkdir -p "$(dirname "$OUT_FILE")"

# Fetch GitHub meta with a User-Agent header to avoid 403
JSON=$(curl -fsSL -H "User-Agent: gh-actions-ip-allowlist-script" "https://api.github.com/meta")

# Extract actions CIDRs (both IPv4 and IPv6) and sort
mapfile -t CIDRS < <(echo "$JSON" | jq -r '.actions[]' | sort -u)

if [ ${#CIDRS[@]} -eq 0 ]; then
  echo "Error: no actions CIDRs found from GitHub meta" >&2
  exit 1
fi

# Write YAML
{
  echo "# Generated by generate_github_actions_ipallowlist.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
  echo "tcp:"
  echo "  middlewares:"
  echo "    github-actions-whitelist:"
  echo "      ipWhiteList:"
  echo "        sourceRange:"
  for cidr in "${CIDRS[@]}"; do
    printf "          - \"%s\"\n" "$cidr"
  done
} > "$TMP_FILE"

mv "$TMP_FILE" "$OUT_FILE"
echo "Wrote $OUT_FILE with ${#CIDRS[@]} CIDRs"

Scheduling with Cron

To ensure the IP list is always up-to-date, you can schedule this script to run periodically using a cron job. For example, to run it once a day, open your crontab with crontab -e and add the following line:

0 0 * * * /path/to/your/scripts/generate_github_actions_ipallowlist.sh

Make sure to replace /path/to/your/scripts/ with the actual path to your script.

By combining a dedicated user, a restrictive sudoers configuration, and a dynamically updated IP whitelist middleware, I can securely deploy my applications from GitHub Actions to my VPS. It's a bit of setup, but the peace of mind is well worth it.

Share this post: