From cac0352b1dc57270944379bb20930032799ebaae Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Thu, 3 Sep 2026 19:23:38 +0200 Subject: [PATCH] Upload custom SSL certificates for proxied accessories An accessory can run behind kamal-proxy on its own hostname with a certificate you provide, instead of one from Let's Encrypt: accessories: imgproxy: proxy: host: img.example.com ssl: certificate_pem: IMGPROXY_CERT private_key_pem: IMGPROXY_KEY The typical case is a subdomain fronted by a CDN: for example imgproxy behind Cloudflare, which caches the processed images and reaches the origin over HTTPS using a Cloudflare Origin Certificate. The config has been accepted since custom certificates were added, but booting such an accessory has always failed: Error: unable to load certificate `kamal-proxy deploy` is told where the certificate lives inside the proxy's apps-config directory, but nothing ever writes it there. Only the app boot path (Kamal::Cli::App::SslCertificates) uploads certificates; `kamal accessory boot` never did. Upload the certificate and key in the accessory boot path, right after the secrets and before the container is registered with kamal-proxy, using the same Proxy accessors and upload! calls as the app. There is no extra validation to add: the config validator already requires both keys, and Kamal::Secrets raises when a secret is missing. Both secrets are resolved before the directory is created, so a missing one fails without touching the host. Store the files under tls/accessories// by giving the accessory's Proxy config "accessories/" as its role_name. App roles use tls//, so an accessory named like a role would otherwise overwrite that role's certificate. This mirrors how the env directory already keeps env/roles/.env and env/accessories/.env apart. Fixes #1769 Co-Authored-By: Claude Fable 5.1 --- lib/kamal/cli/accessory.rb | 10 ++++++++++ lib/kamal/commands/accessory/proxy.rb | 4 ++++ lib/kamal/configuration/accessory.rb | 1 + test/cli/accessory_test.rb | 15 +++++++++++++++ test/configuration/accessory_test.rb | 10 ++++++++++ ...with_accessories_with_different_registries.yml | 2 ++ 6 files changed, 42 insertions(+) diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index bdbf12e7a..2d266edd8 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -28,6 +28,16 @@ def boot(name, prepare: true) execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug execute *accessory.ensure_env_directory upload! accessory.secrets_io, accessory.secrets_path, mode: "0600" + + if accessory.running_proxy? && accessory.proxy.custom_ssl_certificate? + cert_content = accessory.proxy.certificate_pem_content + key_content = accessory.proxy.private_key_pem_content + + execute *accessory.create_ssl_directory + upload! StringIO.new(cert_content), accessory.proxy.host_tls_cert, mode: "0644" + upload! StringIO.new(key_content), accessory.proxy.host_tls_key, mode: "0644" + end + execute *accessory.run(host: host) if accessory.running_proxy? diff --git a/lib/kamal/commands/accessory/proxy.rb b/lib/kamal/commands/accessory/proxy.rb index 833088da0..5a5a7f300 100644 --- a/lib/kamal/commands/accessory/proxy.rb +++ b/lib/kamal/commands/accessory/proxy.rb @@ -9,6 +9,10 @@ def remove proxy_exec :remove, service_name end + def create_ssl_directory + make_directory(File.join(config.proxy_boot.tls_directory, proxy.role_name)) + end + private def proxy_exec(*command) docker :exec, proxy_container_name, "kamal-proxy", *command diff --git a/lib/kamal/configuration/accessory.rb b/lib/kamal/configuration/accessory.rb index d8604007d..a4fe3f2de 100644 --- a/lib/kamal/configuration/accessory.rb +++ b/lib/kamal/configuration/accessory.rb @@ -132,6 +132,7 @@ def initialize_proxy Kamal::Configuration::Proxy.new \ config: config, proxy_config: accessory_config["proxy"], + role_name: "accessories/#{name}", context: "accessories/#{name}/proxy", secrets: config.secrets end diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index 07f9b907a..0a69deb9a 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -42,6 +42,21 @@ class CliAccessoryTest < CliTestCase end end + test "boot with custom ssl certificate" do + Kamal::Cli::Accessory.any_instance.expects(:directories).with("busybox") + Kamal::Cli::Accessory.any_instance.expects(:upload).with("busybox") + Kamal::Configuration::Proxy.any_instance.stubs(:custom_ssl_certificate?).returns(true) + Kamal::Configuration::Proxy.any_instance.stubs(:certificate_pem_content).returns("CERTIFICATE CONTENT") + Kamal::Configuration::Proxy.any_instance.stubs(:private_key_pem_content).returns("PRIVATE KEY CONTENT") + + run_command("boot", "busybox").tap do |output| + assert_match "mkdir -p .kamal/proxy/apps-config/app/tls/accessories/busybox", output + assert_match "Uploading \"CERTIFICATE CONTENT\" to .kamal/proxy/apps-config/app/tls/accessories/busybox/cert.pem", output + assert_match "Uploading \"PRIVATE KEY CONTENT\" to .kamal/proxy/apps-config/app/tls/accessories/busybox/key.pem", output + assert_match "--tls-certificate-path=\"/home/kamal-proxy/.apps-config/app/tls/accessories/busybox/cert.pem\" --tls-private-key-path=\"/home/kamal-proxy/.apps-config/app/tls/accessories/busybox/key.pem\"", output + end + end + test "upload" do run_command("upload", "mysql").tap do |output| assert_match "mkdir -p app-mysql/etc/mysql", output diff --git a/test/configuration/accessory_test.rb b/test/configuration/accessory_test.rb index 45dd321ff..f7847c3fb 100644 --- a/test/configuration/accessory_test.rb +++ b/test/configuration/accessory_test.rb @@ -446,6 +446,16 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase assert_equal [ "monitoring.example.com" ], @config.accessory(:monitoring).proxy.hosts end + test "proxy with custom ssl certificate" do + @deploy[:accessories]["monitoring"]["proxy"]["ssl"] = { "certificate_pem" => "CERT_PEM", "private_key_pem" => "KEY_PEM" } + proxy = Kamal::Configuration.new(@deploy).accessory(:monitoring).proxy + + assert_equal ".kamal/proxy/apps-config/app/tls/accessories/monitoring/cert.pem", proxy.host_tls_cert + assert_equal ".kamal/proxy/apps-config/app/tls/accessories/monitoring/key.pem", proxy.host_tls_key + assert_equal "/home/kamal-proxy/.apps-config/app/tls/accessories/monitoring/cert.pem", proxy.container_tls_cert + assert_equal "/home/kamal-proxy/.apps-config/app/tls/accessories/monitoring/key.pem", proxy.container_tls_key + end + test "invalid boolean restart policy" do @deploy[:accessories]["mysql"]["options"] = { "restart" => false } diff --git a/test/fixtures/deploy_with_accessories_with_different_registries.yml b/test/fixtures/deploy_with_accessories_with_different_registries.yml index d595ac98b..1d145822a 100644 --- a/test/fixtures/deploy_with_accessories_with_different_registries.yml +++ b/test/fixtures/deploy_with_accessories_with_different_registries.yml @@ -43,5 +43,7 @@ accessories: server: other.registry username: other_user password: other_pw + proxy: + host: busybox.example.com readiness_delay: 0