Deploying to Private Servers from GitHub Actions over OpenVPN

A lot of the servers I deploy to are not reachable from the internet. They sit in a private network, and the only way in is a VPN. GitHub's hosted runners, on the other hand, live on the public internet. To deploy from Actions, the runner has to join the private network first.

The examples I found online usually commit the .ovpn client file into the repository. That file contains the credentials for the network, so committing it is exactly what you don't want. Here is the version I use instead, and a second pattern that came out of using it across environments. Both are in my github-actions repository.

The .ovpn file is a secret

An .ovpn file is plain text, so it fits in a GitHub secret as is. The workflow writes it to disk at runtime, starts OpenVPN as a daemon, waits until the private host answers, deploys over SSH, and always disconnects at the end:

name: OpenVPN with SSH

on:
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: OpenVPN install
        run: sudo apt-get install openvpn -y

      - name: OpenVPN setup
        run: echo "${{ secrets.OPENVPN_CONFIG }}" | sudo tee openvpn_config.ovpn > /dev/null

      - name: OpenVPN connect
        run: sudo openvpn --config openvpn_config.ovpn --daemon

      - name: Wait for the VPN connection
        timeout-minutes: 1
        run: until ping -c1 ${{ secrets.SSH_HOST }}; do sleep 2; done

      - name: Connect over SSH and deploy
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          script: |
            # your deploy steps

      - name: Kill VPN connection
        if: always()
        run: sudo killall openvpn

A few details that matter:

Environments without GitHub Environments

The obvious way to have different secrets for production, test and dev is GitHub Environments. But for private repositories on an organization's free plan, Environments aren't available. When I hit that limit, the workaround turned out to be quite clean.

First, derive an environment name from the branch:

env:
  ENVIRONMENT: |-
    ${{
      github.ref_name     == 'master' && 'PROD'
      || github.ref_name  == 'dev'    && 'DEV'
      || github.ref_name  == 'test'   && 'TEST'
      ||                                 'UNKNOWN'
    }}

Then refuse to continue when a branch has no environment. Without this, a typo in a branch name produces a run that tries to read secrets that don't exist and fails much later with a confusing error:

- name: Validate environment
  run: |
    if [ "${{ env.ENVIRONMENT }}" == "UNKNOWN" ]; then
      echo "Error: no environment for branch ${{ github.ref_name }}"
      exit 1
    fi

Finally, name the secrets with the environment as a prefix (PROD_SSH_HOST, DEV_SSH_HOST, TEST_SSH_HOST) and look them up dynamically with format():

- name: OpenVPN setup
  run: echo "${{ secrets[format('{0}_OPENVPN_CONFIG', env.ENVIRONMENT)] }}" | sudo tee openvpn_config.ovpn > /dev/null

- name: Connect over SSH and deploy
  uses: appleboy/[email protected]
  with:
    host: ${{ secrets[format('{0}_SSH_HOST', env.ENVIRONMENT)] }}
    username: ${{ secrets[format('{0}_SSH_USERNAME', env.ENVIRONMENT)] }}
    key: ${{ secrets[format('{0}_SSH_PRIVATE_KEY', env.ENVIRONMENT)] }}
    port: ${{ secrets[format('{0}_SSH_PORT', env.ENVIRONMENT)] }}

The secrets[...] index syntax is what makes this work. It accepts any expression, so one workflow file serves all three environments, and adding a fourth is a new branch rule plus five new secrets.

What you lose compared to real Environments: required reviewers, wait timers and per-environment protection rules. If you need a human approval before production, this pattern doesn't give you that.

Static sites: no VPN needed

For static front-ends the third workflow in the repository skips servers entirely: build, then sync the output folder to an S3 bucket.

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: ${{ secrets.AWS_S3_REGION }}

- name: Deploy to S3
  run: aws s3 sync out/ s3://${{ secrets.AWS_S3_BUCKET_NAME }} --delete

The --delete flag removes files from the bucket that are no longer in the build. Without it, old hashed asset files pile up forever. If you can, swap the access keys for GitHub's OIDC integration with an IAM role. Then there are no long-lived AWS keys in your secrets at all.

The cost: a lot of secrets

With five secrets per environment and three environments, every repository using this pattern needs fifteen secrets, and you can't see any of them again after saving. Setting that up by hand for each new repository is what pushed me to build a small tool for it. I wrote about that in Managing GitHub Actions Secrets Across Many Repositories.

← All posts