1. A little background

I have been meaning to automate the deployment of my blog post to a dev server (running locally) for a while, but I haven’t had the time to get around to it until now. In addition to deploying this, the dev server also had several constraints. It is one of the machines at home and is not exposed directly to the internet. I also don’t have any ports opened on the firewalls at home, which adds a few more interesting challenges. I have a server hosted online in one of the data centers, but I want to keep that for more ‘production’ things.

Another thing I wanted to do was clean up the theme being used, as it wasn’t compatible with the newer version of Hugo. The longer I was pinned to an older version, the more challenging it would be to update. Now, these are all done and working, and I wanted to share what I learned here—more so because I will likely need to find this at some point in the future, but it might also help someone else.

PS - If you don’t notice much (any?) difference in the blog’s look, that means it is a good thing; I worked through fixing the theme and upgrading to the latest version of Hugo (v0.140.0 at the time of writing this).

While cleaning up, I also got around to things I had wanted to do for a while. I wanted to see how to securely expose one of the machines I have at home online without opening up ports and things and serve several services—mostly for my own use.

1.1 Dev-Server Setup 🖥️

I think outlining how this machine is set up would be helpful, as I am sure many others will be trying this. First, this machine runs locally on the home network, running a headless Ubuntu server. The environment that made this dev server unique was the combination of things - I use Tailscale, Cloudflare Tunnels, CloudPanel, and as outlined earlier Hugo. The code sits in a private Github repo, and the build-server is via Github Actions. I won’t get into the details of each setup here as the intent of the blog post is not that, but I will share some context below.

1.2 Tailscale

To make my life easier between the different machines and servers I have, running both locally at home and on the cloud, including bare-metal machines, I use Tailscale.

Tailscale simplifies network management by creating a secure, zero-config mesh VPN based on the WireGuard protocol. Unlike traditional VPNs that route all traffic through a central server, Tailscale forms direct, encrypted connections between devices, regardless of their physical location or network configuration. This “mesh” approach offers significant performance benefits, reduced latency, and improved reliability. By assigning each device a stable IP address within a private network namespace, Tailscale eliminates the complexities of managing firewalls, port forwarding, and dynamic DNS, making it ideal for connecting personal devices, servers, and cloud instances into a cohesive, secure network.

What makes Tailscale work like magic is a feature they call MagicDNS. This feature eliminates the need to remember IP addresses and SSH keys for different machines. Using Tailnet, I get a private VPN between different machines, and I can use that to connect to any of these without opening any ports, etc. This significantly simplifies the networking process and enhances security.

1.3 CloudPanel

I use CloudPanel, a free and open-source server control panel for managing web servers and applications. In the context of this blog post, CloudPanel provides a user-friendly web interface for creating and managing websites, databases, email accounts, and DNS records. It’s a crucial part of the automation process, allowing for efficient server management and deployment. Its lightweight design and efficient resource utilization make it great for me.

1.4 Cloudflare Tunnels

Cloudflare Tunnels play a crucial role in the deployment process. They offer a secure and efficient way to expose web services running on private networks without opening inbound ports on firewalls. By establishing an outbound-only connection between a lightweight daemon (cloudflared) running on your server and Cloudflare’s global network, Tunnels create an encrypted tunnel that allows external users to access your services through Cloudflare’s edge. This approach enhances security by eliminating the attack surface associated with open ports while leveraging Cloudflare’s performance optimizations, such as caching, DDoS protection, and global CDN. It’s a powerful tool for self-hosting applications, APIs, and websites from home networks or private infrastructure without compromising security.

2. Server Setup ⌨️

To get this to work at a high level, we will do the following to get all of this working. This post won’t get into all the details of setting up the prerequisites, and it assumes that you have the following:

  • CloudPanel installed on your machine.
  • Cloudflare account with a Cloudflare Tunnel set up.
  • GitHub repo containing the Hugo project.
  • Tailscale for private networking - this is in addition to the HTTP traffic that Cloudflare handles.

2.1. Create a Static Site in CloudPanel

In CloudPanel, create a new “Site” (from the Dashboard). Select Static, and assign it a domain, e.g., blog.desigeek.com. CloudPanel will create a directory structure under something like:

/home/[cloudpanel_username]/htdocs/blog.desigeek.com

(Exact path can vary based on CloudPanel’s naming conventions.)

The figure below shows an example of how I set this up.

Static site in Cloudpanel

2.1.1 Create an SSH User

If you haven’t already, go to CloudPanel’s “Users” section. You can create an SSH user (for example, blog-ssh). This user will be mapped to the same group or path used by CloudPanel so that it can write your site files.

Important:

  • Make sure the SSH user’s home directory either is or can write to the directory created earlier /home/[cloudpanel_username]/htdocs/blog.desigeek.com.
  • For our example, I created an SSH user called blog-ssh, and the user’s group on Ubuntu was called bahree-blog.
  • Note: on some CloudPanel installations, the user’s primary group might be something else, not necessarily bahree-blog.

The image below shows an example of how to set this up.

Cloudpanel SSH User setting

2.1.2. Directory Paths and Permissions

Confirm that your SSH user (blog-ssh) can write into the blog.desigeek.com directory:

1
2
3
4
5
6
7
# Example:
ls -ld /home/[cloudpanel_username]/htdocs/blog.desigeek.com
# Make sure the owner or group matches your user or a group the user is in.

# If needed:
sudo chown -R blog-ssh:bahree-blog /home/[cloudpanel_username]/htdocs/blog.desigeek.com
sudo chmod -R 755 /home/[cloudpanel_username]/htdocs/blog.desigeek.com

(Adjust bahree-blog to the actual group that CloudPanel assigned.)


2.2 Cloudflare Tunnel Configuration

Cloudflare Tunnel is typically set up via Cloudflare’s admin dashboard or command line (see the docs for more details).

We will map the domain blog.desigeek.com to the local server’s port 80 (or 443). Since CloudPanel listens on port 80/443, your tunnel might forward something like:

  • Cloudflare subdomain → localhost:80 on your server.

Because Cloudflare is reverse proxying your site, you won’t need to open inbound ports on your server. Ensure your DNS is configured so that blog.desigeek.com points to the Cloudflare Tunnel.

The image below shows an example of what this would look like.

Cloudfare Tunnel example


2.3. Setup Tailscale Secure Private Access

To deploy the Hugo site on my home dev server, I need private remote access (SSH, SCP, rsync, etc.) between the GitHub build servers and the home server. I achieve this using Tailscale.

  1. Install Tailscale on your server:
    curl -fsSL https://tailscale.com/install.sh | sh
    sudo tailscale up
    

The figure below shows an example of an ephemeral node, which created and deleted in approx. a minute.

Tailscale ephemeral note

  1. Our Build server is essentially spun up when something is committed in a specific branch, and as a result, we need to get that added to the Tailscale network - but it should not be permanent. Over time, that will pollute the tailnet and make things more difficult to manage. Tailscale has a perfect solution for this - Ephemeral nodes. These nodes, as the name suggests, make it easier to connect and then clean up short-lived devices such as containers, cloud functions, or CI/CD systems that spin up and spin down on a regular basis.

To use this, we generate an Ephemeral Auth Key from the auth keys page of the Tailscale admin console. Given that the build server would need this, we store this key in GitHub as TS_AUTH_KEY.

This allows the ephemeral GitHub Actions runner to connect to my dev server privately, even when I don’t have any inbound ports open.


2.4. Nginx Configuration via CloudPanel

CloudPanel typically manages the Nginx configuration for each “Site” automatically. It also handles SSL certificates, logs, etc. However, in this case, we are relying on Cloudflare Tunnel, and as a result, we do not need SSL termination on the server; Cloudflare can handle it and forward HTTP internally. We therefore need to ensure that the config file (vhost) in CloudPanel is minimal. Below is an example of what I use:

server {
  listen 80;
  listen [::]:80;
  server_name blog.desigeek.com;
  {{root}}

  {{nginx_access_log}}
  {{nginx_error_log}}

  location ~ /.well-known {
    auth_basic off;
    allow all;
  }

  {{settings}}

  include /etc/nginx/global_settings;
  index index.html;

  location ~* ^.+\.(css|js|jpg|jpeg|gif|png|ico|gz|svg|svgz|ttf|otf|woff|woff2|eot|mp4|ogg|ogv|webm|webp|zip|swf)$ {
    add_header Access-Control-Allow-Origin "*";
    expires max;
    access_log off;
  }

  if (-f $request_filename) {
    break;
  }
}

Note: Please note that {{root}} points to /home/[cloudpanel_username]/htdocs/blog.desigeek.com, which is set up in the CloudPanel site configuration.

The figure below shows what this would look like in CloudPanel.

CloudPanel - Vhost editor

3. GitHub Actions Configuration

We finally get to the main thing of building out GitHub actions that will trigger whenever something is checked in on a certain branch. All the other things until now were to essentially expose this dev machine externally (specifically the blog) and allow the build server to deploy to this dev server running at home. If you have a publicly accessible dev machine, much of this might not be required.

3.1. Storing Variables & Secrets in GitHub

We start by storing the variables and secrets we need for this to work. The details are outlined below. To set these up, go to Settings → Secrets and variables → Actions in your GitHub repo.

Variables (non-sensitive):

  • SERVER_HOSTNAME = my-dev-server (or your Cloudflare Tunnel/hostname). If Tailscale is used, it might be the Tailscale IP/hostname.
    • Note: If you are using Tailscale, the notation of machine-name.local will not resolve and fail, even with MagicDNS enabled. It is best to use the machine name (e.g., my-dev-server and not my-dev-server.local or the IP address from the Tailnet)
  • SERVER_USERNAME = blog-ssh (the SSH user you created in CloudPanel).
  • HUGO_VERSION = e.g., 0.140.0
  • HUGO_EXTENDED = true (if you want the extended version, otherwise false).

Secrets (sensitive):

  • SSH_PASSWORD = the password for blog-ssh; this was either set or auto-generated when you created the account in CloudPanel.
  • TS_AUTH_KEY = Your Tailscale ephemeral auth key (only if using Tailscale).

The screenshot below shows what this would look like:

GitHub Actions secrets and variables

3.2 GitHub Actions Workflow

The code below outlines a sample workflow for this. It checks out the code, builds it with Hugo, and, once complete, copies it over to the dev server, where I can expose it securely to test it. To allow us to do this, we take a dependency on sshpass and rsync.

I do have a few things to check as this happens to ensure there aren’t any failures. When Hugo is finished, we show some basic stats, such as the number of files and folders created. This is called Show public directory stats.

Another is to test the SSH connection, as without that, the build machine cannot talk to the dev server where the final site is getting deployed. This step is called Test SSH Connection. At a high level, here is the flow that this goes through:

  • Check out the code
  • Installs Tailscale and connects as an ephemeral node
  • Builds with Hugo
  • Installs sshpass & rsync
  • Uses rsync to deploy to the server
  • Rolls back if the build fails
  • Logs out of Tailscale at the end

Below is a sample .github/workflows/deploy-hugo.yml.

name: Deploy Hugo Site

on:
  push:
    branches:
      - main  # or your chosen branch

jobs:
  deploy:
    runs-on: ubuntu-latest

    # Gets non-sensitive values from "Variables" (not Secrets)
    env:
      SERVER_HOSTNAME: ${{ vars.SERVER_HOSTNAME }}
      SERVER_USERNAME: ${{ vars.SERVER_USERNAME }}
      HUGO_VERSION: ${{ vars.HUGO_VERSION }}
      HUGO_EXTENDED: ${{ vars.HUGO_EXTENDED }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      # Optional Tailscale login if you're using Tailscale for SSH
      - name: Setup Tailscale
        uses: tailscale/github-action@v3
        with:
          authkey: ${{ secrets.TS_AUTH_KEY }}
          statedir: /tmp/tailscale-state/

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: ${{ env.HUGO_VERSION }}
          extended: ${{ env.HUGO_EXTENDED }}

      - name: Build Hugo site
        id: build-site
        run: |
          hugo --minify -d public          

      - name: Verify Hugo Output
        if: steps.build-site.outcome == 'success'
        run: |
          if [ ! -d "public" ] || [ -z "$(ls -A public)" ]; then
            echo "Error: 'public' directory is empty or does not exist."
            exit 1
          fi
          echo "Public directory exists and is not empty."          

      - name: Show public directory stats
        if: steps.build-site.outcome == 'success'
        run: |
          cd public
          echo "Files: $(find . -type f | wc -l)"
          echo "Directories: $(find . -type d | wc -l)"
          echo "Size: $(du -sm . | cut -f1) MB"          

      - name: Install sshpass and rsync
        run: |
          sudo apt-get update
          sudo apt-get install -y sshpass rsync          

      - name: Test SSH Connection
        run: |
          sshpass -p "${{ secrets.SSH_PASSWORD }}" \
            ssh -o StrictHostKeyChecking=no ${{ env.SERVER_USERNAME }}@${{ env.SERVER_HOSTNAME }} \
            'echo "SSH Connection Successful"'          

      - name: Deploy via rsync
        if: steps.build-site.outcome == 'success'
        env:
          SSH_PASSWORD: ${{ secrets.SSH_PASSWORD }}
        run: |
          sshpass -p "$SSH_PASSWORD" rsync -az --delete \
            -e "ssh -o StrictHostKeyChecking=no" \
            public/ ${{ env.SERVER_USERNAME }}@${{ env.SERVER_HOSTNAME }}:/home/[cloudpanel_username]/htdocs/blog.desigeek.com          

      - name: Rollback on Failure
        if: steps.build-site.outcome != 'success'
        uses: appleboy/ssh-action@master
        with:
          host: ${{ env.SERVER_HOSTNAME }}
          username: ${{ env.SERVER_USERNAME }}
          password: ${{ secrets.SSH_PASSWORD }}
          script: |
            BACKUP_DIR="/home/[cloudpanel_username]/backups"
            SITE_DIR="/home/[cloudpanel_username]/htdocs/blog.desigeek.com"
            LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/*.tar.gz | head -n 1)

            if [[ -n "$LATEST_BACKUP" ]]; then
              echo "Rolling back to: $LATEST_BACKUP"
              rm -rf "$SITE_DIR"/*
              tar -xzvf "$LATEST_BACKUP" -C "$SITE_DIR" --strip-components=1
              echo "Rollback successful!"
            else
              echo "No backups found to rollback to!"
            fi            

      - name: Tailscale Logout
        if: always()
        run: |
          # If Tailscale requires sudo or operator privileges to log out,
          # either configure passwordless sudo or skip this if ephemeral.
          sudo tailscale logout          

Note: Update /home/[cloudpanel_username]/htdocs/blog.desigeek.com to match the actual path CloudPanel created. The same applies to the backup directory if you use that mechanism.

The figures below show an example of when this was building, and one of the validations.

Action running

Validation - Build was successful


4. Tips & Troubleshooting 🚨

I wanted to share a few things I have learned as I went through and debugged this. Hopefully this will help someone else too.

  • Permission Denied: Make sure your CloudPanel SSH user has ownership or group-writable permission on /home/[cloudpanel_username]/htdocs/blog.desigeek.com.
  • Rsync Overwrites: The --delete flag in rsync removes files on the server that no longer exist locally. Remove ‘- delete ’ if you don’t want that behavior.
  • Cloudflare Tunnel: If the site is not reachable at blog.desigeek.com, check your Cloudflare DNS config or the tunnel routing.
  • SSH: You might still rely on Tailscale or an open SSH port for SSH since Cloudflare typically only proxies HTTP/HTTPS.
  • Tailscale Logout: If you see “Access denied: logout access denied,” you can skip tailscale logout (ephemeral nodes eventually vanish) or configure passwordless sudo for Tailscale.

5. Conclusion

By combining CloudPanel for site management, Cloudflare Tunnel for secure external traffic, Tailscale for private networking, and GitHub Actions for automated builds, you get a robust, convenient pipeline to deploy your Hugo blog. 😍

  • CloudPanel simplifies web server config and user management.
  • Cloudflare Tunnel ensures secure traffic without exposing direct ports.
  • Tailscale provides easy, private SSH if desired.
  • GitHub Actions automates the entire Hugo build and deployment process.

Now, when you push changes to your repository, your CloudPanel-managed site updates automatically, so no manual FTP or SSH is required!

Happy Deploying! ☺️


Further Reading