Hugo Search with Algolia and GitLab CI

Page content

Roadster’s default sidebar search sends queries to Google (or DuckDuckGo) with a site filter. That works until the corpus is large and you want typo tolerance, facets by section/tags, and results that stay on your domain. Algolia hosts the index; Hugo emits a JSON dump at build time; atomic-algolia pushes only the diff from GitLab CI after hugo succeeds.

Hugo

This pairs with the GitLab CI to GitHub Pages flow: build once, deploy public/, then update the search index with CI secrets—never with keys in git.

Architecture

  content/*.md
       |
       v
  hugo  -->  public/algolia.json   -->  atomic-algolia  -->  Algolia index
       |                                      ^
       +-->  public/*.html  (Pages deploy)    |
                                              |
                                    ALGOLIA_* CI variables

Browser InstantSearch (or Autocomplete) talks to Algolia with the search-only API key. The admin key stays in GitLab CI/CD variables and is used only by atomic-algolia.

1. Create the Algolia app and index

  1. Create an application in the Algolia dashboard.
  2. Create an empty index (for example devopstales).
  3. Copy Application ID, Admin API key (write), and Search-only API key (public).

Never commit the admin key. The search-only key is safe to expose in front-end JS.

2. Emit algolia.json from Hugo

Add an output format and attach it to the home page:

[outputFormats.Algolia]
  baseName = "algolia"
  isPlainText = true
  mediaType = "application/json"
  notAlternative = true

[outputs]
  home = ["HTML", "RSS", "ATOM", "llms", "WebManifest", "Algolia"]

Create layouts/index.algolia.json (name follows the format). Index only real sections—skip filedir partials the same way Atom does:

{{- $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections -}}
{{- $pages = where $pages ".Params.disable_feed" "!=" true -}}
{{- $index := slice -}}
{{- range $pages -}}
  {{- $index = $index | append (dict
    "objectID" .File.UniqueID
    "title" .Title
    "summary" (.Summary | plainify)
    "content" (.Plain | truncate 5000)
    "permalink" .Permalink
    "section" .Section
    "date" (.Date.Format "2006-01-02")
    "tags" (.Params.tags | default slice)
  ) -}}
{{- end -}}
{{- $index | jsonify -}}

objectID is required for atomic updates. Prefer a stable id (file unique id or permalink) so renames/updates merge correctly.

Build and inspect:

hugo
head -c 400 public/algolia.json; echo

3. atomic-algolia locally

npm init -y
npm install atomic-algolia --save-dev

package.json:

{
  "scripts": {
    "algolia": "atomic-algolia"
  }
}

Local env (do not commit):

export ALGOLIA_APP_ID="YOUR_APP_ID"
export ALGOLIA_ADMIN_KEY="YOUR_ADMIN_KEY"
export ALGOLIA_INDEX_NAME="devopstales"
export ALGOLIA_INDEX_FILE="public/algolia.json"

npm run algolia

You should see add/update/remove counts in the CLI, then records in the Algolia dashboard. Details on the incremental upload model are in the atomic-algolia package docs and in writeups such as tisgoud’s GitLab CI example.

4. Wire GitLab CI

Extend the existing Hugo job (same image family that already builds the site). After hugo and before or after the Pages push:

hugo-build:
  image: hugomods/hugo:git-0.148.0
  variables:
    GIT_SUBMODULE_STRATEGY: recursive
  before_script:
    - apk add --no-cache npm
    - npm ci || npm install
  script:
    - hugo
    - touch public/.nojekyll
    # deploy public/ to GitHub Pages (existing steps)...
    - npm run algolia
  only:
    - master

Set masked/protected CI/CD variables in GitLab:

Variable Purpose
ALGOLIA_APP_ID Application id
ALGOLIA_ADMIN_KEY Admin (write) key
ALGOLIA_INDEX_NAME Index name
ALGOLIA_INDEX_FILE public/algolia.json

Run npm run algolia only on the branch that publishes production, so preview pipelines do not overwrite the live index. If you use merge-request builds, gate the step:

- if [ "$CI_COMMIT_BRANCH" = "master" ]; then npm run algolia; fi

Commit package.json and package-lock.json so npm ci is reproducible. Prefer that over npm init inside the job on every run.

5. Front-end InstantSearch (replace the sidebar form)

Keep search-only credentials in site params (public):

[Params.algolia]
  appId = "YOUR_APP_ID"
  indexName = "devopstales"
  searchKey = "YOUR_SEARCH_ONLY_KEY"

Add a small partial (for example layouts/partials/widgets/algolia-search.html) that loads InstantSearch from a CDN and mounts on a container, then swap "search" in Params.sidebar.widgets for your Algolia widget—or override layouts/partials/widgets/search.html.

Minimal sketch:

<div id="algolia-search"></div>
<script src="https://cdn.jsdelivr.net/npm/algoliasearch@4/dist/algoliasearch-lite.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/instantsearch.js@4/dist/instantsearch.production.min.js"></script>
<script>
const { appId, indexName, searchKey } = {{ .Site.Params.algolia | jsonify }};
const search = instantsearch({
  indexName,
  searchClient: algoliasearch(appId, searchKey),
});
search.addWidgets([
  instantsearch.widgets.searchBox({ container: '#algolia-search' }),
  instantsearch.widgets.hits({
    container: '#algolia-hits',
    templates: {
      item: (hit) => `<a href="${hit.permalink}"><strong>${hit.title}</strong><br>${hit.summary || ''}</a>`,
    },
  }),
]);
search.start();
</script>
<div id="algolia-hits"></div>

Tune CSS to match Roadster’s sidebar. Facet on section or tags when you want filters for kubernetes vs AI vs Hugo.

Official UI building blocks are documented under Algolia InstantSearch.

6. Operational tips

  • Quota: atomic updates send diffs; full reindex only when you change the record shape.
  • Content size: truncate .Plain so records stay under Algolia record limits.
  • Drafts: production hugo already omits drafts; keep it that way in CI.
  • Sections: filter with mainSections so filedir HTML includes never become hits.
  • Secrets: rotate the admin key if it ever appeared in a job log or commit.
  • Fallback: leave the old Google site-search widget behind a feature flag until InstantSearch is verified.

Verify

hugo
npm run algolia

curl -s "https://latency-dsn.algolia.net/1/indexes/devopstales?query=kubernetes&x-algolia-application-id=$ALGOLIA_APP_ID&x-algolia-api-key=$ALGOLIA_SEARCH_KEY" | head

On the live site: open the sidebar, type a known title fragment, confirm permalinks resolve under https://devopstales.github.io/.

Summary

Generate public/algolia.json with a Hugo home output format, push diffs with atomic-algolia from GitLab CI using masked admin credentials, and query with InstantSearch using the search-only key in the theme. You keep the existing GitLab → Pages deploy path and add hosted search without turning Hugo into an application server.