OptiPowerTools.Hangfire 2.0.1

dotnet add package OptiPowerTools.Hangfire --version 2.0.1
                    
NuGet\Install-Package OptiPowerTools.Hangfire -Version 2.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="OptiPowerTools.Hangfire" Version="2.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="OptiPowerTools.Hangfire" Version="2.0.1" />
                    
Directory.Packages.props
<PackageReference Include="OptiPowerTools.Hangfire" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add OptiPowerTools.Hangfire --version 2.0.1
                    
#r "nuget: OptiPowerTools.Hangfire, 2.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package OptiPowerTools.Hangfire@2.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=OptiPowerTools.Hangfire&version=2.0.1
                    
Install as a Cake Addin
#tool nuget:?package=OptiPowerTools.Hangfire&version=2.0.1
                    
Install as a Cake Tool

OptiPowerTools.Hangfire

A one-liner bootstrap for adding Hangfire background job processing to Optimizely CMS 13. For Optimizely CMS 12 support, use the 1.x release.

This package was inspired by community feedback on the blog post Adding Hangfire to Optimizely CMS 12, which walked through the manual steps of integrating Hangfire with Optimizely. The recurring request for a ready-made, drop-in solution led to this library — turning what was a multi-step manual setup into a simple, drop-in integration.

Features

  • Single extension method to register Hangfire with SQL Server storage and background server
  • Hangfire Dashboard with Optimizely role-based authorization out of the box
  • CMS menu integration — dashboard appears in the Optimizely navigation bar with configurable placement
  • Hangfire.Console support for rich job output
  • Configurable via options pattern or appsettings.json
  • Custom dashboard authorization — bring your own IDashboardAuthorizationFilter or disable auth entirely for development
  • Toggle individual features on/off (EnableDashboard, EnableConsole, EnableCmsMenu)
  • Built-in job filters for concurrency control (MutualExclusion, WaitForOtherJobs) and lifecycle management (ExpireOnSuccess, RetainOnSuccess)
  • Targets net10.0 (Optimizely CMS 13 requires .NET 10)

Hangfire Dashboard in Optimizely CMS

Quick Start

// In Program.cs or Startup.cs
services.AddOptiPowerToolHangfire(options =>
{
    options.ConnectionString = Configuration.GetConnectionString("HangfireConnection");
});

// In the middleware pipeline (after UseAuthentication/UseAuthorization)
app.UseOptiPowerToolHangfire();

Connection string can point to the same database as Optimizely or to a separate one.

That's it. This registers Hangfire with SQL Server storage, starts the background server, enables the dashboard with role-based auth, and adds a menu item to the CMS navigation.

Configuration

All options except ConnectionString have sensible defaults. Configure via code, appsettings.json, or both (code overrides config).

Code configuration

Minimal configuration
// Connection string is read from appsettings.json ("OptiPowerTools:Hangfire:ConnectionString")
services.AddOptiPowerToolHangfire();

app.UseOptiPowerToolHangfire();
Full configuration
services.AddOptiPowerToolHangfire(options =>
{
    // Required
    options.ConnectionString = "Server=.;Database=MyDb;Trusted_Connection=True;";

    // Optional — all values below are the defaults
    options.DashboardPath = "/optimizely/backoffice/Plugins/hangfire";
    options.DashboardTitle = "OptiPowerTools Hangfire Dashboard";
    options.AuthorizedRoles = ["Administrators", "CmsAdmins", "WebAdmins"];
    options.SchemaName = "hangfire";
    options.EnableDashboard = true;
    options.EnableConsole = true;
    options.EnableCmsMenu = true;
    options.EnableStandardAuthorization = true;

    // Menu placement — default is CmsSection (under the CMS nav section)
    options.MenuPlacement = CmsMenuPlacement.CmsSection;
    options.MenuPath = null;        // Override the auto-derived menu path
    options.MenuSortIndex = null;   // Override the auto-derived sort index
    options.CustomSectionName = "OptiPowerTools"; // Section name for TopLevel/CustomSection placement
    options.CustomMenuItemName = "OptiPowerTools"; // Display name for the menu item
    options.CmsShellPath = "/HangfireCms/Index";   // URL path for the CMS shell iframe page

    // Storage maintenance
    options.JobExpirationCheckInterval = TimeSpan.FromMinutes(15);
});

appsettings.json

{
  "OptiPowerTools": {
    "Hangfire": {
      "ConnectionString": "Server=.;Database=MyDb;Trusted_Connection=True;",
      "DashboardPath": "/optimizely/backoffice/Plugins/hangfire",
      "DashboardTitle": "OptiPowerTools Hangfire Dashboard",
      "AuthorizedRoles": ["Administrators", "CmsAdmins", "WebAdmins"],
      "SchemaName": "hangfire",
      "EnableDashboard": true,
      "EnableConsole": true,
      "EnableCmsMenu": true,
      "EnableStandardAuthorization": true,
      "MenuPlacement": "CmsSection",
      "MenuPath": null,
      "MenuSortIndex": null,
      "CustomSectionName": "OptiPowerTools",
      "CustomMenuItemName": "OptiPowerTools",
      "CmsShellPath": "/HangfireCms/Index",
      "JobExpirationCheckInterval": "00:15:00"
    }
  }
}

Options reference

Option Type Default Description
ConnectionString string "" Required. SQL Server connection string for Hangfire storage.
DashboardPath string "/optimizely/backoffice/Plugins/hangfire" URL path where the Hangfire dashboard is served.
DashboardTitle string "OptiPowerTools Hangfire Dashboard" Title shown in the dashboard header.
AuthorizedRoles string[] ["Administrators", "CmsAdmins", "WebAdmins"] Optimizely roles allowed to access the dashboard.
SchemaName string "hangfire" SQL Server schema for Hangfire tables.
EnableDashboard bool true Serve the Hangfire dashboard UI. Set to false for worker-only nodes.
EnableConsole bool true Enable Hangfire.Console for rich console output in jobs.
EnableCmsMenu bool true Add a Hangfire menu item to the Optimizely CMS navigation.
EnableStandardAuthorization bool true Use the built-in Optimizely role-based authorization filter for the dashboard. When false and no custom filter is provided, the dashboard allows unrestricted access.
MenuPlacement CmsMenuPlacement CmsSection Where the menu item appears: CmsSection, TopLevel, or CustomSection. See Menu Placement.
MenuPath string? null Overrides the auto-derived menu path. Takes precedence over MenuPlacement path logic.
MenuSortIndex int? null Overrides the auto-derived sort index for the menu item (or section in CustomSection mode).
CustomSectionName string "OptiPowerTools" Display name for the section group when MenuPlacement is TopLevel or CustomSection.
CustomMenuItemName string "OptiPowerTools" Display name for the Hangfire menu item in the CMS navigation. Falls back to DashboardTitle when empty.
CmsShellPath string "/HangfireCms/Index" URL path where the CMS shell page (iframe wrapper) is served. The CMS menu item links to this path.
JobExpirationCheckInterval TimeSpan 00:15:00 How often the expiration manager checks for and removes expired jobs.

Dashboard Authorization

By default, the Hangfire dashboard is protected by the built-in Optimizely role-based authorization filter, which restricts access to users in the AuthorizedRoles (Administrators, CmsAdmins, WebAdmins). You can customize this behavior in three ways:

Standard authorization (default)

No changes needed. The dashboard uses OptimizelyDashboardAuthorizationFilter which checks the user's CMS roles.

Custom authorization filter

Provide your own IDashboardAuthorizationFilter implementation using the generic overload. The custom filter takes full precedence over the standard filter, regardless of the EnableStandardAuthorization setting.

services.AddOptiPowerToolHangfire<MyCustomAuthFilter>(options =>
{
    options.ConnectionString = Configuration.GetConnectionString("HangfireConnection");
});
public class MyCustomAuthFilter : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        var httpContext = context.GetHttpContext();
        return httpContext.User.Identity?.IsAuthenticated == true;
    }
}
Free access (no authorization)

Disable the standard authorization filter without providing a custom one. This allows unrestricted access to the dashboard — useful for development environments.

services.AddOptiPowerToolHangfire(options =>
{
    options.ConnectionString = Configuration.GetConnectionString("HangfireConnection");
    options.EnableStandardAuthorization = false;
});

Warning: Do not disable authorization in production. The Hangfire dashboard exposes job data, retry controls, and server information.

By default, the Hangfire menu item appears under the CMS section in the Optimizely navigation bar. You can change this with MenuPlacement:

CmsSection (default)

Nests the menu item under the existing CMS section. This is the current behavior and requires no configuration changes.

TopLevel

Places the menu item directly in the global navigation bar as a top-level entry, alongside CMS, Commerce, etc.

{
  "OptiPowerTools": {
    "Hangfire": {
      "MenuPlacement": "TopLevel"
    }
  }
}
CustomSection

Creates a new collapsible section group and nests the Hangfire item underneath it. The section name is controlled by CustomSectionName.

{
  "OptiPowerTools": {
    "Hangfire": {
      "MenuPlacement": "CustomSection",
      "CustomSectionName": "Background Jobs"
    }
  }
}
Overriding path and sort index

For any placement mode, you can override the menu path and sort index:

{
  "OptiPowerTools": {
    "Hangfire": {
      "MenuPlacement": "CmsSection",
      "MenuPath": "/global/admin/hangfire",
      "MenuSortIndex": 500
    }
  }
}

Job Filters

The package includes CMS-agnostic Hangfire job filters for concurrency control, available via the OptiPowerTools.Hangfire.Tools.Filters namespace.

MutualExclusion

Prevents concurrent execution of jobs sharing the same resource group. Uses Hangfire distributed locks for reliable, race-condition-free mutual exclusion.

using OptiPowerTools.Hangfire.Tools.Filters;

[MutualExclusion("data-pipeline")]
public class DataImportJob
{
    public void Execute() { /* ... */ }
}

[MutualExclusion("data-pipeline")]
public class DataExportJob
{
    public void Execute() { /* ... */ }
}

When DataImportJob is running, DataExportJob is automatically rescheduled (and vice versa). The worker thread is freed immediately — no blocking. The delay before retry is configurable:

[MutualExclusion("data-pipeline", RetryDelaySeconds = 30)]

WaitForOtherJobs

Prevents a job from executing while specific other job types are processing. This is one-directional — only the decorated job needs the attribute.

using OptiPowerTools.Hangfire.Tools.Filters;

[WaitForOtherJobs(typeof(DataImportJob))]
public class ReportGeneratorJob
{
    public void Execute() { /* ... */ }
}

ReportGeneratorJob will be rescheduled if DataImportJob is currently processing. DataImportJob does not need any attribute and runs normally.

// Wait for multiple job types, with custom retry delay
[WaitForOtherJobs(typeof(DataImportJob), typeof(DataExportJob), RetryDelaySeconds = 30)]

Note: WaitForOtherJobs uses the Hangfire monitoring API and has a small race-condition window. For guaranteed mutual exclusion, use MutualExclusion instead.

ExpireOnSuccess

Reduces the retention period for succeeded jobs. By default, Hangfire keeps succeeded jobs for 24 hours. This filter overrides the expiration timeout so short-lived fire-and-forget jobs are cleaned up faster.

using OptiPowerTools.Hangfire.Tools.Filters;

// Job data expires 60 seconds after success (instead of 24 hours)
[ExpireOnSuccess(60)]
public class NotificationJob
{
    public void Execute() { /* ... */ }
}

// Job data expires immediately after success
[ExpireOnSuccess]
public class HealthCheckJob
{
    public void Execute() { /* ... */ }
}

The expirationSeconds parameter defaults to 0 (minimal retention). Only succeeded jobs are affected — failed or deleted jobs keep their default Hangfire expiration.

RetainOnSuccess

Extends the retention period for succeeded jobs beyond Hangfire's default of 24 hours. Use this for infrequent jobs (weekly reports, monthly audits) where you want execution details to remain visible in the dashboard until the next run.

using OptiPowerTools.Hangfire.Tools.Filters;

// Keep succeeded job data for 180 days
[RetainOnSuccess(180)]
public class MonthlyAuditJob
{
    public void Execute() { /* ... */ }
}

// Keep succeeded job data for 90 days (default)
[RetainOnSuccess]
public class WeeklyReportJob
{
    public void Execute() { /* ... */ }
}

The retentionDays parameter defaults to 90. Only succeeded jobs are affected — failed or deleted jobs keep their default Hangfire expiration. For the inverse (reducing retention), see ExpireOnSuccess.

Combining with DisableConcurrentExecution

Neither filter prevents the same job type from running concurrently with itself. Combine with Hangfire's built-in attribute if needed:

[DisableConcurrentExecution(timeoutInSeconds: 60)]
[MutualExclusion("data-pipeline")]
public class DataImportJob { /* ... */ }

Important: MapControllers requirement

The Hangfire CMS shell page (/HangfireCms/Index) is served by an MVC controller. If your application only calls MapContent() without MapControllers(), you must add it to your endpoint configuration:

app.UseEndpoints(endpoints =>
{
    endpoints.MapContent();
    endpoints.MapControllers();
});

app.UseOptiPowerToolHangfire();

Most Optimizely CMS setups already include MapControllers(). If you see a 404 on /HangfireCms/Index, this is the likely cause.

Removing this package

This package is a thin configuration wrapper — it does not modify Hangfire internals or change the way Hangfire stores data. If your project outgrows it and you need full control, simply remove the package and configure Hangfire manually. Your existing database, jobs, and history will continue to work without any migration or data changes.

Development

The solution includes a .Web project that references the MyOptiAlloySite Optimizely CMS 13 site via a git submodule for manual testing. The site runs against SQL Server in Docker.

Prerequisites

  • .NET 10.0 SDK
  • Docker (for SQL Server)
  • Git with submodule support

Getting started

  1. Clone the repository with submodules:

    git clone --recursive https://github.com/szolkowski/OptiPowerTools.Hangfire.git
    

    If you already cloned without --recursive, initialize the submodule:

    git submodule update --init --recursive
    
  2. Create the .env file (copy the example):

    cp .env.example .env
    
  3. Option A — Docker (recommended): Start everything with docker compose from the repo root:

    docker compose up -d
    

    The site starts at http://localhost:5100.

    Option B — Local: Start only SQL Server in Docker, then run the site locally:

    docker compose up db -d
    dotnet run --project src/OptiPowerTools.Hangfire.Web
    

    The site starts at https://localhost:5000.

Once running:

URL Description
/optimizely/cms CMS editorial UI
/HangfireCms/Index Hangfire dashboard (embedded in CMS shell)
/optimizely/backoffice/Plugins/hangfire Hangfire dashboard (standalone)

Docker compose reference

The docker-compose.yml at the repo root defines two services:

Service Description Port
db SQL Server 2025 (reuses MyOptiAlloySite's Dockerfile) localhost:6000
web Hangfire Web project (.NET 10 SDK, live reload via volume mount) localhost:5100

Common commands (run from the repo root):

docker compose up -d              # Start both db and web
docker compose up -d --build      # Rebuild and start (after code changes)
docker compose up db -d           # Start only SQL Server (for local development)
docker compose restart web        # Restart web container
docker compose logs web --tail 50 # View recent web container logs
docker compose logs web -f        # Follow web container logs live
docker compose down               # Stop and remove containers
docker compose down -v            # Stop, remove containers, and delete volumes

Environment configuration: The ASPNETCORE_ENVIRONMENT variable in docker-compose.yml controls which appsettings.{Environment}.json is loaded. Change it to switch menu placement configurations:

Value Config file loaded Menu placement
CmsSection appsettings.CmsSection.json Under CMS section (default)
TopLevel appsettings.TopLevel.json Top-level nav entry
CustomSection appsettings.CustomSection.json Custom collapsible section
Development appsettings.Development.json Default ASP.NET Core dev environment
NoSettingsConfig appsettings.NoSettingsConfig.json No Hangfire options (defaults only)

Connection strings are passed via Docker environment variables (CONNECTIONSTRINGS__EPISERVERDB and OptiPowerTools__Hangfire__ConnectionString), which override values in appsettings files.

Rebuilding after code changes: The web container mounts the repo as a volume and uses dotnet run, so it compiles on startup. After changing source files, rebuild the container:

docker compose up web -d --build

After changing docker-compose.yml (e.g. ASPNETCORE_ENVIRONMENT): A simple restart won't pick up compose file changes — you need to recreate the container:

docker compose up web -d --force-recreate

Sample jobs

The .Web project includes sample jobs in the Samples/ directory. These are not part of the NuGet package — they exist purely to showcase Hangfire and Hangfire.Console capabilities within Optimizely.

Job What it demonstrates
ConsoleShowcaseJob Hangfire.Console features: colored text, progress bars, and structured multi-phase output while processing fake product data
OrderPipelineJob Job continuations via IBackgroundJobClient.ContinueJobWith — chains four steps (Validate → Payment → Ship → Notify) where each step is a separate job
ScheduledCleanupJob Delayed execution via IBackgroundJobClient.Schedule — plans cleanup tasks with varying delays (1m, 5m, 15m) visible in the dashboard's Scheduled tab
CancellableExportJob Cancellation token support via IJobCancellationToken — processes 500 records slowly enough to cancel mid-run from the dashboard and see graceful shutdown

Trigger any sample job manually from the Hangfire dashboard's Recurring Jobs page, or wait for its schedule.

Running tests

dotnet test

Tests run against net10.0.

Project structure

Project Purpose
src/OptiPowerTools.Hangfire The NuGet library package (net10.0)
src/OptiPowerTools.Hangfire.Tools CMS-agnostic job filters and utilities (bundled into the main NuGet package)
src/OptiPowerTools.Hangfire.Web Dev site for manual testing (net10.0, references MyOptiAlloySite submodule)
tests/OptiPowerTools.Hangfire.Tests Unit tests for main library — xUnit + NSubstitute
tests/OptiPowerTools.Hangfire.Tools.Tests Unit tests for Tools library — xUnit + NSubstitute
sub/MyOptiAlloySite Git submodule — szolkowski/MyOptiAlloySite (Optimizely CMS 13 Alloy site)

Version compatibility

Package version Optimizely CMS .NET
2.x (current) CMS 13 .NET 10
1.x CMS 12 .NET 6, 8, 9, 10

Optimizely CMS 12 users: The 1.x line will continue to receive bug fixes and maintenance updates. You do not need to upgrade to 2.x unless you are migrating to CMS 13.

Migrating from v1.x to v2.x

If you are upgrading from CMS 12 to CMS 13:

  1. Update your project to target net10.0
  2. Update Optimizely CMS packages to 13.x
  3. Update the OptiPowerTools.Hangfire package to 2.x
  4. If you customized DashboardPath, note the default changed from /episerver/backoffice/Plugins/hangfire to /optimizely/backoffice/Plugins/hangfire

License

MIT. See LICENSE for details.

Contributing

Contributions are welcome! See CONTRIBUTING.md for guidelines.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.1 116 4/12/2026
2.0.0 108 4/11/2026
1.0.1 106 4/10/2026
1.0.0 119 3/27/2026