Jesse A. Green / notebook

Live demo: the DocCompiler guide library as a static, read-only snapshot (October 2026). No backend, no accounts, nothing to submit.

Halo Admin Guide

Generated June 2026 from halo-dev/halo at 7bf96ee

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

This guide is the operational and architectural reference for Halo, the open-source website-building platform. It is written for administrators, DevOps engineers, and developers who deploy, operate, secure, extend, or contribute to Halo. It covers the system architecture, the complete configuration surface, deployment patterns, security hardening, day-to-day operations, the REST API, and the internals needed to build themes and plugins or to reason about the codebase.

Note

Halo is a single Spring Boot application written in Java 21 using the reactive (WebFlux) stack, fronted by a Vue 3 + TypeScript single-page console. It uses a Kubernetes-style extension/custom-resource model for all of its data. Understanding that model (Section 3) is the key to understanding everything else.


1. Executive Summary

Attribute Value
What it is Self-hosted CMS / website builder (blog, knowledge base, docs, company site).
Backend Java 21, Spring Boot 3, Spring WebFlux (reactive, non-blocking, Netty).
Frontend Vue 3 + TypeScript SPA (Console + User Center), built with Vite.
Persistence Reactive R2DBC. H2 (default), PostgreSQL, MySQL, MariaDB.
Data model Kubernetes-inspired Extensions (custom resources) with schemes, reconcilers, watchers, and indexers.
Extensibility PF4J-based plugin system; Thymeleaf-based theme system; extension points.
Search Apache Lucene full-text indexing.
Cache Caffeine in-memory cache.
Security Spring Security reactive: OAuth2/JWT, TOTP 2FA, remember-me, personal access tokens, RBAC, rate limiting.
Default port 8090 (HTTP).
Working directory ~/.halo2 (host) / /root/.halo2 (container).
License GPL-3.0.
Distribution Docker image (halohub/halo, ghcr.io/halo-dev/halo) and executable Spring Boot JAR.
Analyzed commit 7bf96ee (version line 2.24.x).

Halo is designed so that a single operator can run a production website with minimal moving parts: one application process, one database, and a single working directory that holds all mutable state. Horizontal complexity (themes, plugins, storage backends, auth providers) is added at runtime through the extension and plugin systems rather than by changing core code.


2. System Architecture

2.1 High-level component diagram

                          ┌──────────────────────────────────────────────┐
   Browser (visitors)     │                   Halo JVM                    │
        │                 │            (Spring Boot 3 / WebFlux)          │
        ▼                 │                                               │
  ┌───────────┐  HTTP     │  ┌────────────────────────────────────────┐  │
  │  Reverse  │──────────▶│  │ Netty reactive web server  :8090       │  │
  │   proxy   │  :8090    │  └───────────────┬────────────────────────┘  │
  │ (nginx/   │           │                  │  Spring Security filter    │
  │  caddy/   │◀──────────│                  │  chain (reactive)          │
  │  1Panel)  │           │     ┌────────────┼────────────┬───────────┐  │
  └───────────┘           │     ▼            ▼            ▼           ▼  │
        ▲                 │  Theme       /apis REST    /console     /uc  │
        │                 │  router      endpoints     (SPA)       (SPA) │
  Browser (admins)        │  (Thymeleaf) (console/uc/   static      static│
                          │              public/ext)   assets      assets│
                          │     │            │                           │
                          │     ▼            ▼                           │
                          │  ┌──────────────────────────────────────┐   │
                          │  │  Extension (custom-resource) layer    │   │
                          │  │  Scheme · ReactiveExtensionClient ·   │   │
                          │  │  Reconcilers (controllers) · Watchers │   │
                          │  │  · Lucene indexers                    │   │
                          │  └───────┬───────────────────┬──────────┘   │
                          │          ▼                   ▼               │
                          │   ┌────────────┐      ┌─────────────┐        │
                          │   │ R2DBC repo │      │  PF4J plugin │        │
                          │   │  (DB store)│      │   manager    │        │
                          │   └─────┬──────┘      └──────┬──────┘        │
                          └─────────┼────────────────────┼───────────────┘
                                    ▼                     ▼
                          ┌──────────────────┐   ┌──────────────────┐
                          │  Database         │   │ Working directory │
                          │ H2/PG/MySQL/Maria │   │  ~/.halo2         │
                          └──────────────────┘   │  db/ attachments/ │
                                                  │  themes/ plugins/ │
                                                  │  logs/ backups/   │
                                                  └──────────────────┘

2.2 Runtime stack

Layer Technology Notes
Language / runtime Java 21 Compiled with options.release = 21; toolchain pinned to JDK 21.
Application framework Spring Boot 3 @SpringBootApplication(scanBasePackages = "run.halo.app"), @EnableScheduling, @ConfigurationPropertiesScan.
Web Spring WebFlux Fully reactive, non-blocking; Netty server. Virtual threads enabled (spring.threads.virtual.enabled: true).
Security Spring Security (reactive) OAuth2 jose/client/resource-server, custom auth managers.
Persistence R2DBC Reactive DB access; spring-boot-starter-data-r2dbc.
Templating Thymeleaf (reactive) Server-side rendering of the public theme.
Plugin runtime PF4J Isolated classloaders per plugin.
Cache Caffeine Via spring-boot-starter-cache.
Search Apache Lucene lucene-core, lucene-queryparser, lucene-highlighter, lucene-analysis-common.
Resilience Resilience4j Rate limiters for auth/signup/email/comment flows.
Email Spring Boot Mail Notification delivery.
API docs springdoc-openapi (WebFlux) Generates the OpenAPI spec consumed by the JS client.
Misc Guava, Jsoup, Apache Tika, Thumbnailator, java-diff-utils, json-patch, BouncyCastle, ua-parser Utility libraries (HTML parsing, MIME detection, image thumbnails, diffs/patches for revisions, crypto, user-agent parsing).

Note

The application runs on virtual threads (Project Loom) and a reactive web stack. Blocking calls inside reactive pipelines are an anti-pattern here; plugin authors must respect the reactive contract (return Mono/Flux, never block the event loop).

2.3 Gradle multi-module layout

Halo is a Gradle multi-project build. The modules:

Module Purpose
:api The public plugin API (run.halo.app.*). Published as a library so plugins compile against a stable surface. Depends on Spring Boot starters (webflux, security, data-r2dbc, actuator, mail, thymeleaf, cache), Lucene, PF4J, etc.
:application The runnable application. Bundles the API, the database drivers, the front-end build output, and the Spring Boot launcher. Produces the halo boot JAR and the Docker image.
:platform:application A BOM / dependency-management platform (java-platform) that pins versions for the whole build via libs.versions.toml bundles.
:platform:plugin The dependency platform that plugins depend on, so plugin builds align with the host's library versions.
:buildSrc Groovy Gradle convention plugins (e.g. halo.publish).
:ui The Vue front-end (pnpm workspace). Built with Node and copied into :application resources at build time.

The front-end :ui module is itself a pnpm workspace with published packages:

Package Purpose
@halo-dev/api-client OpenAPI-generated TypeScript REST client (core/console/uc/public).
@halo-dev/components Base UI component library (MIT).
@halo-dev/ui-shared Shared utilities/types/stores for plugins (definePlugin, etc.).
@halo-dev/richtext-editor The default Tiptap/ProseMirror editor.
@halo-dev/ui-plugin-bundler-kit Vite/Rsbuild build presets for plugin front-ends.

Tip

The console (console.html) and User Center (uc.html) are two entry points of one Vite multi-page app sharing ui/src. In production the backend serves /console/** → console.html, /uc/** → uc.html, and /ui-assets/** → static assets.


3. The Extension (Custom-Resource) Model — Architectural Keystone

Everything Halo stores — posts, pages, users, roles, themes, plugins, comments, settings — is an Extension: a typed, versioned, JSON document modeled directly on Kubernetes custom resources. Internalizing this model is the single most important thing for operating or extending Halo.

3.1 Core concepts

Concept Kubernetes analog In Halo (run.halo.app.extension)
Extension Custom Resource Extension / AbstractExtension — a document with apiVersion, kind, metadata, spec, status.
GVK GroupVersionKind GroupVersionKind / GVK annotation — identifies a type, e.g. content.halo.run/v1alpha1, Kind=Post.
Scheme API schema registration Scheme / SchemeManager — registers a type, its GVK, JSON schema, and plural/singular names.
Client API server client ReactiveExtensionClient / ExtensionClient — reactive CRUD over the store (create, get, update, delete, list, listBy, watch).
Metadata ObjectMeta Metadata — name, labels, annotations, creationTimestamp, version (optimistic locking), finalizers.
Reconciler Controller run.halo.app.extension.controller.* — watches a kind and drives actual state toward desired state.
Watcher Informer/watch Watcher — receives create/update/delete events.
Unstructured Unstructured Unstructured / JsonExtension — schema-less access to any extension.
ConfigMap / Secret ConfigMap / Secret ConfigMap, Secret — built-in extension kinds for configuration and credentials.

3.2 How a request flows

  1. A console/API call hits a reactive endpoint (for example "create post").
  2. The handler builds a Post extension and persists it via ReactiveExtensionClient.create(...).
  3. The extension is serialized to JSON and stored as a row in the database (R2DBC).
  4. A watcher notices the new/changed extension and notifies the registered reconciler (controller).
  5. The reconciler performs side effects: generating permalinks, updating counters, reindexing for search, rendering, cleanup, etc. — and may update the extension's status.
  6. Indexers maintain in-memory indexes (backed by Lucene for full-text) so list/query operations (ListOptions, field/label selectors) are fast without scanning the database.

Important

Optimistic concurrency is enforced via metadata.version. Updates carry the version they were read at; a mismatch means another writer won, and the update is rejected so the caller can retry. Plugin code must handle these retries.

3.3 Why this matters operationally

  • The database is a document store of JSON extensions, not a hand-designed relational schema you query directly. Do not edit rows by hand.
  • Backups must capture extensions + working files together — which is exactly what the full-site backup feature does (Section 9).
  • Reconcilers run asynchronously. After an import or restore, give controllers time to converge (rebuild indexes, regenerate derived data).
  • Plugins extend the model by registering new schemes and reconcilers — they participate as first-class citizens in the same machinery as core types.

4. Configuration Mastery

Halo is configured the standard Spring Boot way. Every setting can be supplied as:

  1. A command-line argument: --halo.external-url=https://example.com/
  2. An environment variable: HALO_EXTERNAL_URL=https://example.com/ (relaxed binding: dots/dashes → underscores, uppercased)
  3. A key in application.yaml placed in the working directory or on the classpath.

Precedence (highest first): command-line args → environment variables → external application.yaml → bundled defaults. In the official Docker image, SPRING_CONFIG_LOCATION is set to optional:classpath:/;optional:file:/root/.halo2/, so a application.yaml you drop into the mounted working directory is automatically picked up.

4.1 halo.* — application settings

These are bound from run.halo.app.infra.properties.HaloProperties and its nested classes (@ConfigurationProperties(prefix = "halo")). This is the authoritative, source-derived reference.

Top-level halo

Property Type Default Description
halo.work-dir Path ${user.home}/.halo2 Root directory for all mutable state (DB file, attachments, themes, plugins, logs, backups). Required.
halo.external-url URL (none) Public base URL of the site. Used to build absolute links (feeds, sitemap, social cards, emails). Must be a valid http/https URL.
halo.use-absolute-permalink boolean false Use absolute permalinks for posts/pages/categories/tags. Requires external-url to be set (validated at startup).
halo.initial-extension-locations Set<String> (empty) Extra locations of extension YAML to load on startup.
halo.required-extension-disabled boolean false Disables initialization of required classpath extensions (advanced/testing).

halo.security.*

Property Type Default Description
halo.security.basic-auth.disabled boolean true HTTP Basic auth is off by default.
halo.security.two-factor-auth.disabled boolean false Globally disable TOTP 2FA.
halo.security.frame-options.disabled boolean false Disable X-Frame-Options.
halo.security.frame-options.mode enum SAMEORIGIN X-Frame-Options mode (DENY, SAMEORIGIN).
halo.security.referrer-options.policy enum STRICT_ORIGIN_WHEN_CROSS_ORIGIN Referrer-Policy header value.
halo.security.cors-options.disabled boolean false Disable built-in CORS handling.
halo.security.cors-options.configs[].path-pattern String — Path pattern for a CORS config entry.
halo.security.cors-options.configs[].config object — Standard CORS settings for that path.
halo.security.remember-me.token-validity Duration 14d Lifetime of the remember-me token.
halo.security.remember-me.rotation-cooldown Duration 5m Minimum interval between token rotations (prevents false-positive theft detection from stale cookies).
halo.security.password-reset-methods[].name String email (preset) A password-reset method identifier.
halo.security.password-reset-methods[].href URI /password-reset/email The reset flow endpoint.
halo.security.password-reset-methods[].icon URI /images/... Icon shown on the reset page.

Note

JWT/OAuth2 signing keys are bound separately (JwtProperties) under halo.security.oauth2.jwt.*: public-key-location, private-key-location, issuer-uri, and jws-algorithm (default RS256). Halo manages RSA keys in the working directory by default; only override these if you have a specific key-management strategy.

halo.attachment.*

Property Type Default Description
halo.attachment.resource-mappings[].path-pattern String /upload/** (preset) URL pattern mapped to a local attachment folder (compatibility with Halo 1.x paths).
halo.attachment.resource-mappings[].locations List<String> migrate-from-1.x Folders (relative to the attachments dir) served under the pattern.
halo.attachment.thumbnail.disabled boolean false Disable automatic thumbnail generation.
halo.attachment.thumbnail.concurrent-threads int (≥1) (impl default) Threads used for thumbnail generation.
halo.attachment.thumbnail.quality double (0.0–1.0) (impl default) JPEG quality of generated thumbnails.

halo.theme.*, halo.ui.*, halo.extension.*

Property Type Default Description
halo.theme.initializer.disabled boolean false Skip installing the default theme on first start.
halo.theme.initializer.location String classpath:themes/theme-earth.zip Default theme package to install.
halo.theme.generator-meta-disabled boolean false Suppress the generator meta tag in rendered pages.
halo.ui.location String classpath:/ui/ Location of the built front-end assets.
halo.ui.proxy.enabled boolean false Dev only: proxy console/uc HTML to a running Vite dev server.
halo.ui.proxy.endpoint URI (none) Dev only: the dev server URL (e.g. http://localhost:3000/).
halo.extension.controller.disabled boolean false Disable the extension reconciler controllers (advanced/testing).

halo.notification.*

Property Type Default Description
halo.notification.auto-cleanup.enabled boolean false Periodically delete old notifications.
halo.notification.auto-cleanup.retention-days int 30 Age threshold for cleanup (0 = disabled).
halo.notification.auto-cleanup.cleanup-cron cron 0 0 3 * * ? When cleanup runs (default daily at 03:00).

4.2 spring.* and server settings (from application.yaml)

Property Default Description
server.port 8090 HTTP listen port.
server.forward-headers-strategy native Honor X-Forwarded-* from a reverse proxy.
server.compression.enabled true Gzip responses.
spring.r2dbc.url r2dbc:h2:file:///${halo.work-dir}/db/halo-next?MODE=MySQL... Reactive DB URL.
spring.r2dbc.username / .password admin / 123456 DB credentials (H2 file default — change for external DBs).
spring.sql.init.mode always Run schema initialization.
spring.sql.init.platform h2 Selects schema/migration dialect (h2/mysql/mariadb/postgresql).
spring.http.codecs.max-in-memory-size 10MB Max in-memory buffer for request/response bodies.
spring.cache.type caffeine Cache provider.
spring.cache.caffeine.spec expireAfterAccess=1h, maximumSize=10000 Caffeine sizing/eviction.
spring.threads.virtual.enabled true Use virtual threads.
spring.web.resources.cache.cachecontrol.max-age 365d Static asset cache lifetime.
spring.thymeleaf.reactive.max-chunk-size 8KB Reactive template chunk size.
logging.file.name ${halo.work-dir}/logs/halo.log Log file path.
logging.logback.rollingpolicy.max-file-size 10MB Per-file log rotation size.
logging.logback.rollingpolicy.total-size-cap 1GB Total log size cap.
r2dbc.migrate.resources-path classpath:/db/migration/${platform}/*.sql Migration scripts location.

4.3 Rate limiters (resilience4j.ratelimiter.configs.*)

Halo ships these named limiters (defaults from application.yaml). They protect abuse-prone flows.

Limiter Limit Window Guards
authentication 3 1 min Login attempts.
comment-creation 10 1 min Comment posting.
signup 3 1 hour New-user signup.
send-email-verification-code 1 1 min Sending verification codes.
verify-email 3 1 hour Email verification attempts.
send-password-reset-email 10 1 min Password-reset emails.
password-reset-verification 10 1 min Password-reset verification.

4.4 Environment variables and JVM options

Variable Default (image) Description
HALO_WORK_DIR /root/.halo2 Working directory (binds halo.work-dir).
SPRING_CONFIG_LOCATION optional:classpath:/;optional:file:/root/.halo2/ Where Spring looks for application.yaml.
JVM_OPTS (empty) Extra JVM flags (heap, GC). Example: -Xmx512m -Xms512m.
TZ Asia/Shanghai Container timezone.

Tip

Any application-<profile>.yaml (e.g. application-postgresql.yaml) ships in the image as a convenience profile. Activate with --spring.profiles.active=postgresql, or just set the three spring.r2dbc.* / spring.sql.init.platform values directly as shown in the User Guide.


5. Database

5.1 Supported engines and selection

Halo uses R2DBC (reactive) drivers, not JDBC. The bundled runtime drivers are r2dbc-h2, r2dbc-postgresql (+ postgresql), r2dbc-mariadb, and r2dbc-mysql (io.asyncer:r2dbc-mysql).

Engine spring.r2dbc.url example spring.sql.init.platform
H2 (file) r2dbc:h2:file:///${halo.work-dir}/db/halo-next?MODE=MySQL&DB_CLOSE_ON_EXIT=FALSE h2
PostgreSQL r2dbc:pool:postgresql://HOST:5432/halo postgresql
MySQL r2dbc:pool:mysql://HOST:3306/halo mysql
MariaDB r2dbc:pool:mariadb://HOST:3306/halo mariadb

Important

spring.sql.init.platform must match your engine. It selects both the schema initialization SQL and the migration script directory (classpath:/db/migration/<platform>/*.sql). A mismatch causes startup failures or wrong-dialect SQL.

5.2 Schema and migrations

  • Initial schemas ship as schema-h2.sql, schema-mysql.sql, schema-mariadb.sql, schema-postgresql.sql.
  • Ongoing migrations live in db/migration/{h2,mysql,mariadb,postgresql}/ and are applied by r2dbc-migrate at startup.
  • spring.sql.init.mode: always means initialization runs on every boot (idempotent).

Because all domain data is stored as JSON extensions (Section 3), the relational schema is intentionally generic (a small set of tables holding serialized extensions plus supporting structures). Do not hand-edit rows. Use the API or the console.

5.3 Connection pooling

Prefix the R2DBC URL with r2dbc:pool: to enable connection pooling for external databases (as in the Compose example). Tune pool size via standard Spring R2DBC pool properties if needed.


6. Deployment

6.1 Docker image internals

The image is built from eclipse-temurin:21-jre using Spring Boot's layered jar + tools extraction, and it pre-computes a CDS archive for faster startup:

  • java -Djarmode=tools -jar application.jar extract --layers splits the jar into dependencies/, spring-boot-loader/, snapshot-dependencies/, application/ layers (better Docker layer caching).
  • A build step runs -XX:ArchiveClassesAtExit=application.jsa -Dspring.context.exit=onRefresh to generate an AppCDS archive, then the entrypoint launches with -XX:SharedArchiveFile=application.jsa for reduced startup time and memory.
  • EXPOSE 8090; entrypoint execs java ${JVM_OPTS} ... -jar application.jar "$@", so extra --flag=value args you pass to docker run reach the app.

Official image coordinates:

Registry Image
Docker Hub halohub/halo
GHCR ghcr.io/halo-dev/halo

Tip

Set JVM_OPTS to bound heap in containers, e.g. -e JVM_OPTS="-Xmx512m -Xms512m". The Compose example uses 256m; size it to your traffic and plugin set.

6.2 Reverse proxy

Run Halo behind a proxy that terminates TLS and forwards all paths (/, /console, /uc, /apis/**, /ui-assets/**). Because server.forward-headers-strategy: native, Halo trusts X-Forwarded-Proto/X-Forwarded-Host/X-Forwarded-For to reconstruct correct external URLs.

Minimal Nginx location:

location / {
    proxy_pass http://127.0.0.1:8090;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_http_version 1.1;
}

Note

The project recommends 1Panel (a server management panel) for turnkey deployment, which wires up the reverse proxy, SSL certificates, and backup/upgrade tasks automatically.

6.3 Building from source

# Full build (backend + frontend) — produces application/build/libs/halo-*.jar
./gradlew clean build

# Skip checks for a faster local build
./gradlew clean build -x check

# Build the Docker image to local Docker
./gradlew :application:bootBuildImage

The build flow: :ui:doBuild (pnpm builds Vue → ui/build/dist) → copyUiDist copies it into the application's resources → bootJar packages the executable JAR → createChecksums writes SHA-256 sidecars. Several first-party plugins (comment-widget, search-widget, sitemap, feed, shiki, editor-hyperlink-card, app-store) are downloaded into resources/main/presets/plugins by the downloadPluginPresets task so a fresh install has working defaults.

Note

Requirements to build from source: JDK 21, Node ^24, and pnpm 10.x (the repo pins packageManager). The Gradle Node plugin provisions Node automatically for CI builds.


7. Security

7.1 Authentication mechanisms

Halo's reactive Spring Security chain (run.halo.app.security.authentication.*) supports multiple credential types:

Mechanism Package Notes
Username/password (form login) authentication/login The primary console/UC login. Encrypted password transport (jsencrypt webjar on the client).
OAuth2 / OIDC authentication/oauth2 Social/SSO login via spring-security-oauth2-client; resource-server + jose for JWT validation.
Two-factor (TOTP) authentication/twofactor/totp Time-based one-time passwords (two-factor-auth library), per-user opt-in.
Remember-me authentication/rememberme 14-day rotating token with theft detection and rotation cooldown.
Personal access tokens (PAT) authentication/token Bearer tokens for API/automation, scoped, revocable.
Pre-auth / device security/preauth, security/device Device tracking (ua-parser) and pre-authentication handling.
Switch-user (impersonation) security/switchuser Administrative user switching.
HTTP Basic (built-in) Disabled by default (halo.security.basic-auth.disabled=true).

JWTs are signed with RSA keys (RS256 by default) managed under the working directory; OAuth2 jose handles validation.

7.2 Authorization (RBAC)

Authorization (security/authorization) is role-based. Roles are extensions that bundle permission rules; users are granted roles; effective permissions are the union. UI routes also carry permissions metadata so the console hides actions a user cannot perform. The super-role has unrestricted access (including sensitive actuator data — see below).

7.3 Transport and header hardening

Defaults set sane security headers: X-Frame-Options: SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin, configurable CORS, and HTTPS-awareness via forwarded headers. Run behind TLS in production.

7.4 Abuse protection

Resilience4j rate limiters (Section 4.3) throttle login, signup, comment creation, and email/password-reset flows. These materially reduce credential-stuffing and spam without extra infrastructure.

7.5 Hardening checklist and honest risk notes

  • [ ] Change default database credentials. The bundled application.yaml ships admin/123456 for the file H2 database. For any external database, set strong spring.r2dbc.username/password. Never expose the H2 file DB beyond the host.
  • [ ] Restrict Actuator exposure. application.yaml sets management.endpoints.web.exposure.include: "*" and marks shutdown and heapdump access as unrestricted. These are powerful: shutdown can stop the app and heapdump can leak memory contents. They run on the same port (8090). Do not expose /actuator/** to the public — block it at the reverse proxy, or override the exposure to a minimal set (health,info) and lock down shutdown/heapdump in production. Health details are already gated (show-details: when-authorized, roles: super-role).
  • [ ] Set halo.external-url to your real HTTPS URL so links and OAuth redirects are correct.
  • [ ] Enable 2FA for administrator accounts (halo.security.two-factor-auth.disabled=false, then enroll per user in /uc).
  • [ ] Scope and rotate PATs; treat them as secrets.
  • [ ] Vet plugins/themes. Plugins run in-process with broad access; only install trusted packages.
  • [ ] Keep Halo updated — schema migrations run automatically; security fixes land in patch releases.
  • [ ] Run as non-root where possible and mount the working directory on durable, backed-up storage.

Caution

The combination of exposure.include: "*" + unrestricted shutdown/heapdump is convenient in development but is the single most important thing to lock down before exposing Halo to the internet.


8. The REST API

Halo is API-first: the entire console and User Center are built on the same public REST API, documented via OpenAPI (springdoc) and consumed through the generated @halo-dev/api-client.

8.1 API groups

The OpenAPI spec is generated into five grouped documents (from the build's openApi.groupedApiMappings):

Group File Audience / purpose
aggregated aggregated.json The full combined surface (used to generate the JS client).
public apis_public.api_v1alpha1.json Endpoints safe for anonymous/public consumption.
console apis_console.api_v1alpha1.json Admin console operations.
uc apis_uc.api_v1alpha1.json User Center (self-service) operations.
extension apis_extension.api_v1alpha1.json Generic CRUD over custom resources (extensions).

Endpoints live under /apis/... paths, versioned v1alpha1, grouped by API group (e.g. content.halo.run, api.console.halo.run, uc.api.halo.run). Example: GET /apis/api.console.halo.run/v1alpha1/posts.

8.2 Authentication

Authenticate API calls with either a session (cookie, as the SPA does) or a personal access token as a bearer token:

curl -H "Authorization: Bearer <your-PAT>" \
  https://blog.example.com/apis/api.console.halo.run/v1alpha1/posts

8.3 JavaScript client

The official client wraps every group:

import {
  coreApiClient,     // CRUD over custom models (extensions)
  consoleApiClient,  // console operations
  ucApiClient,       // user-center operations
  publicApiClient,   // public endpoints
  createCoreApiClient,
} from "@halo-dev/api-client";

coreApiClient.content.post.listPost().then((res) => {
  console.log(res.data.items);
});

// External app: bring your own axios instance
import axios from "axios";
const core = createCoreApiClient(axios.create({ baseURL: "http://localhost:8090" }));

Note

The client is OpenAPI-generated (openapi-generator typescript-axios) from api-docs/openapi/v3_0/aggregated.json. Regenerate it with the api-client:gen pnpm script whenever the backend API changes. The OpenAPI doc itself is produced by running the app under the doc profile (--spring.profiles.active=doc) and scraping /v3/api-docs/....


9. Operations

9.1 Working-directory layout

Everything mutable lives under halo.work-dir (~/.halo2):

Path Contents
db/ H2 database file (if using H2).
attachments/ Uploaded media (for local storage policies).
themes/ Installed themes.
plugins/ Installed plugin JARs and their data.
logs/halo.log Application log (rotated, 10MB/file, 1GB cap).
backups/ Full-site backup archives.
(security keys) RSA keys for JWT signing (managed by Halo).

Important

The working directory is the site. Mount it on durable storage, monitor its disk usage (attachments and logs grow), and include it in your backup strategy.

9.2 Start / stop / logs

# Docker
docker logs -f halo
docker restart halo
docker stop halo && docker start halo

# JAR (systemd recommended in production)
java -jar halo.jar --halo.work-dir=/opt/halo/data
tail -f /opt/halo/data/logs/halo.log

9.3 Health and monitoring

Spring Boot Actuator is enabled with Kubernetes-style probes:

Endpoint Use
/actuator/health Overall health (details gated to super-role).
/actuator/health/liveness Liveness probe (probes.enabled: true).
/actuator/health/readiness Readiness probe.
/actuator/info Build/Java/OS info.

Example Kubernetes probes:

livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: 8090 }
readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: 8090 }

Warning

Restrict /actuator/** at the proxy (see Section 7.5). Probes can be allowed internally while the rest of the actuator surface is blocked from the public internet.

9.4 Backup and restore

The full-site backup (migration.halo.run/v1alpha1, Kind=Backup) bundles database extensions + working-directory files into a single ZIP under ${work-dir}/backups/. Operate it from System → Backup in the console:

  • Backups run asynchronously; the Backup resource's status.phase moves Pending → Running → Succeeded/Failed, with status.filename on success.
  • spec.autoDeleteWhen controls retention; users can grant backup permission to specific accounts.
  • Backup is the supported cross-database migration path (H2 → PostgreSQL, etc.).

Important

Take a fresh backup before restoring or upgrading. Restored data may have creationTimestamp overwritten by the current time. Supported for Halo 2.8.x and newer.

9.5 Upgrades

  1. Back up (console backup + DB dump if external).
  2. Pull the new image tag / replace the JAR.
  3. Recreate the container with the same working-directory volume and same database.
  4. On startup, r2dbc-migrate applies any new schema migrations automatically.
  5. Verify the console loads and reconcilers have converged (search index, counters).

10. Performance Tuning

Area Lever Guidance
JVM heap JVM_OPTS=-Xmx… -Xms… Start at 512MB for small sites; raise with traffic/plugins. CDS (application.jsa) already reduces startup cost.
Concurrency spring.threads.virtual.enabled Already true; virtual threads handle high request fan-out cheaply.
Cache spring.cache.caffeine.spec Default expireAfterAccess=1h, maximumSize=10000. Increase maximumSize for large catalogs.
DB pool r2dbc:pool: + pool props Use pooling for external DBs; size to DB capacity.
Static assets spring.web.resources.cache.cachecontrol.max-age Default 365d; assets are fingerprinted, so long caching is safe.
Thumbnails halo.attachment.thumbnail.concurrent-threads / quality Tune for image-heavy sites; disable if you offload to a CDN/storage plugin.
Compression server.compression.enabled On by default; keep on (or move to the proxy).
Body buffers spring.http.codecs.max-in-memory-size Default 10MB; raise for large API payloads, watch memory.
Search Lucene index Rebuilt by reconcilers; large sites benefit from adequate heap and fast disk.

Tip

Halo scales vertically very well on a single node thanks to the reactive stack + virtual threads. The usual bottlenecks are the database and image processing, not the web tier.


11. Extending Halo

11.1 Plugins (PF4J)

Plugins are JARs loaded by PF4J with isolated classloaders. A plugin can:

  • Register new extension schemes and reconcilers (new custom resources + controllers).
  • Add REST endpoints (@ApiVersion-style routes under /apis/...).
  • Contribute extension points and implementations (e.g. storage policies, auth providers, attachment handlers, notifier types).
  • Ship a front-end (console/UC assets) built with @halo-dev/ui-plugin-bundler-kit, registered via definePlugin (routes, menu entries, components, extensionPoints).
  • Declare settings (a setting.yaml form) and persist values as a ConfigMap extension.

Plugin lifecycle: install → enable → (configure) → disable → uninstall, all from System → Plugins or the API. Plugins depend on the :platform:plugin BOM and the :api module so they compile against the host's exact library versions.

Note

Build plugin front-ends with Rsbuild for large/code-split UIs and Vite for simpler ones (@halo-dev/ui-plugin-bundler-kit supports both). Exclude @halo-dev/api-client and axios from the bundle — the host provides them.

11.2 Themes (Thymeleaf)

Themes render the public site with Thymeleaf reactive templates. The theme system provides:

  • Router factories (theme/router/factories) that build dynamic routes for posts, pages, categories, tags, archives, and custom patterns based on permalink settings.
  • Finders (theme/finders) — a safe, read-only data API (posts, categories, comments, menus, site stats) that templates call to fetch content.
  • Template resolution that supports per-post/page/category template overrides.
  • A theme settings form (rendered from the theme's definition) and theme-scoped configuration stored as extensions.

A theme package is a .zip containing templates, static assets, and a manifest. The default bundled theme is "earth" (classpath:themes/theme-earth.zip).

Tip

The front-end @halo-dev/components (UI), @halo-dev/ui-shared (plugin SDK), and @halo-dev/richtext-editor packages are published to npm so plugin authors reuse the exact components and editor the core uses. Custom FormKit inputs (code, attachment, repeater, list, select, secret, postSelect, categorySelect, tagSelect, menu selectors, verificationForm, …) are available to plugin/theme setting forms.

11.3 Front-end module/route generation

Console routes and sidebar menus are generated dynamically from three sources: base routes (src/router/routes.config.ts), core module routes (src/modules/**/module.ts), and plugin routes — all declared via definePlugin. A route's meta.menu object (name, group, icon, priority, mobile) places it in the sidebar; meta.permissions gates visibility by RBAC permission. Core menu groups: dashboard, content, interface, system, tool.


12. Repository Structure (for contributors)

halo/
├── api/                      # Public plugin API (run.halo.app.*), published library
│   └── src/main/java/run/halo/app/{extension,security,core,plugin,theme,...}
├── application/              # Runnable Spring Boot app
│   └── src/main/
│       ├── java/run/halo/app/
│       │   ├── Application.java            # main()
│       │   ├── infra/properties/           # @ConfigurationProperties (halo.*)
│       │   ├── extension/                   # extension store, controllers, indexers
│       │   ├── security/                    # auth (oauth2, twofactor, rememberme, token, pat)
│       │   ├── core/                        # users, attachments, counters, endpoints
│       │   ├── content/                     # posts, pages, comments, permalinks
│       │   ├── theme/                       # router factories, finders, dialect, engine
│       │   ├── plugin/                      # PF4J integration, extension points
│       │   ├── notification/                # notifications + cleanup
│       │   ├── search/                      # Lucene full-text search
│       │   └── migration/                   # backup/restore
│       └── resources/
│           ├── application*.yaml            # config + per-DB profiles
│           ├── db/migration/{h2,mysql,mariadb,postgresql}/
│           ├── schema-*.sql
│           ├── themes/                       # default theme
│           └── config/                       # i18n, uap (ua-parser regexes)
├── platform/{application,plugin}/  # Gradle java-platform BOMs
├── buildSrc/                  # Groovy convention plugins (halo.publish)
├── ui/                        # Vue 3 + TS front-end (pnpm workspace)
│   ├── console-src/           # Console SPA
│   ├── uc-src/                # User Center SPA
│   ├── src/                   # shared (components, formkit, locales, router)
│   └── packages/              # api-client, components, ui-shared, editor, bundler-kit
├── api-docs/openapi/v3_0/     # generated OpenAPI specs
├── docs/                      # developer/extension-point design proposals
├── e2e/                       # api-testing based end-to-end suite
├── Dockerfile                 # layered + CDS image
└── settings.gradle / build.gradle / gradle.properties

Module reference (selected core packages)

Package Responsibility
extension The custom-resource engine: Scheme, ReactiveExtensionClient, Metadata, controller (reconcilers), index (query indexers), router.
security.authentication All auth flows: login, oauth2, twofactor/totp, rememberme, token (PAT), exceptions.
security.authorization RBAC evaluation.
content Posts, pages, comments, categories, tags, permalinks, snapshots.
theme router/factories (dynamic routing), finders (template data API), dialect (Thymeleaf extensions), engine.
plugin PF4J plugin manager, lifecycle, extension-point wiring, plugin resources.
infra Properties, config, exception handling, web filters, actuator, utilities.
search Lucene-backed post search (search/lucene, search/post).
migration Full-site backup and restore.
notification In-app/email notifications + scheduled cleanup.

13. Testing Strategy

Layer Tooling
Backend unit/slice JUnit 5 (spring-boot-starter-test), Reactor Test (StepVerifier), Spring Security Test, WebTestClient.
Backend coverage JaCoCo (XML reports for CI).
Front-end unit Vitest + Vue Test Utils (pnpm test:unit).
Front-end quality ESLint (zero warnings), vue-tsc type-check, Prettier/spotless formatting.
End-to-end e2e/ suite driven by LinuxSuRen api-testing (atest) against a Docker-Compose stack (Halo + DB), runnable for H2/MySQL/PostgreSQL.
Java formatting Spotless with Palantir Java Format; SQL via dbeaver; web resources via Prettier.

Run the backend tests: ./gradlew test. Run the full check (lint + typecheck + unit) for the UI: pnpm --filter ... check (wired through Gradle :ui:check). E2E: cd e2e && ./start.sh.

Note

Add or update e2e cases when you add or change APIs — the e2e/README makes this a contribution expectation.


14. Troubleshooting (Operations)

halo.use-absolute-permalink=true without halo.external-url. Set the external URL or disable absolute permalinks. (Validated by HaloProperties.validate.)

Startup fails: External URL must be a http or https URL.

halo.external-url is not a valid HTTP/HTTPS URL (opaque or missing authority/scheme). Provide a full URL like https://blog.example.com/.

Startup fails: Public/Private key location does not exist

JWT signing keys missing at the configured halo.security.oauth2.jwt.*-key-location. Use the defaults (Halo manages keys under the work dir) or point to existing RSA key files.

App starts but database errors / wrong SQL dialect

spring.sql.init.platform does not match the engine in spring.r2dbc.url. Align them (postgresql/mysql/mariadb/h2).

/console 404 or assets missing behind a proxy

The proxy isn't forwarding all paths. Forward /, /console, /uc, /apis/**, /ui-assets/**. Confirm X-Forwarded-* headers are passed (forward-headers-strategy: native).

Data lost on container recreate

No persistent volume. Always mount -v <host>:/root/.halo2. The working directory holds the entire site.

Actuator endpoint reachable from the internet

exposure.include: "*". Block /actuator/** at the proxy and/or override exposure to health,info; lock down shutdown/heapdump.

High memory / OOM

Bound heap via JVM_OPTS; check Caffeine maximumSize, max-in-memory-size, thumbnail concurrency, and large attachment processing. Inspect logs/halo.log.

Slow search or stale lists after import/restore

Reconcilers/indexers are still converging. Allow time; check logs for indexing activity. A restart forces index rebuild.


15. Quick Reference

Key files

File Purpose
application/src/main/resources/application.yaml Base configuration + defaults.
application/src/main/resources/application-<db>.yaml Per-database profiles.
.../infra/properties/HaloProperties.java halo.* configuration binding (source of truth).
.../Application.java Spring Boot entry point.
Dockerfile Layered + CDS image build.
gradle.properties Project version (2.24.0-SNAPSHOT), pinned lib versions.
api-docs/openapi/v3_0/*.json Generated OpenAPI specs.

Default endpoints

Path Purpose
/ Public site (theme-rendered).
/console Admin SPA.
/uc User Center SPA.
/apis/** REST API (versioned, grouped).
/actuator/health/{liveness,readiness} Probes.
/upload/** Legacy (1.x) attachment compatibility mapping.

Minimal production startup

docker run -d --name halo \
  -p 8090:8090 \
  -v /srv/halo:/root/.halo2 \
  -e TZ=UTC \
  -e JVM_OPTS="-Xmx512m -Xms512m" \
  halohub/halo:2.24 \
  --halo.external-url=https://blog.example.com/ \
  --spring.r2dbc.url=r2dbc:pool:postgresql://db:5432/halo \
  --spring.r2dbc.username=halo \
  --spring.r2dbc.password=CHANGE_ME \
  --spring.sql.init.platform=postgresql \
  --halo.security.two-factor-auth.disabled=false

Source / reference

Resource Location
Source code github.com/halo-dev/halo
Official docs docs.halo.run
Application market halo.run/store/apps
Community bbs.halo.run
License GPL-3.0