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. |
| 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 sharingui/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
- A console/API call hits a reactive endpoint (for example "create post").
- The handler builds a
Postextension and persists it viaReactiveExtensionClient.create(...). - The extension is serialized to JSON and stored as a row in the database (R2DBC).
- A watcher notices the new/changed extension and notifies the registered reconciler (controller).
- The reconciler performs side effects: generating permalinks, updating counters, reindexing for search, rendering, cleanup, etc. — and may update the extension's
status. - 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:
- A command-line argument:
--halo.external-url=https://example.com/ - An environment variable:
HALO_EXTERNAL_URL=https://example.com/(relaxed binding: dots/dashes → underscores, uppercased) - A key in
application.yamlplaced 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) underhalo.security.oauth2.jwt.*:public-key-location,private-key-location,issuer-uri, andjws-algorithm(defaultRS256). 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 threespring.r2dbc.*/spring.sql.init.platformvalues 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.platformmust 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: alwaysmeans 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 --layerssplits the jar intodependencies/,spring-boot-loader/,snapshot-dependencies/,application/layers (better Docker layer caching).- A build step runs
-XX:ArchiveClassesAtExit=application.jsa -Dspring.context.exit=onRefreshto generate an AppCDS archive, then the entrypoint launches with-XX:SharedArchiveFile=application.jsafor reduced startup time and memory. EXPOSE 8090; entrypoint execsjava ${JVM_OPTS} ... -jar application.jar "$@", so extra--flag=valueargs you pass todocker runreach the app.
Official image coordinates:
| Registry | Image |
|---|---|
| Docker Hub | halohub/halo |
| GHCR | ghcr.io/halo-dev/halo |
Tip
Set
JVM_OPTSto bound heap in containers, e.g.-e JVM_OPTS="-Xmx512m -Xms512m". The Compose example uses256m; 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 pinspackageManager). 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.yamlshipsadmin/123456for the file H2 database. For any external database, set strongspring.r2dbc.username/password. Never expose the H2 file DB beyond the host. - [ ] Restrict Actuator exposure.
application.yamlsetsmanagement.endpoints.web.exposure.include: "*"and marksshutdownandheapdumpaccess asunrestricted. These are powerful:shutdowncan stop the app andheapdumpcan 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 downshutdown/heapdumpin production. Health details are already gated (show-details: when-authorized,roles: super-role). - [ ] Set
halo.external-urlto 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: "*"+ unrestrictedshutdown/heapdumpis 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-generatortypescript-axios) fromapi-docs/openapi/v3_0/aggregated.json. Regenerate it with theapi-client:genpnpm script whenever the backend API changes. The OpenAPI doc itself is produced by running the app under thedocprofile (--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
Backupresource'sstatus.phasemovesPending → Running → Succeeded/Failed, withstatus.filenameon success. spec.autoDeleteWhencontrols 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
creationTimestampoverwritten by the current time. Supported for Halo 2.8.x and newer.
9.5 Upgrades
- Back up (console backup + DB dump if external).
- Pull the new image tag / replace the JAR.
- Recreate the container with the same working-directory volume and same database.
- On startup, r2dbc-migrate applies any new schema migrations automatically.
- 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 viadefinePlugin(routes, menu entries, components,extensionPoints). - Declare settings (a
setting.yamlform) and persist values as aConfigMapextension.
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-kitsupports both). Exclude@halo-dev/api-clientandaxiosfrom 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-editorpackages 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/READMEmakes this a contribution expectation.
14. Troubleshooting (Operations)
Startup fails: External URL is required when property use-absolute-permalink is set to true.
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 |