diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro index 4e224b9..7c137eb 100644 --- a/src/components/BaseHead.astro +++ b/src/components/BaseHead.astro @@ -25,6 +25,10 @@ const { title, description, image = '/blog-placeholder-1.jpg' } = Astro.props; + + + + {title} diff --git a/src/content/blog/continuous-deployment-with-ssh-and-tailscale.md b/src/content/blog/continuous-deployment-with-ssh-and-tailscale.md new file mode 100644 index 0000000..26c88db --- /dev/null +++ b/src/content/blog/continuous-deployment-with-ssh-and-tailscale.md @@ -0,0 +1,184 @@ +--- +title: continuous deployment with SSH and tailscale +description: A guide in implementing continuous deployment with SSH and Tailscale +pubDate: May 13 2025 +--- + +*Skip to [high level overview](#high-level-overview) if you are not interested in the back story.* + +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. + +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. + +Thankfully, using [Tailscale](https://tailscale.com/), I can securely access any of my devices, even outside my home network. + +## Why Tailscale? + +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. + +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. + +## High Level Overview + +1. Trigger continuous deployment when commits are made to the main branch +2. Connect the runner of the continuous deployment pipeline to the tailnet to which your host is connected +3. SSH into the host using the assigned IP +4. Pull the new commits on the host, and re-deploy the website + +I will be using GitHub Action syntax for the CI/CD config file, but the overarching concept applies to any CI/CD environment. + +## The Basics + +Start with the following code: + +```yml +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy website to server + env: + MACHINE_USER_NAME: user + MACHINE_NAME: my-host +``` + +This allows the pipeline to be triggered when commits are made to the `main` branch, and manually in the GitHub UI. + +Two environment variables are also defined here: +- `MACHINE_USER_NAME`: the name of the user used for SSH-ing into the machine; and +- `MACHINE_NAME`: the name of the machine assigned by Tailscale. You can find this out in the admin console of Tailscale. + +## Setting Up Tailscale GitHub Action + +### Defining a Tag + +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. + +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`. + +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: + +```json +{ + "tagOwners": { + "tag:ci": ["autogroup:owner"] + } +} +``` + +`"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. + +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. + +```json +{ + "acls": [ + // other lists + {"action": "accept", "src": ["tag:ci"], "dst": ["1.2.3.4:22"]} + ] +} +``` + +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. + +### Creating an OAuth Client + +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. + +Now, add a step in the workflow file to set up Tailscale: + +```yml +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy website to server + env: + MACHINE_USER_NAME: user + MACHINE_NAME: my-host + steps: + - name: Setup Tailscale + uses: tailscale/github-action@v3 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_OAUTH_CLIENT_SECRET }} + tags: 'tag:ci' +``` + +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. + +## Setting Up SSH Access + +To let the CI/CD runner authenticate over SSH, we will need to: + +1. Generate an SSH key pair +2. Install the public key to the host machine, and +3. Save the private key as a GitHub secret to be used in the workflow. + +Once you have completed the steps above, add the following workflow step after the Tailscale setup step: + +```yml +- name: Add SSH key + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + run: | + mkdir -p ~/.ssh + MACHINE_IP="$(tailscale ip -4 $MACHINE_NAME)" + ssh-keyscan $MACHINE_IP >> ~/.ssh/known_hosts + printf "%s" "$SSH_PRIVATE_KEY" > ~/.ssh/key + # add a new line to the end of the private key file + # otherwise it won't be loaded properly + echo >> ~/.ssh/key + chmod 600 ~/.ssh/key +``` + +Let's break down the script: + +1. Create the default SSH directory if it does not exist +2. Using Tailscale's CLI to find the IPv4 of the host machine by its assigned name, and store it in `MACHINE_IP` variable +3. Perform a key scan on the machine, then add it to the list of known hosts +4. Save the private key in GitHub secret (made available as an environment variable via the `env` block) to `~/.ssh/key` file +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 +6. Correct the permission of the key file + +## Deploying the Website + +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. + +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. + +```yml +- name: Deploy website + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + run: | + MACHINE_IP="$(tailscale ip -4 $MACHINE_NAME)" + ssh -i ~/.ssh/key "$MACHINE_USER_NAME@$MACHINE_IP" /bin/bash << EOF + cd /opt/website + git pull + docker build -t website . + docker stop website-container + docker rm website-container + docker run --name website-container --restart=always --publish 5432:80 --detach website + EOF +``` + +## References + +- [Using GitHub Actions and Tailscale to build and deploy applications securely](https://tailscale.com/blog/2021-05-github-actions-and-tailscale) + +## Final Notes + +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. + +Please don't hesitate to reach out should you spot any mistake. \ No newline at end of file diff --git a/src/styles/global.css b/src/styles/global.css index bb6b478..de217f3 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -74,7 +74,7 @@ pre { } body.blog { - font-family: "Georgia", serif; + font-family: "Source Serif 4", "Georgia", serif; } .nf {