diff --git a/CaddyManager.Contracts/Configurations/Docker/DockerServiceConfiguration.cs b/CaddyManager.Contracts/Configurations/Docker/DockerServiceConfiguration.cs
index 7eb4e49..e0785c7 100644
--- a/CaddyManager.Contracts/Configurations/Docker/DockerServiceConfiguration.cs
+++ b/CaddyManager.Contracts/Configurations/Docker/DockerServiceConfiguration.cs
@@ -16,6 +16,12 @@ public class DockerServiceConfiguration
/// Uri to the Docker host
///
public string DockerHost { get; set; } = "unix:///var/run/docker.sock";
+
+ ///
+ /// Path to the Caddyfile as seen from inside the Caddy container, used by `caddy reload`. Must match the
+ /// container side of the Caddy container's config volume mount.
+ ///
+ public string CaddyConfigPathInContainer { get; set; } = "/etc/caddy/Caddyfile";
///
/// Returns the Docker host with environment check. If the environment variable DOCKER_HOST is set, it will return
diff --git a/CaddyManager.Contracts/Docker/CaddyReloadResponse.cs b/CaddyManager.Contracts/Docker/CaddyReloadResponse.cs
new file mode 100644
index 0000000..533931b
--- /dev/null
+++ b/CaddyManager.Contracts/Docker/CaddyReloadResponse.cs
@@ -0,0 +1,17 @@
+namespace CaddyManager.Contracts.Docker;
+
+///
+/// Result of a graceful Caddy configuration reload
+///
+public class CaddyReloadResponse
+{
+ ///
+ /// Indicates whether Caddy accepted and applied the configuration
+ ///
+ public bool Success { get; set; }
+
+ ///
+ /// Output from Caddy when the reload failed, typically the configuration validation error
+ ///
+ public string Message { get; set; } = string.Empty;
+}
diff --git a/CaddyManager.Contracts/Docker/IDockerService.cs b/CaddyManager.Contracts/Docker/IDockerService.cs
index 9669e6c..217dfcc 100644
--- a/CaddyManager.Contracts/Docker/IDockerService.cs
+++ b/CaddyManager.Contracts/Docker/IDockerService.cs
@@ -10,4 +10,11 @@ public interface IDockerService
///
///
Task RestartCaddyContainerAsync();
+
+ ///
+ /// Method to gracefully reload the Caddy configuration without restarting the container. Caddy validates the
+ /// configuration before applying it, so a broken configuration leaves the running one untouched.
+ ///
+ /// Whether the reload succeeded, along with Caddy's output when it did not
+ Task ReloadCaddyContainerAsync();
}
\ No newline at end of file
diff --git a/CaddyManager.Services/Docker/DockerService.cs b/CaddyManager.Services/Docker/DockerService.cs
index 8ffa5cf..98d09c7 100644
--- a/CaddyManager.Services/Docker/DockerService.cs
+++ b/CaddyManager.Services/Docker/DockerService.cs
@@ -15,12 +15,8 @@ public class DockerService(IConfigurationsService configurationsService) : IDock
/// Method to get the container id of the Caddy container by the name configured
///
///
- private async Task GetCaddyContainerId()
+ private async Task GetCaddyContainerId(IDockerClient client)
{
- var client = new DockerClientConfiguration(new Uri(Configuration.DockerHostWithEnvCheck)).CreateClient();
-
- if (client == null) return string.Empty;
-
var containers = await client.Containers.ListContainersAsync(new ContainersListParameters
{
All = true
@@ -30,19 +26,75 @@ public class DockerService(IConfigurationsService configurationsService) : IDock
?.ID ?? string.Empty;
}
-
+
+ private IDockerClient CreateClient() =>
+ new DockerClientConfiguration(new Uri(Configuration.DockerHostWithEnvCheck)).CreateClient();
+
///
public async Task RestartCaddyContainerAsync()
{
- var containerId = await GetCaddyContainerId();
-
- if (string.IsNullOrEmpty(containerId)) return;
-
- var client = new DockerClientConfiguration(new Uri(Configuration.DockerHostWithEnvCheck)).CreateClient();
+ using var client = CreateClient();
- if (client != null)
+ var containerId = await GetCaddyContainerId(client);
+
+ if (string.IsNullOrEmpty(containerId)) return;
+
+ await client.Containers.RestartContainerAsync(containerId, new ContainerRestartParameters());
+ }
+
+ ///
+ public async Task ReloadCaddyContainerAsync()
+ {
+ try
{
- await client.Containers.RestartContainerAsync(containerId, new ContainerRestartParameters());
+ using var client = CreateClient();
+
+ var containerId = await GetCaddyContainerId(client);
+
+ if (string.IsNullOrEmpty(containerId))
+ {
+ return new CaddyReloadResponse
+ {
+ Message = $"Caddy container '{Configuration.CaddyContainerName}' was not found"
+ };
+ }
+
+ var exec = await client.Exec.ExecCreateContainerAsync(containerId, new ContainerExecCreateParameters
+ {
+ Cmd = ["caddy", "reload", "--config", Configuration.CaddyConfigPathInContainer],
+ AttachStdout = true,
+ AttachStderr = true
+ });
+
+ string stdout, stderr;
+
+ using (var stream = await client.Exec.StartAndAttachContainerExecAsync(exec.ID, false))
+ {
+ (stdout, stderr) = await stream.ReadOutputToEndAsync(CancellationToken.None);
+ }
+
+ var inspection = await client.Exec.InspectContainerExecAsync(exec.ID);
+
+ if (inspection.ExitCode == 0)
+ {
+ return new CaddyReloadResponse { Success = true };
+ }
+
+ // Caddy reports errors on stderr, interleaved with its structured JSON progress logs which are noise here
+ var output = string.Join(Environment.NewLine, $"{stderr}{Environment.NewLine}{stdout}"
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Where(line => !line.StartsWith('{')));
+
+ return new CaddyReloadResponse
+ {
+ Message = string.IsNullOrWhiteSpace(output)
+ ? $"Caddy reload failed with exit code {inspection.ExitCode}"
+ : output.Trim()
+ };
+ }
+ catch (Exception e)
+ {
+ return new CaddyReloadResponse { Message = e.Message };
}
}
}
\ No newline at end of file
diff --git a/CaddyManager.Tests/Services/Docker/DockerServiceTests.cs b/CaddyManager.Tests/Services/Docker/DockerServiceTests.cs
index fd129cb..684f029 100644
--- a/CaddyManager.Tests/Services/Docker/DockerServiceTests.cs
+++ b/CaddyManager.Tests/Services/Docker/DockerServiceTests.cs
@@ -1,5 +1,6 @@
using CaddyManager.Contracts.Configurations.Docker;
using CaddyManager.Contracts.Configurations;
+using CaddyManager.Contracts.Docker;
using CaddyManager.Services.Docker;
namespace CaddyManager.Tests.Services.Docker;
@@ -161,6 +162,63 @@ public class DockerServiceTests
// Assert
config.CaddyContainerName.Should().Be("caddy");
config.DockerHost.Should().Be("unix:///var/run/docker.sock");
+ config.CaddyConfigPathInContainer.Should().Be("/etc/caddy/Caddyfile");
+ }
+
+ ///
+ /// Tests that a graceful reload against a container that does not exist reports a failure instead of throwing.
+ /// Setup: Points the service at a container name that is guaranteed not to be running.
+ /// Expectation: The service returns an unsuccessful carrying an explanatory
+ /// message, so the UI can surface the problem rather than the caller having to handle exceptions.
+ ///
+ [Fact]
+ public async Task ReloadCaddyContainerAsync_WithNonExistentContainer_ReturnsFailureResponse()
+ {
+ // Arrange
+ _mockConfigurationsService
+ .Setup(x => x.Get())
+ .Returns(new DockerServiceConfiguration
+ {
+ CaddyContainerName = "non-existent-container",
+ DockerHost = "unix:///var/run/docker.sock"
+ });
+
+ var service = new DockerService(_mockConfigurationsService.Object);
+
+ // Act
+ var response = await service.ReloadCaddyContainerAsync();
+
+ // Assert
+ response.Success.Should().BeFalse();
+ response.Message.Should().NotBeNullOrWhiteSpace();
+ }
+
+ ///
+ /// Tests that a graceful reload against an unreachable Docker daemon reports a failure instead of throwing.
+ /// Setup: Points the service at a Docker host that cannot be contacted.
+ /// Expectation: The connection error is captured in the response message, keeping the failure handling uniform
+ /// regardless of whether Docker, the container, or Caddy itself is the cause.
+ ///
+ [Fact]
+ public async Task ReloadCaddyContainerAsync_WithUnreachableDockerHost_ReturnsFailureResponse()
+ {
+ // Arrange
+ _mockConfigurationsService
+ .Setup(x => x.Get())
+ .Returns(new DockerServiceConfiguration
+ {
+ CaddyContainerName = "test-caddy",
+ DockerHost = "tcp://unreachable-host:2376"
+ });
+
+ var service = new DockerService(_mockConfigurationsService.Object);
+
+ // Act
+ var response = await service.ReloadCaddyContainerAsync();
+
+ // Assert
+ response.Success.Should().BeFalse();
+ response.Message.Should().NotBeNullOrWhiteSpace();
}
///
diff --git a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor
index ad71ca1..009bded 100644
--- a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor
+++ b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor
@@ -32,7 +32,7 @@
@bind-SelectedValues="_selectedCaddyConfigurations">
@foreach (var (index, caddyConfig) in _availableCaddyConfigurations.Index())
{
-
@if (index < _availableCaddyConfigurations.Count - 1)
diff --git a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs
index 127c059..ac9c9b3 100644
--- a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs
+++ b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs
@@ -78,7 +78,7 @@ public partial class CaddyReverseProxiesPage : ComponentBase
if (result is { Data: bool, Canceled: false } && (bool)result.Data)
{
- await RestartCaddy();
+ await ReloadCaddy();
}
Refresh();
@@ -160,6 +160,28 @@ public partial class CaddyReverseProxiesPage : ComponentBase
});
}
+ ///
+ /// Gracefully reload the Caddy configuration without bouncing the container
+ ///
+ ///
+ private async Task ReloadCaddy()
+ {
+ _isProcessing = true;
+ StateHasChanged();
+ Snackbar.Add("Reloading Caddy configuration", Severity.Info);
+ // Added a small delay for debugging purposes to ensure UI renders
+ await Task.Delay(100);
+
+ var response = await DockerService.ReloadCaddyContainerAsync();
+
+ Snackbar.Add(
+ response.Success ? "Caddy configuration reloaded successfully" : $"Failed to reload Caddy: {response.Message}",
+ response.Success ? Severity.Success : Severity.Error);
+
+ _isProcessing = false;
+ StateHasChanged();
+ }
+
///
/// Restart the Caddy container
///
diff --git a/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs b/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs
index 181cbfb..7708fad 100644
--- a/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs
+++ b/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs
@@ -3,7 +3,6 @@ using BlazorMonaco.Editor;
using CaddyManager.Contracts.Models.Caddy;
using Microsoft.AspNetCore.Components;
using MudBlazor;
-using CaddyManager.Contracts.Docker;
namespace CaddyManager.Components.Pages.Caddy.CaddyfileEditor;
@@ -34,7 +33,6 @@ public partial class CaddyfileEditor : ComponentBase
[Inject] private ICaddyService CaddyService { get; set; } = null!;
[Inject] private ISnackbar Snackbar { get; set; } = null!;
- [Inject] private IDockerService DockerService { get; set; } = null!;
[Inject] private IDialogService DialogService { get; set; } = null!;
protected override Task OnInitializedAsync()
diff --git a/CaddyManager/Components/Pages/Caddy/CaddyfilePage.razor.cs b/CaddyManager/Components/Pages/Caddy/CaddyfilePage.razor.cs
index ba59de7..3c97cc5 100644
--- a/CaddyManager/Components/Pages/Caddy/CaddyfilePage.razor.cs
+++ b/CaddyManager/Components/Pages/Caddy/CaddyfilePage.razor.cs
@@ -1,5 +1,6 @@
using BlazorMonaco.Editor;
using CaddyManager.Contracts.Caddy;
+using CaddyManager.Contracts.Docker;
using Microsoft.AspNetCore.Components;
using MudBlazor;
@@ -30,6 +31,11 @@ public partial class CaddyfilePage : ComponentBase
///
[Inject] private ISnackbar Snackbar { get; set; } = null!;
+ ///
+ /// Docker service used to have Caddy pick up the saved configuration
+ ///
+ [Inject] private IDockerService DockerService { get; set; } = null!;
+
///
/// Initializes the component
///
@@ -68,14 +74,21 @@ public partial class CaddyfilePage : ComponentBase
{
var response = CaddyService.SaveCaddyGlobalConfiguration(await _codeEditor.GetValue());
- if (response.Success)
- {
- Snackbar.Add("Caddy configuration saved successfully", Severity.Success);
- }
- else
+ if (!response.Success)
{
Snackbar.Add("Failed to save Caddy configuration", Severity.Error);
+ return;
}
+
+ Snackbar.Add("Caddy configuration saved successfully", Severity.Success);
+
+ var reloadResponse = await DockerService.ReloadCaddyContainerAsync();
+
+ Snackbar.Add(
+ reloadResponse.Success
+ ? "Caddy configuration reloaded successfully"
+ : $"Failed to reload Caddy: {reloadResponse.Message}",
+ reloadResponse.Success ? Severity.Success : Severity.Error);
}
///
diff --git a/README.md b/README.md
index eca0654..99f008d 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,9 @@ services:
ASPNETCORE_ENVIRONMENT: "Production"
CaddyService__ConfigDir: "/config"
DockerService__CaddyContainerName: "caddy"
+ # Path to the Caddyfile as seen from inside the caddy container. Must match the container side of the
+ # caddy container's config volume. Defaults to /etc/caddy/Caddyfile, so this line is optional above.
+ DockerService__CaddyConfigPathInContainer: "/etc/caddy/Caddyfile"
# To have the access to the caddy config file
user: "1000:1000"
ports:
@@ -179,6 +182,8 @@ Currently, the Caddy Manager is able to:
- Edit the content of the Caddy configuration files by clicking on the file name
- Create and manage the caddy files
- Edit the global Caddy configuration file by using the tab "Global Cadddyfile"
+- Apply configuration changes with a graceful `caddy reload` (no dropped connections, and Caddy validates the
+ configuration first, so a broken file is reported back instead of taking the proxy down)
- Restart caddy container on demand
- Parse simple information from the caddy configurations