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 openvpnA few details that matter:
- Wait for the tunnel, with a timeout.
openvpn --daemonreturns before the tunnel is up. Pinging the private host in a loop is the simplest readiness check, andtimeout-minutes: 1keeps a broken VPN from hanging the job until GitHub kills it. - SSH to the private IP. Once the tunnel is up, the server's private address is the one to use. The public address, if there is one, can stay closed.
- Use a restricted deploy user. The SSH user in the secret should only be able to do the deploy, not administer the box.
- Disconnect with
if: always(). The runner is thrown away after the job anyway, but closing the tunnel explicitly means a failed deploy step doesn't leave a session open on the VPN server side. - Move SSH off port 22, when you can. That's why the port is a secret too.
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
fiFinally, 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 }} --deleteThe --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.