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” gives golfzaptw/action-connect-ovpn, an open-source Github Action that can set up a VPN connection. In that repository, I found 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.

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 --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
        run: until ping -c1 your-server-url; 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 --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-url by the hostname or IP address of the server you want to connect with.

- name: Wait for a VPN connection
  run: until ping -c1 your-server-url; 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!


We welcome your feedback

We enjoy compliments, but you can totally shout at us for doing it wrong on our Twitter account 👋