How to use OpenVPN in GitHub Action workflows?

One of our clients requires a VPN connection to access their servers and we want to auto-deploy the app with a GitHub Action workflow. So let's dig in and fix it.


First some credits. Googling for “github actions vpn” gave golfzaptw/action-connect-ovpn (November 2022 the repository has been removed), an open-source GitHub Action that can set up a VPN connection. That repository contained an issue with a 100% workflow YAML solution. That was my starting point. Many thanks to the involved devs!

I don’t use the action because the log output is not sent to stdout, which makes it hard to debug. And this package is a thin layer around OpenVPN, too thin in my opinion.

This article assumes OpenVPN 2. I had a conversation with @GrantGochnauer about OpenVPN 3. It turned out not to be an easy upgrade. Keep that in mind.

The solution

Why finish with the solution when you can also start with it? So here it is. Below I’ll explain every step.

# .github/vpn/client.ovpn
...
ca ca.crt
cert user.crt
key user.key
tls-auth tls.key
auth-user-pass secret.txt
...
# .github/workflows/your-workflow.yml
...
jobs:
  deploy:
    runs-on: ubuntu-20.04
    steps:
      - name: Install OpenVPN
        run: |
          sudo apt-get update
          sudo apt-get --assume-yes --no-install-recommends install openvpn          

      - name: Setup VPN config
        run: |
          echo "${{ secrets.CA_CRT }}" > ca.crt
          echo "${{ secrets.USER_CRT }}" > user.crt
          echo "${{ secrets.USER_KEY }}" > user.key
          echo "${{ secrets.SECRET_USERNAME_PASSWORD }}" > secret.txt
          echo "${{ secrets.TLS_KEY }}" > tls.key          

      - name: Connect VPN
        run: sudo openvpn --config ".github/vpn/config.ovpn" --log "vpn.log" --daemon

      - name: Wait for a VPN connection
        timeout-minutes: 1
        run: until ping -c1 your-server-address; do sleep 2; done
        # OR
        run: until dig @your-dns-resolver your-server-address A +time=1; do sleep 2; done

      - name: Deploy
        run: some-command

      - name: Kill VPN connection
        if: always()
        run: |
          sudo chmod 777 vpn.log
          sudo killall openvpn          

      - name: Upload VPN logs
        uses: actions/upload-artifact@v2
        if: always()
        with:
          name: VPN logs
          path: vpn.log
...

Prepare the .ovpn file

It’s possible to use the .ovpn file as is, but that means you commit credentials. That’s not secure. Adding the whole file as a secret is an option. I don’t prefer that because the file contains more configuration than just the credentials. I want to be able to see those details.

I choose to load the credentials from external files which are filled with secrets. To do that you have to replace some tags in the .ovpn file. By replacing the tag you tell OpenVPN to read the value from a file. It’s not possible to use command flags, because your credentials could end up in the history of your shell.

Replace:

<ca>
...
</ca>

by ca ca.crt and add secret CA_CRT to your repository with the certificate as value (omit <ca> and </ca>).

The same with <cert>, <key> and <tls-auth>. If one of them is missing just ignore it.

Replace

<cert>
...
</cert>

by cert user.crt, with its content in secret USER_CRT (omit <cert> and </cert>).

Replace

<key>
...
</key>

by key user.key, with its content in secret USER_KEY (omit <key> and </key>).

Replace

<tls-auth>
...
<tls-auth>

by tls-auth tls.key, with its content in secret TLS_KEY (omit <tls-auth> and </tls-auth>).

The last change, add secret.txt as a parameter to auth-user-pass:

auth-user-pass secret.txt

secret.txt will contain the secret SECRET_USERNAME_PASSWORD. That secret must contain the username and password of your VPN connection separated by a newline:

username
password

The workflow file

The first step is installing OpenVPN.

- name: Install OpenVPN
  run: |
      sudo apt-get update
      sudo apt-get --assume-yes --no-install-recommends install openvpn      

The second step is loading the secrets into files. You changed the .ovpn file to load the values from those files. Omit the lines with files you didn’t reference.

- name: Setup VPN config
  run: |
      echo "${{ secrets.CA_CRT }}" > ca.crt
      echo "${{ secrets.USER_CRT }}" > user.crt
      echo "${{ secrets.USER_KEY }}" > user.key
      echo "${{ secrets.SECRET_USERNAME_PASSWORD }}" > secret.txt
      echo "${{ secrets.TLS_KEY }}" > tls.key      

Now it’s time to start OpenVPN with the prepared .ovpn file. By using the --daemon flag OpenVPN is started as a background process. That makes it possible to run the deploy command, which uses the VPN connection. Because the process is daemonized the output is not sent to stdout, by adding the --log flag the output is sent to a file. More on that in the last step.

- name: Connect VPN
  run: sudo openvpn --config ".github/vpn/config.ovpn" --log "vpn.log" --daemon

Before the script can continue it must wait for the VPN connection to be established. For that, we use until and ping. until is the opposite of while. A very handy feature of Bash. When ping fails, the script must sleep for 2 seconds and try again, until it is successful. And when it’s successful we know the VPN connection is established.

📖 Read more about until

Replace your-server-address by the hostname or IP address of the server you want to connect with. The timeout-minutes property prevents the step from running longer than 2 minutes.

- name: Wait for a VPN connection
  timeout-minutes: 2
  run: until ping -c1 your-server-address; do sleep 2; done

When you’re dealing with a DNS resolver provided by the VPN server you can’t use ping. It’s not possible to specify which DNS server ping must use, with dig it is. Replace your-server-address by the hostname of the server and replace your-dns-resolver by the IP or hostname of the DNS resolver. It will ensure dig only succeeds when it can connect to the DNS resolver that provides the local IP address.

---
run: until dig @your-dns-resolver your-server-address A +time=1; do sleep 2; done

Now it’s your turn to use the VPN connection and deploy your code. Or whatever you want to do with it.

- name: Deploy
  run: some-command

The workflow finishes with killing the VPN connection and making the log file available for step “Upload VPN logs”. Because OpenVPN is running as root, upload-artifacts is not allowed to read the log file. sudo chmod 777 gives the current user access to the log file.

Notice if: always(), that line ensures the step is always executed, so you always have the log file available as a build artifact.

- name: Kill VPN connection
  if: always()
  run: |
      sudo chmod 777 vpn.log
      sudo killall openvpn      

- name: Upload VPN logs
  uses: actions/upload-artifact@v2
  if: always()
  with:
      name: VPN logs
      path: vpn.log

That’s it, happy deploying!