Skip to main content
Tigers are great and tacos are tasty

Setup a dead simple CI/CD flow for a Golang app with GitHub actions

·5 mins

At the end of this post you will have configured a dead simple CI/CD workflow that can be used to deploy a Go app (or any other app for that matter), it shouldn’t take more than 10 minutes for you to set things up!

The setup - infrastructure and code #

I have this little Go service that concurrently scrapes different websites, performs some business logic and then persist the results in a Firestore.

This setup was previously running in Linode, but we noticed that we got strange 403 DENIED messages when using the Firestore client. I didn’t find anything about this on different forums and trying to use different IAM access keys didn’t resolve the problem. What did was to spin up a new Linode instance with the same config and voilà! all of a sudden it worked again.

This led me to the conclusion that our IP address was getting blocked by Google, so we decided to spin up a GCE (Google Compute Engine) instance instead to see if we can avoid getting blocked in the future (considering Google has hour payment info for the Firebase stuff already).

Anyway lets get into the CI/CD stuff that probably was the thing that brought you here…

Let’s create your CI/CD workflow #

We are going to go through a couple of steps to get things up and running.

  1. Create an SSH key
  2. Add some secrets to your GitHub secret store
  3. Configure GitHub Actions for your repo
  4. Make a code change and see how things work.

1. Create an SSH key #

Login to your remote server and navigate to your ssh folder cd .ssh.

Now generate your key, make sure to add your own email address in there. You will be prompted for a name, just type github-actions so that you can easily remember what the key is used for.

$ ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
Generating public/private rsa key pair.
Enter file in which to save the key (/.../fredrik/.ssh/id_rsa): github-actions
Enter passphrase (empty for no passphrase):    # Use an empty passphrase!
Enter same passphrase again: 
Your identification has been saved in github-actions.
Your public key has been saved in github-actions.pub.

Two keys will have been generated, the one that ends with .pub is the public key, the other is, well you guessed it, the private key.

.ssh $ ls github-actions*
  github-actions     github-actions.pub

2. Append your public key to authorized_keys #

It’s important to add the key to the right machine - you should already be logged in to the remote server when you executed the previous commands and that is where we should add it.

$ cat github-actions.pub >> authorized_keys

3. Create and configure GitHub Actions #

Go to your GitHub repository and click on “Settings”, then “Secrets” then “Actions”. You should see a button that says “New repository secret”.

Go ahead and copy the contents of the github-actions private ssh key that you generated in step 1. Including the BEGIN and END.

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAACFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAgEAwPx9+VdVsxcGL5RAk0n8NNcISboaJgDiItH+4kBAs/Fozc+aLEcf

....

0Zt6NKxKHSRd2rXMwHVF+bpuuZPiTP8LQfEk6jUQKYTBuO2/tajhqW42tDCCDtQ6+YvNZ+
XmsNtocXjr47s91rlzchX3zkAJshYZqigCeKdRJK6uZeu9N4hhOyDMOGPDMgLSZRt5gZBM
GLj6urpXOp3mli/9GzbJj0vklyVk2ICoxOBdfnFEkZ
-----END OPENSSH PRIVATE KEY----

Create a new repository secret in GitHub with the name SSH_PRIVATE_KEY and the contents of the key.

While we are here go ahead and create two more repository secret

  • PRODUCTION_SERVER: which contains the IP/Hostname of the server where you are running your service.
  • PRODUCTION_USERNAME: with the username that you use to connect to your remote server (ie the one you used when you created the SSH keys).

Now you should have 3 secrets created so lets move on and create a file in your codebase .github/workflows/deploy.yaml with the following contents:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
name: Build & Deploy

on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Set up Go
        uses: actions/setup-go@v2
        with:
          go-version: 1.17

      - name: Build it
        run: GOOS=linux GOARCH=amd64 go build -o my-binary cmd/service/main.go

      - name: Test it
        run: go test -v ./...

      - uses: actions/upload-artifact@v2
        with:
          name: binary-artifact
          path: ./my-binary

  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v2
      - uses: actions/download-artifact@v2
        with:
          name: binary-artifact
      - name: Adding private SSH key to ssh-agent
        env:
          SSH_AUTH_SOCK: /tmp/ssh_agent.sock
        run: |
          mkdir -p ~/.ssh
          ssh-agent -a $SSH_AUTH_SOCK > /dev/null
          ssh-keyscan github.com >> ~/.ssh/known_hosts
          ssh-add - <<< "${{ secrets.SSH_PRIVATE_KEY }}"          
      - name: scp binary to GCE and apply latest version
        env:
          SSH_AUTH_SOCK: /tmp/ssh_agent.sock
        run: |
          scp  -o StrictHostKeyChecking=no -r my-binary ${{ secrets.PRODUCTION_USERNAME }}@${{ secrets.PRODUCTION_HOST }}:./my-binary.new
          ssh -o StrictHostKeyChecking=no ${{ secrets.PRODUCTION_USERNAME }}@${{ secrets.PRODUCTION_HOST }} << 'ENDSSH'
            echo "** restarting service tof apply new version **"
            sudo service my-binary stop
            echo "** service stopped **"
            mv my-binary.new my-binary
            chmod +x my-binary
            sudo service my-binary start
            echo "** service started **"
            ps -ef |grep my-binary
          ENDSSH          

Let’s go through what this workflow does.

Line 1-5: is just the name of the job and for which branch is triggering the action. Line 7-26: this is where we build and test our Go service. Line 23-26: is where we upload the compiled binary so that we can use it in the next job (deploy jon on line 28). Line 36-58: is quite neat, this is where we retrieve our secrets from GitHub, and then we add the private SSH Key to the CI/CD server which is executing our job. We then scp the file to our remote host, then in an SSH session we do some things to make the binary runnable.

So on Line 47 is where you would put the specifics for your service.

The service I’m describing above is controlled by systemd on the remote server. For it to work a file need to be present in /etc/systemd/system, for the sake of this guide the file is /etc/systemd/system/my-service.service and contains the following.

[Unit]
Description=My service

[Service]
Type=simple
ExecStart=/<path-to>/my-binary
Environment=PORT=443

[Install]
WantedBy=multi-user.target

Go ahead and commit your code and push to main (remember, the .yaml file above only specifies that the action should be triggered on push for the main branch).

That’s it for now, I hope you found this guide helpful! Feel free to follow me on Twitter @fredrik_burman.