Portal Community

Configuration Entry Point

The OctopusConfig object wraps the application's IConfiguration and provides a helper method for binding plugin-specific sections:

public void OnRegister(IServiceCollection services, OctopusConfig config)
{
    // Method 1: Use the OctopusConfig helper (binds and returns a new instance)
    var pluginConfig = config.GetSection<HRPluginConfig>("HRPlugin");

    // Method 2: Bind directly to IConfiguration (same result)
    var pluginConfig2 = config.Configuration
        .GetSection("HRPlugin")
        .Get<HRPluginConfig>()!;

    // Method 3: Register for IOptions<T> injection (recommended for services)
    services.Configure<HRPluginConfig>(
        config.Configuration.GetSection("HRPlugin"));
}

Defining a Plugin Options Class

namespace BizFirst.Octopus.Plugins.HR;

public class HRPluginConfig
{
    // Non-nullable strings should have sensible defaults or be validated
    public string  BaseUrl          { get; set; } = string.Empty;
    public string  ApiVersion       { get; set; } = "v2";
    public int     TimeoutSeconds   { get; set; } = 30;
    public int     MaxRetries       { get; set; } = 3;
    public bool    EnableCaching    { get; set; } = true;
    public int     CacheTtlMinutes  { get; set; } = 15;

    // Credential IDs — never store raw secrets in config
    public int     ApiKeyCredentialId { get; set; }  // resolved via ICredentialResolver

    // Nested objects for logical grouping
    public HRCachingConfig    Caching    { get; set; } = new();
    public HRFeaturesConfig   Features   { get; set; } = new();
}

public class HRCachingConfig
{
    public bool   Enabled        { get; set; } = true;
    public int    TtlMinutes     { get; set; } = 15;
    public int    MaxEntries     { get; set; } = 1000;
    public string BackingStore   { get; set; } = "Memory";  // Memory | Redis
}

public class HRFeaturesConfig
{
    public bool LeaveManagement  { get; set; } = true;
    public bool Payroll          { get; set; } = false;
    public bool PerformanceReview { get; set; } = false;
}

appsettings.json Structure

{
  "HRPlugin": {
    "BaseUrl": "https://hr-api.internal.company.com",
    "ApiVersion": "v2",
    "TimeoutSeconds": 30,
    "MaxRetries": 3,
    "ApiKeyCredentialId": 42,
    "Caching": {
      "Enabled": true,
      "TtlMinutes": 15,
      "MaxEntries": 1000,
      "BackingStore": "Memory"
    },
    "Features": {
      "LeaveManagement": true,
      "Payroll": false,
      "PerformanceReview": false
    }
  }
}

Consuming Config in Services via IOptions

// Service that receives config via constructor injection
public class HRSystemClient : IHRSystemClient
{
    private readonly HRPluginConfig   _config;
    private readonly ICredentialResolver _creds;
    private readonly HttpClient       _http;

    public HRSystemClient(
        IOptions<HRPluginConfig> options,
        ICredentialResolver credentialResolver,
        HttpClient http)
    {
        _config = options.Value;
        _creds  = credentialResolver;
        _http   = http;
        _http.BaseAddress = new Uri(_config.BaseUrl);
        _http.Timeout     = TimeSpan.FromSeconds(_config.TimeoutSeconds);
    }

    public async Task<LeaveBalance> GetLeaveBalanceAsync(string employeeId, CancellationToken ct)
    {
        // Resolve the API key from the credential store (never from config directly)
        var apiKey = await _creds.GetPasswordAsync(_config.ApiKeyCredentialId);
        _http.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", apiKey);

        var response = await _http.GetAsync($"/employees/{employeeId}/leave", ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<LeaveBalance>(ct)!;
    }
}

Environment-Specific Overrides

ASP.NET Core's standard layered configuration applies. Octopus reads from all configured sources in order:

SourcePriorityUse For
appsettings.jsonBase (lowest)Default values, non-sensitive config
appsettings.{Environment}.jsonEnvironmentDev/staging/production overrides
Environment variablesHighContainer/cloud deployments
Azure Key Vault / AWS SecretsHighProduction secrets (base URLs, etc.)
Command-line argumentsHighestOne-off overrides, testing
// Environment variable format: double underscore separates nesting levels
// HRPlugin__BaseUrl=https://prod-hr.company.com
// HRPlugin__Caching__TtlMinutes=30
// HRPlugin__Features__Payroll=true

// appsettings.Production.json
{
  "HRPlugin": {
    "BaseUrl": "https://prod-hr.company.com",
    "Caching": {
      "BackingStore": "Redis"
    }
  }
}

Config Validation at Startup

Use data annotations or IValidateOptions to fail fast if required config is missing:

using System.ComponentModel.DataAnnotations;

public class HRPluginConfig
{
    [Required, Url]
    public string BaseUrl { get; set; } = string.Empty;

    [Range(1, 300)]
    public int TimeoutSeconds { get; set; } = 30;

    [Range(1, int.MaxValue, ErrorMessage = "ApiKeyCredentialId must be a valid credential ID")]
    public int ApiKeyCredentialId { get; set; }
}

// In OnRegister — register with validation
public void OnRegister(IServiceCollection services, OctopusConfig config)
{
    services.AddOptions<HRPluginConfig>()
        .Bind(config.Configuration.GetSection("HRPlugin"))
        .ValidateDataAnnotations()
        .ValidateOnStart();  // Fails startup immediately if config is invalid
}
ValidateOnStart — This causes the application to throw on startup rather than failing at the first request that uses the bad config. Always use this for production plugins.

Secrets and Credentials

Never store raw secrets in appsettings.json. API keys, passwords, and tokens must be stored in the credential store and referenced by integer credential ID only. The credential resolver fetches secrets at runtime.
// WRONG — never do this
{
  "HRPlugin": {
    "ApiKey": "sk-prod-abc123..."   // ❌ Exposed in config files and logs
  }
}

// CORRECT — store the credential ID only
{
  "HRPlugin": {
    "ApiKeyCredentialId": 42        // ✓ The actual key lives in the credential store
  }
}

// At runtime:
var apiKey = await credentialResolver.GetPasswordAsync(config.ApiKeyCredentialId);