cState is an open-source status page built on the Hugo static site generator. There is no database and no application server: components are listed in a configuration file, every incident is a Markdown file in a Git repository, and Hugo turns them into plain HTML, an RSS feed and a JSON file. Because the output is static, you can host it somewhere completely independent of the infrastructure it reports on. In this tutorial you will create a cState site on Ubuntu 24.04, define your components, publish and resolve an incident, and deploy it to GitHub Pages with GitHub Actions or to your own Nginx server.

Prerequisites

To follow this tutorial you need:

  • A machine running Ubuntu 24.04 LTS to build the site on: your workstation, or a server such as a CubePath VPS, with a non-root sudo user.
  • Git installed (sudo apt install git).
  • A GitHub account if you want to host the page on GitHub Pages, or a separate server with Nginx if you want to host it yourself.
  • Optionally, a subdomain such as status.your_domain for the page. Point it somewhere other than your main infrastructure, so the status page stays up during an outage.

Step 1 - Installing Hugo extended

cState needs the extended edition of Hugo. Theme compatibility matters here: cState 6.0 builds with Hugo 0.160.0, while Hugo 0.166.0 fails with a template "small" not found error. This guide pins 0.160.0, and you will use the same version in CI so local and published builds match.

Download the Debian package from the Hugo releases page and install it:

cd /tmp
curl -fsSLO https://github.com/gohugoio/hugo/releases/download/v0.160.0/hugo_extended_0.160.0_linux-amd64.deb
sudo apt install ./hugo_extended_0.160.0_linux-amd64.deb

On ARM machines, use the linux-arm64 package instead. Check the version:

hugo version
hugo v0.160.0-...+extended linux/amd64 BuildDate=... VendorInfo=gohugoio

The +extended part must be present.

Step 2 - Creating the site from the example repository

cState's recommended starting point is its example site, which already contains a working configuration and includes the theme as a Git submodule in themes/cstate. Clone it with --recursive so the submodule is downloaded too:

cd ~
git clone --recursive -b master https://github.com/cstate/example.git status-page
cd status-page

Check that the theme is there:

ls themes/cstate
archetypes  CODE_OF_CONDUCT.md  CONTRIBUTING.md  docker  Dockerfile  exampleSite  i18n  images  layouts  LICENSE.md  README.md  static  theme.toml

If themes/cstate is empty, run git submodule update --init --recursive.

The example ships with sample incidents and a prebuilt public/ directory. Remove both, keep build output out of Git, and rename the branch to main:

git rm -q content/issues/*.md
git rm -rq public
git rm -q --cached .hugo_build.lock
echo "public/" >> .gitignore
echo ".hugo_build.lock" >> .gitignore
git branch -M main

The site now builds from your own content only.

Step 3 - Configuring the page and its components

All settings live in config.yml. Open it:

nano config.yml

At the top, set the page title and its final URL:

title: Example Status
baseURL: https://status.your_domain/

If you will use GitHub Pages without a custom domain, you can leave baseURL as it is; the deployment workflow in Step 6 overrides it with the correct address.

Further down, replace the categories and systems lists under params with your own. Categories group components under a heading, and closed: true collapses a category by default. Each system is one component on the page:

params:
  categories:
    - name: Customer-facing
      description: Services our customers use directly.
    - name: Infrastructure
      description: Internal systems the services depend on.
      closed: true

  systems:
    - name: Website
      category: Customer-facing
      link: https://your_domain/
    - name: API
      description: Public REST API.
      category: Customer-facing
    - name: Database
      category: Infrastructure
    - name: Email delivery
      category: Infrastructure

Remember the exact system names: incidents refer to them. In the same params block, adjust a few more values:

  • description: the sentence shown under the overall status.
  • brand, ok, disrupted, down, notice: colors of the header and of each state, as hex values.
  • logo and useLogo: put your logo in static/logo.png and keep useLogo: true, or set it to false to show the title as text.
  • googleAnalytics: delete this line unless you actually use Google Analytics.

To show the interface in another language, set both languageCode and defaultContentLanguage at the top of the file to one of the languages in themes/cstate/i18n, for example es for Spanish.

Step 4 - Previewing the site locally

Start Hugo's development server. It rebuilds the site every time you save a file:

hugo server
Web Server is available at http://localhost:1313/ (bind address 127.0.0.1)
Press Ctrl+C to stop

Open http://localhost:1313 in your browser. If you are building on a remote server, open an SSH tunnel from your workstation first with ssh -L 1313:localhost:1313 your_user@your_server_ip. You may see a deprecation warning about languageCode; it does not affect the build.

cState also publishes a machine-readable summary at /index.json. In a second terminal, check the overall status:

curl -s http://localhost:1313/index.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["summaryStatus"])'
ok

Step 5 - Reporting and resolving an incident

Each incident is a Markdown file in content/issues/. The file name becomes the URL, so start it with the date. Create one:

nano content/issues/2026-09-25-api-latency.md
---
title: Elevated API latency
date: 2026-09-25 14:20:00
resolved: false
severity: disrupted
affected:
  - API
section: issue
---

*Investigating* - We are investigating slower than normal API responses. {{< track "2026-09-25 14:20:00" >}}

The front matter fields are:

FieldMeaning
titleIncident title shown on the page (required)
dateWhen the problem started, without time zone (required)
resolvedfalse while the incident affects the status, true afterwards (required)
resolvedWhenWhen the impact ended; add it when you resolve the incident
severitynotice, disrupted or down (required)
affectedList of system names exactly as written in config.yml (required)
sectionAlways issue (required)

The track shortcode prints a timestamp next to each update. Save the file and look at the preview: the API component turns orange and the header shows the incident. The JSON summary changes as well:

curl -s http://localhost:1313/index.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["summaryStatus"])'
disrupted

When the incident is over, add the newest update at the top of the body, set resolved: true and add resolvedWhen:

---
title: Elevated API latency
date: 2026-09-25 14:20:00
resolved: true
resolvedWhen: 2026-09-25 15:05:00
severity: disrupted
affected:
  - API
section: issue
---

*Resolved* - A saturated database connection pool caused the slowdown. The pool size was increased and response times are back to normal. {{< track "2026-09-25 15:05:00" >}}

*Investigating* - We are investigating slower than normal API responses. {{< track "2026-09-25 14:20:00" >}}

The component returns to green and the incident moves to the history section.

For announcements that should not change any component's status, such as planned maintenance, use informational: true and pin the notice to the top of the page with pin: true:

---
title: Planned database maintenance on October 3
date: 2026-09-26 09:00:00
informational: true
pin: true
section: issue
---

On October 3 between 02:00 and 02:30 UTC we will upgrade the database cluster. The API may return errors for up to five minutes during this window.

Stop the preview with CTRL+C and commit your changes:

git add -A
git commit -m "Configure status page and add first incident"

Step 6 - Deploying to GitHub Pages

Create an empty repository on GitHub, for example your_user/status-page, and point your local repository at it:

git remote set-url origin [email protected]:your_user/status-page.git

Add a workflow that installs the same Hugo version, builds the site and publishes it:

mkdir -p .github/workflows
nano .github/workflows/pages.yml
name: Deploy status page

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-24.04
    env:
      HUGO_VERSION: 0.160.0
    steps:
      - name: Install Hugo
        run: |
          curl -fsSLo "${RUNNER_TEMP}/hugo.deb" \
            "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb"
          sudo dpkg -i "${RUNNER_TEMP}/hugo.deb"

      - name: Check out the repository and the cState submodule
        uses: actions/checkout@v5
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Configure Pages
        id: pages
        uses: actions/configure-pages@v5

      - name: Build
        run: hugo --minify --baseURL "${{ steps.pages.outputs.base_url }}/"

      - name: Upload the site
        uses: actions/upload-pages-artifact@v4
        with:
          path: ./public

  deploy:
    needs: build
    runs-on: ubuntu-24.04
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

fetch-depth: 0 downloads the full Git history, which cState uses to show when each page was last modified.

In the repository on GitHub, open Settings > Pages and set Source to GitHub Actions. Then commit the workflow and push:

git add .github/workflows/pages.yml
git commit -m "Deploy with GitHub Actions"
git push -u origin main

Follow the run under the repository's Actions tab. When it finishes, the page is live at https://your_user.github.io/status-page/. Verify it from the command line:

curl -s https://your_user.github.io/status-page/index.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["summaryStatus"])'

To use status.your_domain, enter it under Settings > Pages > Custom domain, create a CNAME DNS record pointing status to your_user.github.io, and enable Enforce HTTPS once the certificate is issued. The next deployment picks up the new address automatically.

From now on, reporting an incident is a commit: add or edit a file in content/issues/, push, and the page updates in about a minute. You can even do it from GitHub's web editor when your own machines are unreachable.

Step 7 - Hosting on your own Nginx server (alternative)

If you prefer to host the page yourself, use a server that does not share infrastructure with the services it reports on. On that server, install Nginx and Certbot, open the firewall and create the web root:

sudo apt update
sudo apt install nginx certbot python3-certbot-nginx rsync
sudo ufw allow 'Nginx Full'
sudo mkdir -p /var/www/status
sudo chown your_user:your_user /var/www/status

Create a server block:

sudo nano /etc/nginx/sites-available/status
server {
    listen 80;
    listen [::]:80;
    server_name status.your_domain;

    root /var/www/status;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

Enable it and request a certificate:

sudo ln -s /etc/nginx/sites-available/status /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d status.your_domain

On your build machine, build the site with its final URL and copy it over. --delete removes files that no longer exist, such as renamed incidents:

hugo --minify --baseURL https://status.your_domain/
rsync -az --delete public/ your_user@status_server_ip:/var/www/status/

Check the result:

curl -s https://status.your_domain/index.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["summaryStatus"])'

Troubleshooting

hugo fails with template "small" not found or other template errors. Your Hugo version is newer than what the theme supports. Install the pinned version from Step 1 and use the same HUGO_VERSION in the workflow.

failed to load Git data: not a git repository. enableGitInfo: true in config.yml requires the site to be a Git repository. Build from inside the cloned repository, or set enableGitInfo: false.

themes/cstate is empty or the build cannot find the theme. The submodule was not downloaded. Run git submodule update --init --recursive locally, and make sure the workflow uses submodules: recursive.

An incident does not change a component's status. The names under affected must match the name of a system in config.yml exactly, including capitalization, resolved must be false, and section must be issue.

The published page has broken styles or links. baseURL does not match the address the page is served from. On GitHub Pages, let the workflow set it; when self-hosting, pass --baseURL with a trailing slash.

Conclusion

You built a cState status page with Hugo, defined your components, reported and resolved an incident with plain Markdown files, and deployed it with GitHub Actions or to your own Nginx server. As next steps, write down in your incident process who is allowed to push to the status repository, subscribe your team to the page's RSS feed at /index.xml, and read /index.json from your chat or monitoring tools to show the current status elsewhere.