Setting Up Your Own Hugo Site with Content Publishing via Obsidian
Greetings!

In this guide, I will explain how to set up your own website using the Hugo static site generator. I will also show how to create content with Obsidian and publish it automatically via CI/CD👨‍💻.

Preface: Why Do This?

While learning Linux, many users create a local knowledge base or collection of notes that often grows into a blog or something similar, accessible from the internet.

You could say that my website is a clear example of this approach😊. I recently migrated it from WordPress to Hugo, so this article can be considered a way to reinforce what I learned🙄.

Another important reason for writing this article was requests from subscribers to our Telegram channel for a guide like this.

Now, a little about the star of the show. Hugo🥳 is an open-source static site generator written in Go. Its main purpose is to transform templates (HTML, CSS, JS) and content (Markdown) into a fast, secure website ready to be hosted online.

Unlike dynamic systems such as WordPress, Hugo does not use a database or require a continuously running backend service. It simply generates all site pages in advance as plain HTML files served by a web server such as Nginx/Angie, Apache, Traefik, and so on. It is fast while maintaining a high level of security by reducing the number of failure and compromise points.

Besides, using Hugo is a great opportunity to try modern approaches and tools👨‍💻. It is fashionable now to call various concepts “something as Code.” Hugo is precisely a tool for implementing the so-called Docs as Code approach.

You write posts in Markdown format, stored as ordinary text files. You put them in a Git repository , configure CI/CD to initiate the static site build, and feed the resulting files to the web server.

In this article, I have tried to describe the process of configuring and launching a Hugo site as simply and clearly as I could. If you have questions or encounter any problems, please feel free to ask in our Raven chat. We have a fairly friendly microcommunity of IT connoisseurs there😇. I will certainly try to help you in my spare time.

Workflow

Now, briefly, here is what we will configure today and how it will work. The future content publishing system will work as follows:

  1. Edit .md files;
  2. commit and push changes to the remote repository;
  3. Start the CI/CD process (GitHub Actions);
  4. Build: build and validate the site files;
  5. Deploy: execute an SSH command (run a script) on the server hosting the site;
  6. Notify: check the status and send a Telegram notification about deployment success or failure.

Below is a simplified visualization of the workflow described above:

Prerequisites

To implement our plan, we will need:

Input Data

Below is the input data that will be used in the article:

KeyValue
Server namehugo.r4ven.me
Server SSH port2222
Server OSDebian 13
Usernameivan
Local machine namedesktop.lan
Hugo Docker imagedebian-0.151.0
Angie Docker image1.10.2-alpine

I recommend opening two terminals or two tabs right away: in the first, we will connect to the remote server, hugo.r4ven.me in my example, while the second will be our local machine (desktop.lan in my example).

Connect to our server over SSH and switch to the privileged root mode, for example via sudo:

BASH
ssh -p 2222 ivan@hugo.r4ven.me

sudo -s
Click to expand and view more

Launching Angie and Hugo

Create the required directory structure in /opt and move to the working directory:

BASH
mkdir -vp /opt/hugo/{hugo,angie}_data && cd /opt/hugo/
Click to expand and view more

Now create a Compose file for the Angie and Hugo services:

BASH
nvim docker-compose.yml
Click to expand and view more

Add the following content:

YAML
---

services:

  angie:
    #image: docker.angie.software/angie:latest
    image: docker.angie.software/angie:1.10.2-alpine
    container_name: angie
    restart: on-failure
    stop_grace_period: 1m
    deploy: &default_deploy
      resources:
        limits:
          cpus: '0.70'
          memory: 512M
        reservations:
          cpus: '0.2'
          memory: 256M
    logging: &default_logging
      driver: json-file
      options:
        max-size: "50m"
        max-file: "5"
    hostname: angie
    environment:
      - TZ=Europe/Moscow
    #command: ["angie", "-g", "daemon off;"]
    volumes:
      - ./angie_data/angie.conf:/etc/angie/angie.conf:ro
      - ./angie_data/conf.d/:/etc/angie/conf.d/:ro
      - ./angie_data/certs/:/var/lib/angie/acme/
      - ./hugo_data/hugo.r4ven.me/public/:/usr/share/angie/html/:ro
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"

  hugo:
    #image: docker.io/hugomods/hugo
    image: hugomods/hugo:debian-0.151.0
    container_name: hugo
    restart: on-failure
    stop_grace_period: 1m
    deploy: *default_deploy
    logging: *default_logging
    hostname: hugo
    environment:
      - TZ=Europe/Moscow
    working_dir: /src/hugo.r4ven.me
    volumes:
      - ./hugo_data/:/src/
    command: ["hugo", "--minify", "--gc"]
Click to expand and view more

Configuring and Launching Angie

Next, create the angie.conf web server configuration file:

BASH
nvim ./angie_data/angie.conf
Click to expand and view more
BASH
user angie;
worker_processes auto;
worker_rlimit_nofile 65536;

load_module modules/ngx_http_zstd_filter_module.so;
load_module modules/ngx_http_zstd_static_module.so;
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;

events {
    worker_connections 65536;
}

http {
    include       /etc/angie/mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;
    keepalive_timeout 20;
    server_tokens   off;

    brotli on;
    brotli_comp_level 5;

    zstd on;
    zstd_comp_level 7;

    gzip on;
    gzip_comp_level 6;
    gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
    # application/rss+xml

    access_log /var/log/angie/access.log;
    error_log  /var/log/angie/error.log notice;

    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "accelerometer=(), camera=(), microphone=()" always;
    add_header X-Frame-Options "DENY" always;

    # DNS resolver for ACME
    resolver 8.8.8.8 8.8.4.4 ipv6=off;

    acme_client le https://acme-v02.api.letsencrypt.org/directory
        email=kar-kar@r4ven.me
        key_type=ecdsa
        renew_before_expiry=30d
        # renew_on_load=on
        ;

    server {
        listen 80;
        server_name hugo.r4ven.me;

        location /.well-known/acme-challenge/ {
            root /usr/share/angie/html;
        }

        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl;
        http2 on;
        http3 on;

        server_name hugo.r4ven.me;

        acme le;

        ssl_certificate     $acme_cert_le;
        ssl_certificate_key $acme_cert_key_le;

        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_prefer_server_ciphers on;
        ssl_session_cache shared:SSL:50m;
        ssl_session_timeout 1d;

        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

        root /usr/share/angie/html;
        index index.html;

        location /.well-known/acme-challenge/ {
            root /usr/share/angie/html;
        }

        location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|webp|svg|ico|ttf|otf|woff|woff2)$ {
            access_log off;
            expires 1y;
            add_header Cache-Control "public, max-age=31536000, immutable";
            try_files $uri =404;
        }

        location = / {
            add_header Cache-Control "public, max-age=3600, stale-while-revalidate=300";
            try_files /index.html =404;
        }

        location ~* \.html$ {
            add_header Cache-Control "public, max-age=3600, stale-while-revalidate=300";
            try_files $uri =404;
        }

        # gzip_static   on;
        # brotli_static on;

        open_file_cache max=10000 inactive=60s;
        open_file_cache_valid 30s;
        open_file_cache_min_uses 2;
        open_file_cache_errors on;

        error_page 404 /404.html;
        error_page 500 502 503 504 /500.html;
    }

    include /etc/angie/conf.d/*.conf;
}
Click to expand and view more

Replace the following parameters in the configuration with your own:

BASH
sed 's|email=kar-kar@r4ven.me;|email=user@example.com;|' \
    ./angie_data/angie.conf

sed 's|server_name hugo.r4ven.me;|server_name example.com;|g' \
    ./angie_data/angie.conf
Click to expand and view more

Launch Angie and view the logs:

BASH
docker compose up -d angie

docker compose ps

docker compose logs -f
Click to expand and view more

Docker will download the required image and start the container. On its first launch, angie will request TLS certificates from Let’s Encrypt. You should see output similar to this:

That means everything is OK.

Verify that the certificates physically exist:

BASH
ls -lR ./angie_data/certs/
Click to expand and view more

If everything started successfully, check the web server and inspect the TLS certificate details:

BASH
curl -I https://hugo.r4ven.me

openssl s_client -connect hugo.r4ven.me:443 < /dev/null 2> /dev/null | openssl x509 -text | head -n 20
Click to expand and view more

Excellent. Let’s move on.

Configuring and Launching Hugo

First, you need to choose a site theme, and there are plenty available. You can choose one on the official website: https://themes.gohugo.io/. In this article, I will use the theme used by my r4ven.me site: Narrow. You can view the live demo here. The theme documentation is here.

Run the Hugo container and create a new site project (replace hugo.r4ven.me with your address):

BASH
docker compose run --rm -w /src hugo new site hugo.r4ven.me
Click to expand and view more

Clone the Narrow theme repository:

BASH
git clone https://github.com/tom2almighty/hugo-narrow.git \
    ./hugo_data/hugo.r4ven.me/themes/hugo-narrow

cp -r ./hugo_data/hugo.r4ven.me/themes/hugo-narrow/exampleSite/* \
    ./hugo_data/hugo.r4ven.me/
Click to expand and view more

Remove the unnecessary files:

BASH
rm -f ./hugo_data/hugo.r4ven.me/hugo.toml

rm -rf ./hugo_data/hugo.r4ven.me/themes/hugo-narrow/.git
Click to expand and view more

Then specify your address in the Hugo configuration file:

BASH
sed -i "s|^baseURL.*|baseURL: 'https://hugo.r4ven.me'|" \
    ./hugo_data/hugo.r4ven.me/hugo.yaml
Click to expand and view more

Now add a couple of test posts directly in the terminal:

Post 1
cat << EOF > ./hugo_data/hugo.r4ven.me/content/posts/first-post.md
---
title: "My First Post"
date: 2025-06-13
draft: false
categories: ["Blog"]
tags: ["Hugo", "Tutorial"]
---

## Hello, world!
EOF
Click to expand and view more
Post 2
cat << EOF > ./hugo_data/hugo.r4ven.me/content/posts/second-post.md
---
title: "My second Post"
date: 2025-07-20
draft: false
categories: ["Test"]
tags: ["Hugo", "Howto"]
---

## Hello, friend!
EOF
Click to expand and view more

Check:

BASH
ls -l ./hugo_data/hugo.r4ven.me/content/posts/
Click to expand and view more

As you may have noticed, each post begins with some metadata known as front matter.

Now start updating/generating our site:

BASH
docker compose up hugo
Click to expand and view more

The command will generate the site and, if everything is OK, show the final status of the site files:

Check the site in the console:

BASH
curl https://hugo.r4ven.me/posts/first-post/

curl https://hugo.r4ven.me/posts/second-post/
Click to expand and view more

And, of course, in the browser at https://hugo.r4ven.me:

Site is up!

Optional: Configuring Hugo to Start with Systemd

I usually use Systemd to manage the entire Compose stack conveniently.

Create a Systemd service unit:

BASH
systemctl edit --full --force hugo.service
Click to expand and view more

Add approximately the following content:

INI
[Unit]
Description=Hugo service
Requires=docker.service
After=docker.service

[Service]
Restart=on-failure
RestartSec=5
ExecStart=/usr/bin/docker compose -f /opt/hugo/docker-compose.yml up
ExecStop=/usr/bin/docker compose -f /opt/hugo/docker-compose.yml down

[Install]
WantedBy=multi-user.target
Click to expand and view more

Stop the current container and start it through Systemd:

BASH
docker compose down

systemctl enable --now hugo
Click to expand and view more

Check the container/service status:

BASH
docker compose ps

systemctl status hugo

journalctl -fu hugo
Click to expand and view more

You can now use systemctl start|stop|restart hugo for management.

Configuring CI/CD with GitHub Actions

Well then. The site works, but updating and filling it with content through the console is a task for enthusiasts. We are not among them, so let’s configure some automation.

First, create the SSH keys that will be used to access the repository during deployment:

BASH
mkdir -vp ~/.ssh/ && chmod 700 ~/.ssh/

ssh-keygen -q -N "" -t ed25519 -f ~/.ssh/id_ed25519_github_repo

ls -l ~/.ssh/
Click to expand and view more

Immediately configure SSH to use this key when connecting to GitHub:

BASH
cat << EOF >> ~/.ssh/config
Host github.com
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_github_repo
    IdentitiesOnly yes
EOF
Click to expand and view more

Before moving on to the next step, display and copy our public SSH key:

BASH
cat ~/.ssh/id_ed25519_github_repo.pub
Click to expand and view more

We will add it as a “deploy key” for repository access.

Go to GitHub, sign in to your account (or create a new one), and then create a new repository:

Specify a name, make it private, and click “Create”:

Now go to the repository settings:

And click “Add deploy key”:

Also specify a name, enter the public SSH key itself in the “Key” field, and enable “Allow write access”:

Now return to our server console and configure git.

Install it if it is not already present and move to the site directory:

BASH
apt update && apt install -y git

cd /opt/hugo/
Click to expand and view more

Initialize a new repository and configure exclusions:

BASH
git init

echo 'hugo_data/hugo.r4ven.me/public/' > .gitignore

echo 'hugo_data/hugo.r4ven.me/content/.obsidian/' >> .gitignore

echo 'hugo_data/hugo.r4ven.me/.hugo_build.lock' >> .gitignore

echo 'angie_data/certs/' >> .gitignore
Click to expand and view more

Create placeholder files in every project directory (so they can be pushed to GitHub), add the files to the index, and commit the changes:

BASH
find ./hugo_data/hugo.r4ven.me/ -type d -empty -exec touch {}/.gitkeep \;

git add ./

git commit -m "Hugo initial commit"
Click to expand and view more

Finally, synchronize the local repository with the remote one:

BASH
git branch -M main

git remote add origin git@github.com:r4ven-me/hugo.git

git push -u origin main
Click to expand and view more

Check the repository on the web:

Everything looks good. Continue the setup.

Now configure the client machine that will be used to add content to the site.

Create the project folder and clone our remote repository:

BASH
git clone git@github.com:r4ven-me/hugo.git ~/Hugo

mkdir -p ~/Hugo/.github/workflows

cd ~/Hugo
Click to expand and view more

In this directory, create a special CI/CD pipeline definition file that GitHub automatically reads when changes are pushed to the repository:

BASH
nvim .github/workflows/deploy.yml
Click to expand and view more
YAML
---

name: Deploy Hugo site
on:
  push:
    branches: [ "main" ]
  workflow_dispatch:

env:
  HUGO_VERSION: 0.151.0

defaults:
  run:
    shell: bash

jobs:
  build:
    name: Build and test Hugo site
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: hugo_data/hugo.r4ven.me

    steps:
      - name: Checkout repo
        uses: actions/checkout@v4

      - name: Set up Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: ${{ env.HUGO_VERSION }}
          extended: true

      - name: Build site
        run: hugo --minify --gc

      - name: Validate Hugo output
        run: |
          # Check the directory
          if [ ! -d "public" ]; then
            echo "❌ No 'public' directory!"
          exit 1
          fi
          # Check index.html
          if [ ! -f "public/index.html" ]; then
            echo "❌ No index.html generated!"
            exit 1
          fi
          # Check the size
          if [ ! -s "public/index.html" ]; then
            echo "❌ index.html is empty!"
            exit 1
          fi
          # Check the number of files
          FILE_COUNT=$(find public -type f | wc -l)
          if [ "$FILE_COUNT" -lt 5 ]; then
            echo "❌ Too few files ($FILE_COUNT) in public/ — probably build failed!"
            exit 1
          fi
          echo "✅ Hugo output looks OK: $FILE_COUNT files generated."

      - name: Check generated HTML
        run: |
          sudo apt install -y ruby ruby-dev
          sudo gem install html-proofer
          sudo htmlproofer ./public --disable-external --allow-missing-href

  deploy:
    name: Deploy to remote server
    needs: build
    if: success()
    runs-on: ubuntu-latest

    steps:
      - name: Run a multi-line script
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.KEY }}" > ~/.ssh/id_ed25519
          chmod 700 ~/.ssh/
          chmod 600 ~/.ssh/id_ed25519
          echo -e "Host *\nStrictHostKeyChecking no\n" > ~/.ssh/config
          ssh ${{ secrets.HOST }} -p ${{ secrets.PORT }} -l ${{ secrets.USER }}
          rm -rf ~/.ssh

  notify:
    runs-on: ubuntu-latest
    needs: [build, deploy]
    if: always()
    steps:
      - name: Send Telegram summary
        uses: appleboy/telegram-action@master
        with:
          to: ${{ secrets.BLOG_TELEGRAM_ID }}
          token: ${{ secrets.BLOG_TELEGRAM_TOKEN }}
          message: |
            🚀 **CI/CD Summary**
            Repository: ${{ github.repository }}
            Commit: ${{ github.sha }}
            Author: ${{ github.actor }}
            Message: ${{ github.event.head_commit.message }}
            --------------------------------
            🧱 Build:  ${{ needs.build.result }}
            📦 Deploy: ${{ needs.deploy.result }}
Click to expand and view more

Be sure to replace the domain name with your own:

BASH
sed -i 's|hugo.r4ven.me|example.com|g' .github/workflows/deploy.yml
Click to expand and view more

Now we need to create SSH access credentials for our remote server for the pipeline’s deploy stage.

On the client machine, generate a new SSH key and display its PRIVATE part in the terminal:

BASH
ssh-keygen -q -N "" -t ed25519 -f ~/.ssh/id_ed25519_github_cicd

cat ~/.ssh/id_ed25519_github_cicd
Click to expand and view more
id_ed25519_github_cicd
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
Click to expand and view more

Now go to the repository settings, open the secrets and variables section, and add new secrets:

KeyValue
USERivan
HOSThugo.r4ven.me
PORT2222
KEY—–BEGIN OPENSSH PRIVATE KEY—–

—–END OPENSSH PRIVATE KEY—–
BLOG_TELEGRAM_ID12345678
BLOG_TELEGRAM_TOKEN123456qwerty

The spoiler below contains brief instructions for obtaining the bot token (BLOG_TELEGRAM_TOKEN) and chat ID (BLOG_TELEGRAM_ID):

You should now have secrets like these:

Now display the contents of the PUBLIC key in the terminal:

BASH
cat ~/.ssh/id_ed25519_github_cicd.pub
Click to expand and view more
id_ed25519_github_cicd.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGvDu1omD1WwG1K68/mkvnBw/d4xRpREHDH4ytMDOl20
Click to expand and view more

Then configure the script to run when the pipeline connects during the deploy stage.

Now create a specially configured SSH authorized_keys file on the server:

BASH
mkdir -p ~/.ssh/

chmod 700 ~/.ssh/

echo 'command="sudo /opt/hugo/deploy.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGvDu1omD1WwG1K68/mkvnBw/d4xRpREHDH4ytMDOl20' >> ~/.ssh/authorized_keys
Click to expand and view more

Where, instead of ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGvDu1omD1WwG1K68/mkvnBw/d4xRpREHDH4ytMDOl20, specify your public key, which you displayed in the terminal on the client machine in the previous step.

Now configure the server so that sudo /opt/hugo/deploy.sh can run successfully.

Create a sudoers file:

BASH
visudo -f /etc/sudoers.d/90_hugo
Click to expand and view more

Add the following content:

PLAINTEXT
ivan ALL=(root) NOPASSWD: /opt/hugo/deploy.sh
Click to expand and view more

Now create a test deploy.sh on the server:

BASH
echo -e '#!/usr/bin/bash\necho Pong' > /opt/hugo/deploy.sh

chmod 700 /opt/hugo/deploy.sh
Click to expand and view more

Let’s test it from the client where we generated the CI/CD key: id_ed25519_github_cicd.

Try connecting to the server:

BASH
ssh -p 2222 -i ~/.ssh/id_ed25519_github_cicd ivan@hugo.r4ven.me
Click to expand and view more

In response, you should receive Pong from our deploy.sh:

PLAINTEXT
PTY allocation request failed on channel 0
Pong
Connection to hugo.r4ven.me closed.
Click to expand and view more

If so, everything is configured correctly.

Now let’s write a complete Bash script for deploying Hugo in the ~/Hugo project directory:

BASH
nvim ./deploy.sh
Click to expand and view more

Add the following content:

BASH
#!/usr/bin/env bash

# Safe script execution options:
# -E: functions inherit the trap
# -e: exit on any error
# -u: error when using uninitialized variables
# -o pipefail: return the error code of the last failed command in a pipeline
set -Eeuo pipefail

# Explicitly define PATH to avoid problems finding binaries
export PATH="/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin"


# Project variables
PROJECT_NAME="hugo"           # Hugo project/service name
PROJECT_WEB="angie"           # Project web server name
PROJECT_DIR="/opt/hugo"       # Project directory


# Logging settings
LOG_TO_STDOUT=1       # output logs to stdout
LOG_TO_FILE=0         # log to a file (<script_name>.log)
LOG_TO_SYSLOG=0       # log to syslog with the <script_name> tag


# Main script variables
SCRIPT_PID=$$    # PID of the current script
# Absolute path to the script directory
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd -P)
SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"    # Script name
SCRIPT_LOG="${SCRIPT_DIR}/${SCRIPT_NAME%.*}.log" # Log file
SCRIPT_LOG_PREFIX='[%Y-%m-%d %H:%M:%S.%3N]'     # Log timestamp format
SCRIPT_LOCK="${SCRIPT_DIR}/${SCRIPT_NAME%.*}.lock" # Lock file


# Function for cleaning up resources on exit or errors
cleanup() {
    trap - SIGINT SIGTERM SIGHUP SIGQUIT ERR EXIT  # reset traps

    # Close the lock descriptor if it was opened
    [[ -n "${fd_lock-}" ]] && exec {fd_lock}>&-

    # Remove the lock file if it belongs to the current process
    if [[ -f "$SCRIPT_LOCK" && $(< "$SCRIPT_LOCK") -eq $SCRIPT_PID ]]; then
        rm -f "$SCRIPT_LOCK"
    fi
}


# Logging function
logging() {
    while IFS= read -r line; do
        # Format the log line with a timestamp
        log_line="$(date +"${SCRIPT_LOG_PREFIX}") - $line"

        # Output the log to stdout if enabled
        if (( "$LOG_TO_STDOUT" )); then echo "$log_line"; fi

        # Log to a file if enabled
        if (( "$LOG_TO_FILE" )); then echo "$log_line" >> "$SCRIPT_LOG"; fi

        # Log to syslog if enabled
        if (( "$LOG_TO_SYSLOG" )); then logger -t "$SCRIPT_NAME" -- "$line"; fi
    done
}


# Script locking function to prevent parallel execution
lock_script() {
    # Open the lock file descriptor for writing
    exec {fd_lock}>> "${SCRIPT_LOCK}"

    # Try to acquire an exclusive lock; exit if unsuccessful
    if ! flock -n "$fd_lock"; then
        echo "Another script instance is already running, exiting..."
        exit 1
    fi

    # Write the current script PID to the lock file
    echo "$SCRIPT_PID" > "$SCRIPT_LOCK"
}


# Main deployment function
deploy() {
    echo "Check git and docker binaries"
    # Check for git and docker
    if ! command -v git; then echo >&2 "Git is not installed"; fi
    if ! command -v docker; then echo >&2 "Docker is not installed"; fi

    # Move to the project directory if it is writable
    if [[ -w "$PROJECT_DIR" ]]; then
        cd "$PROJECT_DIR"
    else
        echo "No such directory: $PROJECT_DIR"
        exit 1
    fi

    # Update the git repository if it exists
    if [[ -d .git ]]; then
        echo "Pull changes from main branch"
        git checkout main
        git fetch --all
        git reset --hard origin/main
    else
        echo "No git files found"
        exit 1
    fi

    echo "Restart $PROJECT_NAME service"
    #systemctl restart "$PROJECT_NAME"   # systemctl can be used
    docker compose up hugo               # start the service through Docker Compose

    sleep 3  # short timeout

    echo "Waiting for site generation..."
    # Wait for the Hugo service to finish
    while docker compose ps --services --filter "status=running" | grep -q "^$PROJECT_NAME$"; do
        echo "Waiting for site generation..."
        sleep 1
    done

    echo 'Done!'

    # Check that the web server is running
    echo "Checking if webserver container is running"
    if docker compose ps --services --filter "status=running" | grep -q "^$PROJECT_WEB$"; then
        echo 'Up!'
    else
        echo "Container with $PROJECT_WEB webserver is not running"
        exit 1
    fi

    sleep 3

    # Output the latest service logs
    echo "Some output of running service"
    LOG=$(journalctl -n 50 --no-pager -u "$PROJECT_NAME")
    echo "------------------------------"
    echo "$LOG"
    echo "------------------------------"
}


# Main script function
main() {
    # Catch signals and errors and call cleanup
    trap 'RC=$?; cleanup; exit $RC' SIGINT SIGHUP SIGTERM SIGQUIT ERR EXIT
    # Redirect stdout and stderr to the logging function
    exec > >(logging) 2>&1
    lock_script   # lock the script
    deploy        # start deployment
}


# Entry point
if main; then
    sleep 1
    echo "Deploy completed successfully"
    exit 0
else
    echo "Deploy failed"
    exit 1
fi
Click to expand and view more

The script provides safe execution, logs actions to stdout, a file, or syslog, prevents parallel execution with a lock, and performs the following steps:

On success, it displays a successful completion message; otherwise, it reports an error.

All script output will be visible in the CI/CD section on GitHub.

Make the script executable:

BASH
chmod 700 ./deploy.sh
Click to expand and view more

Finally, commit the changes and push them to the remote repository:

BASH
git add .

git commit -m 'Get test deploy!'

git push origin main
Click to expand and view more

Open GitHub on the web and go to the Actions section:

We can see the running pipeline:

Open it to watch the CI/CD stages run in real time.

On successful completion, it will look like this:

The deploy stage output should contain Pong!.

This is a good sign: the pipeline worked correctly. All that remains is to bring the deploy.sh script onto the server.

In the project directory on the server, pull the changes that we previously pushed from the client:

BASH
cd /opt/hugo

git fetch --all

git reset --hard origin/main
Click to expand and view more

Check that the current script has arrived on the server:

BASH
less deploy.sh
Click to expand and view more

Everything is in place. Now perform a final test of the entire pipeline plus the deployment script.

Add a new Markdown post from the client machine:

BASH
cd ~/Hugo

cat << EOF > ./hugo_data/hugo.r4ven.me/content/posts/my-third-post.md
---
title: "My third Post"
date: 2025-08-20
draft: false
categories: ["Test"]
tags: ["Hugo", "Guide"]
---

## Hello, R4ven!
EOF
Click to expand and view more

Push the changes to the repository:

BASH
git add . && git commit -m 'Get real deploy!' && git push
Click to expand and view more

View the actions section on GitHub:

Excellent! The pipeline has run. The “Deploy to remote server” stage will contain our script output:

Now check in the browser whether our site has been updated:

Indeed it has! A third post has appeared:

If you have successfully reached this point, congratulations! You have already completed the hardest part.

All that remains is to make publishing new content and configuring the site more convenient…

Configuring Obsidian

Launch Obsidian and open the folder as a vault:

Select ~/Hugo/hugo.r4ven.me/content/:

We can see our posts. Now go to the application settings:

Basic Markdown Configuration

The Editor section:

KeyValue
Strict line breaksenable
Use tabsdisable

The Files and links section:

KeyValue
Default location for new notesIn the folder specified below
Folder to create new notes inposts
New link formatRelative path to file
Use [[Wikilinks]]disable
Default location for new attachmentsIn subfolder under current folder
Subfolder nameattachments

Installing the Git Plugin

Go to “Settings” –> “Community plugins” –> “Browse”

Install and enable the Git plugin:

Now the button on the left opens the panel on the right🤷‍♂️.

It will show the changes tracked by Git:

We did not have to configure the repository in the Git plugin because we had already initialized the ~/Hugo directory.

Add posts and click the “Commit-and-sync” button in the panel on the right:

A notification will confirm that the push to the remote repository succeeded:

Installing the Templater Plugin (Optional)

I recommend the Templater plugin. It lets you write scripts that automate actions with notes inside Obsidian.

Let me show you what I mean. First, install Templater:

Go to the plugin settings and set “Template folder location” to templates:

Now create a templates folder in the Obsidian root and an init file inside it. Add the following content:

PLAINTEXT
<%*
function translit(str) {
    const map = {
        'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'yo','ж':'zh',
        'з':'z','и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o',
        'п':'p','р':'r','с':'s','т':'t','у':'u','ф':'f','х':'h','ц':'c',
        'ч':'ch','ш':'sh','щ':'shch','ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya'
    };
    return str.toLowerCase()
        .trim()
        .replace(/\s+/g, 
'-')             // spaces to hyphens
        .split('')
        .map(c => map[c] ?? c)
        .join('')
        .replace(/[^a-z0-9\-_.]/g, '')
        .replace(/-+/g, '-')
        .replace(/^-+|-+$/g, '');
}

function getAllFolders() {
    const out = [];
    const root = app.vault.getRoot();
    function walk(folder) {
        out.push(folder.path);
        if (!folder.children) return;
        folder.children.forEach(c => {
            if (Array.isArray(c.children)) walk(c);
        });
    }
    walk(root);
    return out;
}

try {
    const folders = getAllFolders();
    if (!folders || folders.length === 0) {
        new Notice("Failed to get the list of folders in the vault.");
        return;
    }

    // select the base folder (for example, content/posts/linux)
    const basePath = await tp.system.suggester(folders, folders);
    if (!basePath) {
        new Notice("Folder selection canceled.");
        return;
    }

    const today = tp.date.now("YYYY-MM-DD");
    const rusName = await tp.system.prompt("Enter the title (in Russian):");
    if (!rusName) {
        new Notice("No title entered; canceled.");
        return;
    }

    const slug = translit(rusName) || "untitled";
    const folderName = `${today}-${slug}`;
    const finalPath = `${basePath}/${folderName}`;

    // create a new folder for the post
    await app.vault.createFolder(finalPath).catch(()=>{});

    // category is the last element of basePath
    const categoryName = basePath.split("/").pop();

    // front matter
    const frontmatter = 
`---
draft: true
title: ${JSON.stringify(rusName)}
date: ${today}
lastmod:
author: Иван Чёрный
toc: false
slug: ${slug}
url: /${categoryName}/${slug}
aliases:
categories:
  - ${categoryName}
tags:
  - raven
cover: cover.jpg
description: Post description.
---

## Introduction

## Article Content

## Conclusion
`;

    // create index.md
    const targetPath = `${finalPath}/index.md`;
    const existing = app.vault.getAbstractFileByPath(targetPath);
    if (existing) {
        new Notice(`⚠️ Already exists: ${targetPath}`);
    } else {
        await app.vault.create(targetPath, frontmatter);
        new Notice(`✅ Created: ${targetPath}`);
    }

} catch (err) {
    new Notice("Script error: " + (err?.message ?? String(err)));
    console.error(err);
}
%>
Click to expand and view more

Try using the template: press Alt+e (or the plugin button on the left) and select init:

The template will ask which directory should contain the new post:

Next, specify the post title, preferably one that will work well as the heading of your new website article:

Press Enter. As a result, a separate folder for the post (date + transliterated name) and an index.md file with prefilled front matter and article body will be created in the posts folder.

Convenient? I think so!👍

Before publishing, be sure to remove the draft “flag” from the front matter: draft.

Add a couple of images to the post with a simple Ctrl+c, Ctrl+v, plus a small code block for variety:

Also place the article cover, cover.png (specified in the front matter), next to the post’s index.md file. The name must match.

Now let’s publish all of this.

Testing the Content Publishing Process

Click Commit-and-sync:

Open GitHub on the web to check:

Deployment is complete🏁:

The Telegram notification has arrived📬:

The article has appeared on the site🌐:

Cool!🎉

Conclusion

Whew..😔 That was a long and fascinating journey! In the end, we built a fast and secure static site with Hugo and, most importantly, configured a convenient publishing process: write posts in Obsidian, click “one button,” and within a few minutes the content is automatically updated on the server. This is what people now call the modern “Docs as Code” approach to blogging: efficient, automated, and enjoyable😎.

Thank you for reading!

P.S. I recommend taking a closer look at Obsidian; it is quite a powerhouse. In my experience, it greatly simplifies writing and organizing articles and notes.

References

Copyright Notice

Author: Ivan Cherniy

Link: https://r4ven.me/en/web/podnimaem-svoy-sayt-na-hugo-s-publikaciey-kontenta-cherez-obsidian/

License: CC BY-NC-SA 4.0

Blog materials may be used with attribution to the author and source, for non-commercial purposes, and under the same license.

Start searching

Enter keywords to search articles

↑↓
ESC
⌘K Shortcut