|
| 1 | +--- |
| 2 | +title: continuous deployment with SSH and tailscale |
| 3 | +description: A guide in implementing continuous deployment with SSH and Tailscale |
| 4 | +pubDate: May 13 2025 |
| 5 | +--- |
| 6 | + |
| 7 | +*Skip to [high level overview](#high-level-overview) if you are not interested in the back story.* |
| 8 | + |
| 9 | +I recently purchased a cheap Dell OptiPlex to act as my own server. I have since successfully moved all my services to the machine, including my [git server](https://code.nym.sh) and my website which was previously hosted on [Fly](https://fly.io). Without the awesome `fly deploy` command to deploy my website, I had to find another way to automate the deployment of my website. |
| 10 | + |
| 11 | +Here comes the problem: my website is exposed to the public via [CloudFlare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/), but the host running my website isn't publicly accessible. This means that, for instance, I cannot SSH into the host in a CI/CD environment, such as GitHub Actions. |
| 12 | + |
| 13 | +Thankfully, using [Tailscale](https://tailscale.com/), I can securely access any of my devices, even outside my home network. |
| 14 | + |
| 15 | +## Why Tailscale? |
| 16 | + |
| 17 | +Based on [WireGuard](https://www.wireguard.com/), Tailscale lets you set up your own VPN with ease for your own devices, which forms a "tailnet". Any device connected to the tailnet can securely access other devices within it using the corresponding assigned address. |
| 18 | + |
| 19 | +Therefore, if I can manage to connect a machine to my tailnet, I will be able to access my host anywhere, in any environment, including CI/CD pipelines. Fortunately, Tailscale maintains a [GitHub Action](https://tailscale.com/kb/1276/tailscale-github-action) that lets you connect to your tailnet in a GitHub Action runner. |
| 20 | + |
| 21 | +## High Level Overview |
| 22 | + |
| 23 | +1. Trigger continuous deployment when commits are made to the main branch |
| 24 | +2. Connect the runner of the continuous deployment pipeline to the tailnet to which your host is connected |
| 25 | +3. SSH into the host using the assigned IP |
| 26 | +4. Pull the new commits on the host, and re-deploy the website |
| 27 | + |
| 28 | +I will be using GitHub Action syntax for the CI/CD config file, but the overarching concept applies to any CI/CD environment. |
| 29 | + |
| 30 | +## The Basics |
| 31 | + |
| 32 | +Start with the following code: |
| 33 | + |
| 34 | +```yml |
| 35 | +on: |
| 36 | + push: |
| 37 | + branches: |
| 38 | + - main |
| 39 | + workflow_dispatch: |
| 40 | + |
| 41 | +jobs: |
| 42 | + deploy: |
| 43 | + runs-on: ubuntu-latest |
| 44 | + name: Deploy website to server |
| 45 | + env: |
| 46 | + MACHINE_USER_NAME: user |
| 47 | + MACHINE_NAME: my-host |
| 48 | +``` |
| 49 | +
|
| 50 | +This allows the pipeline to be triggered when commits are made to the `main` branch, and manually in the GitHub UI. |
| 51 | + |
| 52 | +Two environment variables are also defined here: |
| 53 | +- `MACHINE_USER_NAME`: the name of the user used for SSH-ing into the machine; and |
| 54 | +- `MACHINE_NAME`: the name of the machine assigned by Tailscale. You can find this out in the admin console of Tailscale. |
| 55 | + |
| 56 | +## Setting Up Tailscale GitHub Action |
| 57 | + |
| 58 | +### Defining a Tag |
| 59 | + |
| 60 | +In Tailscale, you can assign and group machines with *tags*. One of their primary purposes is to allow you to apply access control to machines based on tags. |
| 61 | + |
| 62 | +In this case, it is useful to assign the runner running the CI/CD pipeline a tag. Then, using Tailscale's [Access Control Lists (ACLs)](https://tailscale.com/kb/1018/acls), we can limit access the tag has to the host to which we wish to deploy. We will name it `tag:ci`. |
| 63 | + |
| 64 | +To define a tag, navigate to the "Access Controls" page in the admin console. You will then be presented with an editor for the ACL file. Within the `"tagOwners"` key, add the following: |
| 65 | + |
| 66 | +```json |
| 67 | +{ |
| 68 | + "tagOwners": { |
| 69 | + "tag:ci": ["autogroup:owner"] |
| 70 | + } |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +`"autogroup:owner"` specifies that the owner of the tailnet can apply this tag. Before we move on, make a note of the IP address of the machine that will be hosting the service. |
| 75 | + |
| 76 | +Now, let's define an ACL for the tag. We want machines that are tagged with `tag:ci` to be able to SSH into the host machine and nothing else. As an example, we will use `1.2.3.4` as the IP address of the machine. |
| 77 | + |
| 78 | +```json |
| 79 | +{ |
| 80 | + "acls": [ |
| 81 | + // other lists |
| 82 | + {"action": "accept", "src": ["tag:ci"], "dst": ["1.2.3.4:22"]} |
| 83 | + ] |
| 84 | +} |
| 85 | +``` |
| 86 | + |
| 87 | +This specifies that machines tagged with `tag:ci` can only access machine with IP `1.2.3.4` on port `22`, which is the default SSH port. If your machine has a non-standard SSH port, change `22` to the correct port. |
| 88 | + |
| 89 | +### Creating an OAuth Client |
| 90 | + |
| 91 | +Tailscale's GitHub Action relies on OAuth2 for authentication. To get started, go to the admin console, then navigate to the "OAuth Clients" section in settings. Give the client a descriptive name, and make sure the [`auth_keys` scope](https://tailscale.com/kb/1215/oauth-clients#scopes) is enabled. Take a note of the client ID and client secret, and store them as GitHub Action secrets. |
| 92 | + |
| 93 | +Now, add a step in the workflow file to set up Tailscale: |
| 94 | + |
| 95 | +```yml |
| 96 | +on: |
| 97 | + push: |
| 98 | + branches: |
| 99 | + - main |
| 100 | + workflow_dispatch: |
| 101 | +
|
| 102 | +jobs: |
| 103 | + deploy: |
| 104 | + runs-on: ubuntu-latest |
| 105 | + name: Deploy website to server |
| 106 | + env: |
| 107 | + MACHINE_USER_NAME: user |
| 108 | + MACHINE_NAME: my-host |
| 109 | + steps: |
| 110 | + - name: Setup Tailscale |
| 111 | + uses: tailscale/github-action@v3 |
| 112 | + with: |
| 113 | + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} |
| 114 | + oauth-secret: ${{ secrets.TS_OAUTH_CLIENT_SECRET }} |
| 115 | + tags: 'tag:ci' |
| 116 | +``` |
| 117 | + |
| 118 | +This step installs Tailscale on the runner machine, and authenticates it into our tailnet using the OAuth credentials we created. The `tag:ci` is also applied to the runner, which means it can access the machine at `1.2.3.4` on port 22 and nothing else. |
| 119 | + |
| 120 | +## Setting Up SSH Access |
| 121 | + |
| 122 | +To let the CI/CD runner authenticate over SSH, we will need to: |
| 123 | + |
| 124 | +1. Generate an SSH key pair |
| 125 | +2. Install the public key to the host machine, and |
| 126 | +3. Save the private key as a GitHub secret to be used in the workflow. |
| 127 | + |
| 128 | +Once you have completed the steps above, add the following workflow step after the Tailscale setup step: |
| 129 | + |
| 130 | +```yml |
| 131 | +- name: Add SSH key |
| 132 | + env: |
| 133 | + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} |
| 134 | + run: | |
| 135 | + mkdir -p ~/.ssh |
| 136 | + MACHINE_IP="$(tailscale ip -4 $MACHINE_NAME)" |
| 137 | + ssh-keyscan $MACHINE_IP >> ~/.ssh/known_hosts |
| 138 | + printf "%s" "$SSH_PRIVATE_KEY" > ~/.ssh/key |
| 139 | + # add a new line to the end of the private key file |
| 140 | + # otherwise it won't be loaded properly |
| 141 | + echo >> ~/.ssh/key |
| 142 | + chmod 600 ~/.ssh/key |
| 143 | +``` |
| 144 | + |
| 145 | +Let's break down the script: |
| 146 | + |
| 147 | +1. Create the default SSH directory if it does not exist |
| 148 | +2. Using Tailscale's CLI to find the IPv4 of the host machine by its assigned name, and store it in `MACHINE_IP` variable |
| 149 | +3. Perform a key scan on the machine, then add it to the list of known hosts |
| 150 | +4. Save the private key in GitHub secret (made available as an environment variable via the `env` block) to `~/.ssh/key` file |
| 151 | +5. GitHub trims any trailing newline character in a secret value. Because SSH expects a trailing newline character at the end of a key file, a new line needs to be appended to the end of the key file created in the previous step |
| 152 | +6. Correct the permission of the key file |
| 153 | + |
| 154 | +## Deploying the Website |
| 155 | + |
| 156 | +With everything in place, the runner is now able to SSH into the host machine via Tailscale! How you deploy your services may vary, so I will use [my website](https://github.com/kennethnym/website/blob/main/.github/workflows/deploy.yml) as an example, which is stored as a git repo on the host machine and is run as a Docker container. |
| 157 | + |
| 158 | +The important part here is obtaining the machine IP using its assigned name, which we did in the previous step; passing the correct key file to SSH; and finally providing the correct username and IP. |
| 159 | + |
| 160 | +```yml |
| 161 | +- name: Deploy website |
| 162 | + env: |
| 163 | + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} |
| 164 | + run: | |
| 165 | + MACHINE_IP="$(tailscale ip -4 $MACHINE_NAME)" |
| 166 | + ssh -i ~/.ssh/key "$MACHINE_USER_NAME@$MACHINE_IP" /bin/bash << EOF |
| 167 | + cd /opt/website |
| 168 | + git pull |
| 169 | + docker build -t website . |
| 170 | + docker stop website-container |
| 171 | + docker rm website-container |
| 172 | + docker run --name website-container --restart=always --publish 5432:80 --detach website |
| 173 | + EOF |
| 174 | +``` |
| 175 | + |
| 176 | +## References |
| 177 | + |
| 178 | +- [Using GitHub Actions and Tailscale to build and deploy applications securely](https://tailscale.com/blog/2021-05-github-actions-and-tailscale) |
| 179 | + |
| 180 | +## Final Notes |
| 181 | + |
| 182 | +We have successfully created a GitHub Action workflow that automatically deploys a service over SSH using Tailscale, without public access to our host machine. I hope you find this guide helpful, and thank you for reading. |
| 183 | + |
| 184 | +Please don't hesitate to reach out should you spot any mistake. |
0 commit comments