Using Ansible via GitHub Actions and Tailscale

I build this blog using Hugo and updated it nightly (or on push to main) via GitHub Actions. I run an nginx web server, sitting on a DigitalOcean VPS to serve the traffic. It is a very simple setup for someone who is used to running a CMS (have been doing it since 2005). Almost too simple.

With this in mind, I set up to automate the creation and deletion of my VPS using Ansible. The playbook is specific to my situation. I set up nginx, tailscale, redirections, a staging env only accessable from the tailscale network, certbot, etc. Every blog would be different so I won’t be sharing my playbook but I will go over how I set up Ansible to connect to my VPS over the Tailscale network.

For those that don’t know, Tailscale is a mesh VPN that allows direct wireguard connections between peers. Tailscale runs a coordination server for you to establish the connection, then it is peer-to-peer. At work, we put it through it’s paces and found it easy to deploy, the user deactivation process was near instantanious, and the price was right.

On my own, I use it to access my home resources while on the road. My mobile devices and laptops automatically connect so I am able to access my home media, rss reader, and more.

Why?

You may say to yourself, why make it so complicated? Security and fun is the answer. Being able to put the VPS behind a firewall that only allows port 80 and 443 incoming is nice. Since tailscale initiates the connection from the client, it isn’t impeeded by the firewall. It is fun because I love building rube goldberg machines so lay off!

Setting up Ansible

First order of business is to make sure you have an inventory file that contains your assets and their corresponding tailnet addresses. You’ll need it inside of the GitHub action.

Use the ansible_host inventory variable to handle it if you aren’t using host_vars.

hugo ansible_host=102.1.264.22

Run your playbook with the new inventory file to make sure you are ready. Any troubleshooting that can happen before you roll out CI/CD will save you time later. The iteration times are slow, no matter the CI/CD environment. Plus, to iterate your CI, you need to commit code to generate the jobs so there is proof of your incompetence! We must keep our incompetence to ourselves. I love hiding my sins by squashing my PRs.

Configuring the Action

I love the layout of a GitHub Action yml file. Simple, well-documented, and comes with batteries included. The latest Ubuntu image works best for me and look at everything it comes with. That, coupled with a great VS Code extension and I was off to the races. Looks at this tool tip. It is doing the cron time math for me (in UTC).

GitHub Actions vs code extension displaying a helpful tooltip

Make sure to configure your secrets in the repository so you can reference them in the action.

GitHub action secrets configuration page

GitHub has a great action dispatch system where you can generate actions for just about any event logged to your repository. Here, set up the action to run when there is a commit to the main branch (when I merge a PR), when I kick it off manually via the workflow dispatch feature, and every day at 13:01 UTC.

on:
  push:
    branches:
      - main
  workflow_dispatch:
  schedule:
    - cron: '01 13 * * *'

We have secrets to reference, so we set them as env vars near the top of the yml file. To generate your ssh key, run ssh-keygen -t ed25519 to get a private key to add to GitHub. You can add the public key to the knownhost file on the server using ssh-copy-id.

env:
  SSH_KEY_GITHUB_ACTIONS: ${{ secrets.SSH_KEY_GITHUB_ACTIONS }}
  VPS_DEPLOY_USER: ${{ secrets.VPS_DEPLOY_USER }}
  VPS_DEPLOY_HOST: ${{ secrets.VPS_DEPLOY_HOST }}

Define your job next. You give it a name, tell it what to run on, and give it a bunch of steps to complete. First, we checkout the code. Since I’m using a git submodule, I need to pass that config as parameter.

jobs:
  playbook:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          submodules: recursive
          fetch-depth: 0

Then use the Tailscale provided reusable action to authenticate to your tailnet. I tag my job so when I can apply ACLs on the ephemeral instance. This instance will disappear shortly after the runner dies.

A screencap of the tailscale admin device screen showing the ephemeral device.

      - name: Connect to Tailnet
        uses: tailscale/github-action@v2
        with:
          oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
          oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
          tags: tag:github

Now comes the fun part. We drop into a bash terminal and set a few configs, then invoke ansible-playbook. The first few lines are to set up your ssh private key since I disable password based authentication. I’m then able to call ansible-playbook with the env vars I set earlier in the file.

      - name: Invoke Ansible
        run: |
          mkdir -p ~/.ssh/
          chmod 0700 ~/.ssh
          eval $(ssh-agent -s)
          ssh-add <(echo "$SSH_KEY_GITHUB_ACTIONS")
          pip install -r requirements.txt
          ansible-playbook ansible/playbook.yml \
            -i ansible/tailscale_inventory \
            --ssh-common-args='-o StrictHostKeyChecking=no' \
            --extra-vars ansible_user=$VPS_DEPLOY_USER \
            --extra-vars ansible_host=$VPS_DEPLOY_HOST          

If I was truly paranoid, I’d add the ssh host key as an environment variable so I wouldn’t need to disable strict host checking.

Entire Action

name: Configure Server

on:
  push:
    branches:
      - main
  workflow_dispatch:
  schedule:
    - cron: '01 13 * * *'

env:
  SSH_KEY_GITHUB_ACTIONS: ${{ secrets.SSH_KEY_GITHUB_ACTIONS }}
  VPS_DEPLOY_USER: ${{ secrets.VPS_DEPLOY_USER }}
  VPS_DEPLOY_HOST: ${{ secrets.VPS_DEPLOY_HOST }}

jobs:
  playbook:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Connect to Tailnet
        uses: tailscale/github-action@v2
        with:
          oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
          oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
          tags: tag:github
      
      - name: Invoke Ansible
        run: |
          mkdir -p ~/.ssh/
          chmod 0700 ~/.ssh
          eval $(ssh-agent -s)
          ssh-add <(echo "$SSH_KEY_GITHUB_ACTIONS")
          pip install -r requirements.txt
          ANSIBLE_HOST_KEY_CHECKING=False
          ansible-playbook ansible/playbook.yml \
            -i ansible/tailscale_inventory \
            --ssh-common-args='-o StrictHostKeyChecking=no' \
            --extra-vars ansible_user=$VPS_DEPLOY_USER \
            --extra-vars ansible_host=$VPS_DEPLOY_HOST