Automating Static Website Deployment with Forgejo Actions

Taylor Talkington : ~

In late 2025 I decided to move away from using GitHub as my primary code repository and instead use a self-hosted Forgejo instance.

This also meant that the few static sites I had conveniently hosted with GitHub Pages would need to move, and all of my automatic build & deploy scripts were useless.

Fortunately, once I got a runner hosted for Forgejo, getting my site build and deployements going again was fairly straight forward.

The following will have to be setup for each new site/collection of documents I want to host.

nginx

Each new site needs to be configured in nginx. This will either be a new location in an existing server section, or an entirely new server in the case of a subdomain.

For example, the subdomain for this blog: https://blog.the-eg.net. Note: some configuration details have been omitted below.

server {
    listen 443 ssl;
    listen [::]:443 ssl;

    server_name blog.the-eg.net;

    location / {
        sendfile on;
        sendfile_max_chunk 1m;
        tcp_nopush on;
        root /var/www/blog;
        index index.html;
    }
}

In this case, the blog files will be served from /var/www/blog.

Forgejo Action

Now, a Forgejo action will need to:

  1. Setup a Python venv.
  2. Install Pelican.
  3. Build the Blog.
  4. Arrange for it to be uploaded to the server at /var/www/blog with the proper permissions.

The first 3 steps are straightforward, since Forgejo actions are very similar to GitHub actions. The 4th is a bit trickier. I chose to accomplish it with SSH.

The new workflow looks like (.forgejo/workflows/build-blog.yml):

name: Build and Deploy Blog

on:
  push:
    branches:
      - main
jobs:
  build-deploy-docs:
    name: Build and Deploy Blog
    runs-on: debian-trixie
    steps:
      - name: Checkout code
        uses: https://data.forgejo.org/actions/checkout@v6
      - name: Setup ssh
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SSH_HOST_PUB_KEY: ${{ secrets.SSH_HOST_PUB_KEY }}
        run: |
          apt-get update
          apt-get install -y openssh-client

          mkdir -p ~/.ssh
          chmod 700 ~/.ssh

          echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519

          echo "$SSH_HOST_PUB_KEY" >> ~/.ssh/known_hosts
      - name: Setup python
        run: |
          python3 -m venv .venv
          . ./.venv/bin/activate
          pip install --upgrade pip
          pip install pelican
      - name: Build blog
        run: |
          . ./.venv/bin/activate
          pelican --settings publishconf.py --extra-settings SITEURL='"https://blog.the-eg.net"' --output output
      - name: Deploy blog
        run: |
          ssh -i ~/.ssh/id_ed25519 -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "${{ vars.UPLOAD_CLEAN_SCRIPT }}"
          scp -i ~/.ssh/id_ed25519 -P ${{ secrets.SSH_PORT }} -r output/* ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ vars.UPLOAD_DIR }}
          ssh -i ~/.ssh/id_ed25519 -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "${{ vars.DEPLOY_SCRIPT }}"

SSH Setup

Since step 4 above is accomplished via SSH, it must be setup during the deploy.

This is done during the second step of the action above, Setup ssh:

steps:

  - name: Setup ssh
    env:
      SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
      SSH_HOST_PUB_KEY: ${{ secrets.SSH_HOST_PUB_KEY }}
    run: |
      apt-get update
      apt-get install -y openssh-client

      mkdir -p ~/.ssh
      chmod 700 ~/.ssh

      echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
      chmod 600 ~/.ssh/id_ed25519

      echo "$SSH_HOST_PUB_KEY" >> ~/.ssh/known_hosts
The first two lines of this step install openssh-client. This may not be necessary for most setups, I just forgot to include it when setting up my Forgejo runner container. Oops.

For this we need two values, a private key that will be used to authenticate with the host server and the servers public key. This will allow the deploy runner to upload files and run scripts as an authenticated user without having to store login information.

First, generate a new key pair to be used by the runner:

$ key-gen -t ed25519 -C "forgejo action deploy key"

Follow the prompts and specify to save the key files in a secure location.

In the Forgejo repository, add an action secret named SSH_PRIVATE_KEY and set the value to the contents of the private key file. This is the key file without the .pub extension.

Now we need the server's public key. The easiest is to look at your local ~/.ssh/known_hosts and copy the corresponding line. Create another secret in the repo named SSH_HOST_PUB_KEY and set the value to this line.

Now the action can create the private key file to be used later and set the server as a known host.

Deploy

Unfortunately, deployment isn't quite as simple as just copying the files somewhere.

The files need to be deleted first so that stale files do not hang around, and they need a particular owner and set of permissions. I chose to just setup a couple of scripts that could be run remotely after the files are uploaded to an intermediate location.

The Deploy blog step above does this:

steps:

  - name: Deploy blog
    run: |
      ssh -i ~/.ssh/id_ed25519 -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "${{ vars.UPLOAD_CLEAN_SCRIPT }}"
      scp -i ~/.ssh/id_ed25519 -P ${{ secrets.SSH_PORT }} -r output/* ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ vars.UPLOAD_DIR }}
      ssh -i ~/.ssh/id_ed25519 -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "${{ vars.DEPLOY_SCRIPT }}"

This requires more secrets and some action variables. Secrets and variables are similar, except that the value of secrets can't be seen or edited once set.

Add the following secrets:

  • SSH_USER: the user to use to authenticate with the server. This will have to be the same user that created the key above.
  • SSH_HOST: the host of the server. This will need to match the host specified in SSH_HOST_PUB_KEY.
  • SSH_PORT: the ssh port to connect to. Probably 22, but it could be different.

And add the following variables:

  • UPLOAD_CLEAN_SCRIPT: a script that will clean the upload area. In this case, ~/web-deploy/scripts/clean-blog-upload.sh.
  • UPLOAD_DIR: a location to upload the built site to, temporarily. In this case, ~/web-deploy/blog/.
  • DEPLOY_SCRIPT: a script that will deploy the site from UPLOAD_DIR to the final location, and set file permissions appropriately, etc.

With those set, the Deploy blog step now:

  1. Runs the UPLOAD_CLEAN_SCRIPT on the server.
  2. Uploads the output of the pelican build to UPLOAD_DIR on the server.
  3. Runs DEPLOY_SCRIPT on the server, after which the blog should be updated.

The UPLOAD_CLEAN_SCRIPT referenced above just cleans out the upload folder:

#!/bin/sh
rm -rf ~/web-deploy/blog/*

DEPLOY_SCRIPT is more complex:

#!/bin/sh
sudo rm -rf /var/www/blog
sudo mkdir /var/www/blog
sudo cp -r /home/taylor/web-deploy/blog/* /var/www/blog
sudo chown -R www-data:www-data /var/www/blog
sudo find /var/www/blog -type d -exec chmod 755 -- {} +
sudo find /var/www/blog -type f -exec chmod 644 -- {} +

This script:

  1. Removes the old blog site files.
  2. Creates a new folder to hold the new files.
  3. Copies the site from the UPLOAD_DIR.
  4. Sets the owner of the copied files to www-data:www-data.
  5. Sets permissions for files and folders to 755 and 644 respectively.

The last two steps are important security measures that ensure the site is not inadvertently changed or accessed.