Repository: hetznercloud/hcloud-cloud-controller-manager
Pull request #181 (2021-04-19 06:53:29)

Create managed certificates for Load Balancers
This commit implements the creation of managed certificates for a
Hetzner Cloud Load Balancer.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6db91e06..82a41f76 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,8 +7,9 @@ Unreleased
 * Add support for setting load balancer values via cluster-wide defaults: `HCLOUD_LOAD_BALANCERS_LOCATION`, `HCLOUD_LOAD_BALANCERS_NETWORK_ZONE`, `HCLOUD_LOAD_BALANCERS_DISABLE_PRIVATE_INGRESS`, `HCLOUD_LOAD_BALANCERS_USE_PRIVATE_IP` (#125)
 * Fix: allow referencing certificates by name (#116)
 * Update build to go 1.16
-* Update hcloud-go to 1.24.0
+* Update hcloud-go to 1.25.0
 * Fix: Creating a Route may fail because of stale cache
+* Add support for Hetzner Cloud Managed Certificates
 
 v1.8.1
 ------
diff --git a/go.mod b/go.mod
index dcd9f3c0..1ffaec69 100644
--- a/go.mod
+++ b/go.mod
@@ -6,10 +6,10 @@ require (
 	github.com/NYTimes/gziphandler v1.1.1 // indirect
 	github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect
 	github.com/golang/groupcache v0.0.0-20171101203131-84a468cf14b4 // indirect
-	github.com/hetznercloud/hcloud-go v1.24.0
-	github.com/stretchr/testify v1.6.1
+	github.com/hetznercloud/hcloud-go v1.25.0
+	github.com/stretchr/testify v1.7.0
 	github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 // indirect
-	golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975
+	golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
 	gopkg.in/square/go-jose.v2 v2.3.0 // indirect
 	k8s.io/api v0.18.8
 	k8s.io/apimachinery v0.18.8
diff --git a/go.sum b/go.sum
index de60bb0d..e03cb886 100644
--- a/go.sum
+++ b/go.sum
@@ -132,7 +132,6 @@ github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
 github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
-github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -257,8 +256,8 @@ github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ
 github.com/google/cadvisor v0.35.0/go.mod h1:1nql6U13uTHaLYB8rLS5x9IJc2qT6Xd/Tr1sTX6NE48=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
 github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -299,8 +298,8 @@ github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce/go.mod h1:oZtUIOe8dh
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 github.com/heketi/heketi v9.0.1-0.20190917153846-c2e2a4ab7ab9+incompatible/go.mod h1:bB9ly3RchcQqsQ9CpyaQwvva7RS5ytVoSoholZQON6o=
 github.com/heketi/tests v0.0.0-20151005000721-f3775cbcefd6/go.mod h1:xGMAM8JLi7UkZt1i4FQeQy0R2T8GLUwQhOP5M1gBhy4=
-github.com/hetznercloud/hcloud-go v1.24.0 h1:/CeHDzhH3Fhm83pjxvE3xNNLbvACl0Lu1/auJ83gG5U=
-github.com/hetznercloud/hcloud-go v1.24.0/go.mod h1:3YmyK8yaZZ48syie6xpm3dt26rtB6s65AisBHylXYFA=
+github.com/hetznercloud/hcloud-go v1.25.0 h1:QAaFKtGKWRxjwjKJWBGMxGYUxVEQmIkb35j/WXrsazY=
+github.com/hetznercloud/hcloud-go v1.25.0/go.mod h1:2C5uMtBiMoFr3m7lBFPf7wXTdh33CevmZpQIIDPGYJI=
 github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/imdario/mergo v0.3.5 h1:JboBksRwiiAJWvIYJVo46AfV+IAIKZpfrSzVKj42R4Q=
@@ -501,8 +500,8 @@ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoH
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
 github.com/thecodeteam/goscaleio v0.1.0/go.mod h1:68sdkZAsK8bvEwBlbQnlLS+xU+hvLYM/iQ8KXej1AwM=
@@ -557,8 +556,9 @@ golang.org/x/crypto v0.0.0-20190424203555-c05e17bb3b2d/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975 h1:/Tl7pH94bvbAAHBdZJT947M/+gp0+CqQXDtMRC0fseo=
 golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
+golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -596,8 +596,9 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191004110552-13f9640d40b9 h1:rjwSpXsdiK0dV8/Naq3kAw9ymfAeJIyd0upUIElB+lI=
 golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -633,14 +634,18 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7 h1:HmbHVPwrPEKPGLAcHSrMe6+hqSUlvZU0rab6x5EXfGU=
 golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.0.0-20170915090833-1cbadb444a80/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
diff --git a/hcloud/cloud.go b/hcloud/cloud.go
index c1a69bb8..23d7be80 100644
--- a/hcloud/cloud.go
+++ b/hcloud/cloud.go
@@ -110,7 +110,7 @@ func newCloud(config io.Reader) (cloudprovider.Interface, error) {
 
 	lbOps := &hcops.LoadBalancerOps{
 		LBClient:      &client.LoadBalancer,
-		CertClient:    &client.Certificate,
+		CertOps:       &hcops.CertificateOps{CertClient: &client.Certificate},
 		ActionClient:  &client.Action,
 		NetworkClient: &client.Network,
 		NetworkID:     networkID,
diff --git a/internal/annotation/load_balancer.go b/internal/annotation/load_balancer.go
index 7319dce9..83cae743 100644
--- a/internal/annotation/load_balancer.go
+++ b/internal/annotation/load_balancer.go
@@ -94,10 +94,42 @@ const (
 	// LBSvcHTTPCookieLifetime specifies the lifetime of the HTTP cookie.
 	LBSvcHTTPCookieLifetime Name = "load-balancer.hetzner.cloud/http-cookie-lifetime"
 
-	// LBSvcHTTPCertificates specifies a comma separated IDs of Certificates
-	// assigned to the service. HTTPS only.
+	// LBSvcHTTPCertificateType defines the type of certificate the Load
+	// Balancer should use.
+	//
+	// Possible values are "uploaded" and "managed".
+	//
+	// If not set LBSvcHTTPCertificateType defaults to "uploaded".
+	// LBSvcHTTPManagedCertificateDomains is ignored in this case.
+	//
+	// HTTPS only.
+	LBSvcHTTPCertificateType Name = "load-balancer.hetzner.cloud/certificate-type"
+
+	// LBSvcHTTPCertificates a comma separated list of IDs or Names of
+	// Certificates assigned to the service.
+	//
+	// HTTPS only.
 	LBSvcHTTPCertificates Name = "load-balancer.hetzner.cloud/http-certificates"
 
+	// LBSvcHTTPManagedCertificateName contains the names of the managed
+	// certificate to create by the Cloud Controller manager. Ignored if
+	// LBSvcHTTPCertificateType is missing or set to "uploaded". Optional.
+	LBSvcHTTPManagedCertificateName Name = "load-balancer.hetzner.cloud/http-managed-certificate-name"
+
+	// LBSvcHTTPManagedCertificateUseACMEStaging tells the cloud controller manager to create
+	// the certificate using Let's Encrypt staging.
+	//
+	// This annotation is exclusively for Hetzner internal testing purposes.
+	// Users should not use this annotation. There is no guarantee that it
+	// remains or continues to function as it currently functions.
+	LBSvcHTTPManagedCertificateUseACMEStaging Name = "load-balancer.hetzner.cloud/http-managed-certificate-acme-staging"
+
+	// LBSvcHTTPManagedCertificateDomains contains a coma separated list of the
+	// domain names of the managed certificate.
+	//
+	// All domains are used to create a single managed certificate.
+	LBSvcHTTPManagedCertificateDomains Name = "load-balancer.hetzner.cloud/http-managed-certificate-domains"
+
 	// LBSvcRedirectHTTP create a redirect from HTTP to HTTPS. HTTPS only.
 	LBSvcRedirectHTTP Name = "load-balancer.hetzner.cloud/http-redirect-http"
 
diff --git a/internal/annotation/name.go b/internal/annotation/name.go
index eff74daa..092eab96 100644
--- a/internal/annotation/name.go
+++ b/internal/annotation/name.go
@@ -22,7 +22,7 @@ type Name string
 //
 // AnnotateService returns an error if converting v to a string fails.
 func (s Name) AnnotateService(svc *v1.Service, v interface{}) error {
-	const op = "annotation/Spec.AddToService"
+	const op = "annotation/Name.AnnotateService"
 
 	if svc.ObjectMeta.Annotations == nil {
 		svc.ObjectMeta.Annotations = make(map[string]string)
@@ -37,6 +37,8 @@ func (s Name) AnnotateService(svc *v1.Service, v interface{}) error {
 		svc.ObjectMeta.Annotations[k] = vt
 	case []string:
 		svc.ObjectMeta.Annotations[k] = strings.Join(vt, ",")
+	case hcloud.CertificateType:
+		svc.ObjectMeta.Annotations[k] = string(vt)
 	case []*hcloud.Certificate:
 		idsOrNames := make([]string, len(vt))
 		for i, c := range vt {
@@ -282,6 +284,31 @@ func (s Name) CertificatesFromService(svc *v1.Service) ([]*hcloud.Certificate, e
 	return cs, err
 }
 
+// CertificateTypeFromService retrieves the hcloud.CertificateType value
+// belonging to the annotation from svc.
+//
+// CertificateTypeFromService returns an error if the value could not be
+// converted to a hcloud.CertificateType. In the case of a missing value, the
+// error wraps ErrNotSet.
+func (s Name) CertificateTypeFromService(svc *v1.Service) (hcloud.CertificateType, error) {
+	const op = "annotation/Name.CertificateTypeFromService"
+	var ct hcloud.CertificateType
+
+	err := s.applyToValue(op, svc, func(v string) error {
+		switch strings.ToLower(v) {
+		case string(hcloud.CertificateTypeUploaded):
+			ct = hcloud.CertificateTypeUploaded
+		case string(hcloud.CertificateTypeManaged):
+			ct = hcloud.CertificateTypeManaged
+		default:
+			return fmt.Errorf("%s: unsupported certificate type: %s", op, v)
+		}
+		return nil
+	})
+
+	return ct, err
+}
+
 func (s Name) applyToValue(op string, svc *v1.Service, f func(string) error) error {
 	v, ok := s.StringFromService(svc)
 	if !ok {
diff --git a/internal/hcops/certificates.go b/internal/hcops/certificates.go
index d77a0a18..6c0396bb 100644
--- a/internal/hcops/certificates.go
+++ b/internal/hcops/certificates.go
@@ -2,6 +2,7 @@ package hcops
 
 import (
 	"context"
+	"fmt"
 
 	"github.com/hetznercloud/hcloud-go/hcloud"
 )
@@ -9,5 +10,78 @@ import (
 // HCloudCertificateClient defines the hcloud-go function related to
 // certificate management.
 type HCloudCertificateClient interface {
+	AllWithOpts(context.Context, hcloud.CertificateListOpts) ([]*hcloud.Certificate, error)
 	Get(ctx context.Context, idOrName string) (*hcloud.Certificate, *hcloud.Response, error)
+	CreateCertificate(
+		ctx context.Context, opts hcloud.CertificateCreateOpts,
+	) (hcloud.CertificateCreateResult, *hcloud.Response, error)
+}
+
+// CertificateOps implements all operations regarding Hetzner Cloud Certificates.
+type CertificateOps struct {
+	CertClient HCloudCertificateClient
+}
+
+// GetCertificateByNameOrID obtains a certificate from the Hetzner Cloud
+// backend using its ID or Name.
+//
+// If a certificate could not be found the returned error wraps ErrNotFound.
+func (co *CertificateOps) GetCertificateByNameOrID(ctx context.Context, idOrName string) (*hcloud.Certificate, error) {
+	const op = "hcops/CertificateOps.GetCertificateByNameOrID"
+
+	cert, _, err := co.CertClient.Get(ctx, idOrName)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", op, err)
+	}
+	if cert == nil {
+		return nil, fmt.Errorf("%s: %w", op, ErrNotFound)
+	}
+	return cert, nil
+}
+
+// GetCertificateByLabel obtains a single certificate by the passed label.
+//
+// If the label matches more than one certificate a wrapped ErrNonUniqueResult
+// is returned. If no certificate could be found a wrapped ErrNotFound is
+// returned.
+func (co *CertificateOps) GetCertificateByLabel(ctx context.Context, label string) (*hcloud.Certificate, error) {
+	const op = "hcops/CertificateOps.GetCertificateByLabel"
+	opts := hcloud.CertificateListOpts{ListOpts: hcloud.ListOpts{LabelSelector: label}}
+	certs, err := co.CertClient.AllWithOpts(ctx, opts)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %v", op, err)
+	}
+	if len(certs) == 0 {
+		return nil, fmt.Errorf("%s: %w", op, ErrNotFound)
+	}
+	if len(certs) > 1 {
+		return nil, fmt.Errorf("%s: %w", op, ErrNonUniqueResult)
+	}
+	return certs[0], nil
+}
+
+// CreateManagedCertificate creates a managed certificate for domains labeled
+// with label.
+//
+// CreateManagedCertificate returns a wrapped ErrAlreadyExists if the
+// certificate already exists.
+func (co *CertificateOps) CreateManagedCertificate(
+	ctx context.Context, name string, domains []string, labels map[string]string,
+) error {
+	const op = "hcops/CertificateOps.CreateManagedCertificate"
+
+	opts := hcloud.CertificateCreateOpts{
+		Name:        name,
+		Type:        hcloud.CertificateTypeManaged,
+		DomainNames: domains,
+		Labels:      labels,
+	}
+	_, _, err := co.CertClient.CreateCertificate(ctx, opts)
+	if hcloud.IsError(err, hcloud.ErrorCodeUniquenessError) {
+		return fmt.Errorf("%s: %w", op, ErrAlreadyExists)
+	}
+	if err != nil {
+		return fmt.Errorf("%s: %v", op, err)
+	}
+	return nil
 }
diff --git a/internal/hcops/errors.go b/internal/hcops/errors.go
index 694fca75..93db97d4 100644
--- a/internal/hcops/errors.go
+++ b/internal/hcops/errors.go
@@ -11,4 +11,8 @@ var (
 	// by the Hetzner Cloud backend was returned where only one item was
 	// expected.
 	ErrNonUniqueResult = errors.New("non-unique result")
+
+	// ErrAlreadyExists signals that the resource creation failed, because the
+	// resource already exists.
+	ErrAlreadyExists = errors.New("already exists")
 )
diff --git a/internal/hcops/load_balancer.go b/internal/hcops/load_balancer.go
index 168ef14b..c2a4e381 100644
--- a/internal/hcops/load_balancer.go
+++ b/internal/hcops/load_balancer.go
@@ -65,7 +65,7 @@ type LoadBalancerOps struct {
 	LBClient      HCloudLoadBalancerClient
 	ActionClient  HCloudActionClient
 	NetworkClient HCloudNetworkClient
-	CertClient    HCloudCertificateClient
+	CertOps       *CertificateOps
 	RetryDelay    time.Duration
 	NetworkID     int
 	Defaults      LoadBalancerDefaults
@@ -595,6 +595,10 @@ func (l *LoadBalancerOps) ReconcileHCLBServices(
 	const op = "hcops/LoadBalancerOps.ReconcileHCLBServices"
 	var changed bool
 
+	if err := l.reconcileManagedCertificate(ctx, svc); err != nil {
+		return false, fmt.Errorf("%s: %v", op, err)
+	}
+
 	hclbListenPorts := make(map[int]bool, len(lb.Services))
 	for _, hclbService := range lb.Services {
 		hclbListenPorts[hclbService.ListenPort] = true
@@ -616,7 +620,7 @@ func (l *LoadBalancerOps) ReconcileHCLBServices(
 		portExists := hclbListenPorts[portNo]
 		delete(hclbListenPorts, portNo)
 
-		b := &hclbServiceOptsBuilder{Port: port, Service: svc, CertClient: l.CertClient}
+		b := &hclbServiceOptsBuilder{Port: port, Service: svc, CertOps: l.CertOps}
 		if portExists {
 			klog.InfoS("update service", "op", op, "port", portNo, "loadBalancerID", lb.ID)
 
@@ -664,10 +668,43 @@ func (l *LoadBalancerOps) ReconcileHCLBServices(
 	return changed, nil
 }
 
+func (l *LoadBalancerOps) reconcileManagedCertificate(ctx context.Context, svc *v1.Service) error {
+	const op = "hcops/LoadBalancerOps.reconcileManagedCertificate"
+
+	if typ, ok := annotation.LBSvcHTTPCertificateType.StringFromService(svc); !ok || typ != string(hcloud.CertificateTypeManaged) {
+		return nil
+	}
+	name, ok := annotation.LBSvcHTTPManagedCertificateName.StringFromService(svc)
+	if !ok || name == "" {
+		name = fmt.Sprintf("ccm-managed-certificate-%s", svc.ObjectMeta.UID)
+	}
+	domains, err := annotation.LBSvcHTTPManagedCertificateDomains.StringsFromService(svc)
+	if errors.Is(err, annotation.ErrNotSet) {
+		return fmt.Errorf("%s: no domains for managed certificate", op)
+	}
+	labels := map[string]string{
+		LabelServiceUID: string(svc.ObjectMeta.UID),
+	}
+	// It's ok to ignore the error here. We are only interested if the
+	// annotation is set and parseable as a truthy boolean. Anything else tells
+	// us we do not want to use ACME staging.
+	if ok, _ := annotation.LBSvcHTTPManagedCertificateUseACMEStaging.BoolFromService(svc); ok {
+		labels["HC-Use-Staging-CA"] = "true"
+	}
+	err = l.CertOps.CreateManagedCertificate(ctx, name, domains, labels)
+	if errors.Is(err, ErrAlreadyExists) {
+		return nil
+	}
+	if err != nil {
+		return fmt.Errorf("%s: %v", op, err)
+	}
+	return nil
+}
+
 type hclbServiceOptsBuilder struct {
-	Port       v1.ServicePort
-	Service    *v1.Service
-	CertClient HCloudCertificateClient
+	Port    v1.ServicePort
+	Service *v1.Service
+	CertOps *CertificateOps
 
 	listenPort      int
 	destinationPort int
@@ -751,6 +788,12 @@ func (b *hclbServiceOptsBuilder) extract() {
 	})
 
 	b.do(func() error {
+		certtyp, ok := annotation.LBSvcHTTPCertificateType.StringFromService(b.Service)
+		if ok && certtyp == string(hcloud.CertificateTypeManaged) {
+			// Continue with managed certificates below
+			return nil
+		}
+
 		certs, err := annotation.LBSvcHTTPCertificates.CertificatesFromService(b.Service)
 		if errors.Is(err, annotation.ErrNotSet) {
 			return nil
@@ -762,7 +805,7 @@ func (b *hclbServiceOptsBuilder) extract() {
 		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 		defer cancel()
 
-		certs, err = b.resolveCertNames(ctx, certs)
+		certs, err = b.resolveCertsByNameOrID(ctx, certs)
 		if err != nil {
 			return fmt.Errorf("%s: %w", op, err)
 		}
@@ -771,6 +814,26 @@ func (b *hclbServiceOptsBuilder) extract() {
 		return nil
 	})
 
+	b.do(func() error {
+		certtyp, ok := annotation.LBSvcHTTPCertificateType.StringFromService(b.Service)
+		if !ok || certtyp != string(hcloud.CertificateTypeManaged) {
+			// Not a a managed certificate.
+			return nil
+		}
+
+		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+
+		svcUID := b.Service.ObjectMeta.UID
+		cert, err := b.CertOps.GetCertificateByLabel(ctx, fmt.Sprintf("%s=%s", LabelServiceUID, svcUID))
+		if err != nil {
+			return err
+		}
+		b.httpOpts.Certificates = []*hcloud.Certificate{{ID: cert.ID}}
+		b.addHTTP = true
+		return nil
+	})
+
 	b.do(func() error {
 		redirectHTTP, err := annotation.LBSvcRedirectHTTP.BoolFromService(b.Service)
 		if errors.Is(err, annotation.ErrNotSet) {
@@ -800,8 +863,8 @@ func (b *hclbServiceOptsBuilder) extract() {
 	b.extractHealthCheck()
 }
 
-func (b *hclbServiceOptsBuilder) resolveCertNames(ctx context.Context, cs []*hcloud.Certificate) ([]*hcloud.Certificate, error) {
-	const op = "hcops/hclbServiceOptsBuilder.resolveCertNames"
+func (b *hclbServiceOptsBuilder) resolveCertsByNameOrID(ctx context.Context, cs []*hcloud.Certificate) ([]*hcloud.Certificate, error) {
+	const op = "hcops/hclbServiceOptsBuilder.resolveCertsByNameOrID"
 
 	resolved := make([]*hcloud.Certificate, len(cs))
 	for i, c := range cs {
@@ -810,7 +873,7 @@ func (b *hclbServiceOptsBuilder) resolveCertNames(ctx context.Context, cs []*hcl
 			continue
 		}
 
-		c, _, err := b.CertClient.Get(ctx, c.Name)
+		c, err := b.CertOps.GetCertificateByNameOrID(ctx, c.Name)
 		if err != nil {
 			return nil, fmt.Errorf("%s: %w", op, err)
 		}
diff --git a/internal/mocks/casts.go b/internal/mocks/casts.go
index 55a9e93c..b0299893 100644
--- a/internal/mocks/casts.go
+++ b/internal/mocks/casts.go
@@ -68,3 +68,19 @@ func getCertificatePtr(args mock.Arguments, i int) *hcloud.Certificate {
 	}
 	return v.(*hcloud.Certificate)
 }
+
+func getCertificatePtrS(args mock.Arguments, i int) []*hcloud.Certificate {
+	v := args.Get(i)
+	if v == nil {
+		return nil
+	}
+	return v.([]*hcloud.Certificate)
+}
+
+func getCertificateCreateResult(args mock.Arguments, i int) hcloud.CertificateCreateResult {
+	v := args.Get(i)
+	if v == nil {
+		return hcloud.CertificateCreateResult{}
+	}
+	return v.(hcloud.CertificateCreateResult)
+}
diff --git a/internal/mocks/certificates.go b/internal/mocks/certificates.go
index 1384f1c5..851b95d3 100644
--- a/internal/mocks/certificates.go
+++ b/internal/mocks/certificates.go
@@ -11,7 +11,21 @@ type CertificateClient struct {
 	mock.Mock
 }
 
+func (m *CertificateClient) AllWithOpts(
+	ctx context.Context, opts hcloud.CertificateListOpts,
+) ([]*hcloud.Certificate, error) {
+	args := m.Called(ctx, opts)
+	return getCertificatePtrS(args, 0), args.Error(1)
+}
+
 func (m *CertificateClient) Get(ctx context.Context, idOrName string) (*hcloud.Certificate, *hcloud.Response, error) {
 	args := m.Called(ctx, idOrName)
 	return getCertificatePtr(args, 0), getResponsePtr(args, 1), args.Error(2)
 }
+
+func (m *CertificateClient) CreateCertificate(
+	ctx context.Context, opts hcloud.CertificateCreateOpts,
+) (hcloud.CertificateCreateResult, *hcloud.Response, error) {
+	args := m.Called(ctx, opts)
+	return getCertificateCreateResult(args, 0), getResponsePtr(args, 1), args.Error(2)
+}


Test patch:
diff --git a/e2etests/e2e_test.go b/e2etests/e2e_test.go
index bd12ac8f..06c46406 100644
--- a/e2etests/e2e_test.go
+++ b/e2etests/e2e_test.go
@@ -3,11 +3,14 @@ package e2etests
 import (
 	"context"
 	"fmt"
+	"math/rand"
 	"os"
 	"strings"
 	"testing"
 
 	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/annotation"
+	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/hcops"
+	"github.com/hetznercloud/hcloud-go/hcloud"
 	"github.com/stretchr/testify/assert"
 	typesv1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -155,6 +158,43 @@ func TestCloudControllerManagerLoadBalancersHTTPS(t *testing.T) {
 	lbTest.TearDown()
 }
 
+func TestCloudControllerManagerLoadBalancersHTTPSWithManagedCertificate(t *testing.T) {
+	domainName := fmt.Sprintf("%d-ccm-test.hc-certs.de", rand.Int())
+	lbTest := lbTestHelper{
+		t:             t,
+		K8sClient:     testCluster.k8sClient,
+		KeepOnFailure: testCluster.KeepOnFailure,
+		podName:       "loadbalancer-https",
+		port:          443,
+	}
+
+	pod := lbTest.DeployTestPod()
+
+	lbSvc := lbTest.ServiceDefinition(pod, map[string]string{
+		string(annotation.LBLocation):                                "nbg1",
+		string(annotation.LBSvcHTTPCertificateType):                  "managed",
+		string(annotation.LBSvcHTTPManagedCertificateDomains):        domainName,
+		string(annotation.LBSvcProtocol):                             "https",
+		string(annotation.LBSvcHTTPManagedCertificateUseACMEStaging): "true",
+	})
+
+	lbSvc, err := lbTest.CreateService(lbSvc)
+	if err != nil {
+		t.Fatalf("deploying test svc: %s", err)
+	}
+	certs, err := testCluster.setup.Hcloud.Certificate.AllWithOpts(context.Background(), hcloud.CertificateListOpts{
+		ListOpts: hcloud.ListOpts{
+			LabelSelector: fmt.Sprintf("%s=%s", hcops.LabelServiceUID, lbSvc.ObjectMeta.UID),
+		},
+	})
+	assert.NoError(t, err)
+	assert.Len(t, certs, 1)
+
+	lbTest.TearDown()
+	_, err = testCluster.setup.Hcloud.Certificate.Delete(context.Background(), certs[0])
+	assert.NoError(t, err)
+}
+
 func TestCloudControllerManagerLoadBalancersWithPrivateNetwork(t *testing.T) {
 	if testCluster.useNetworks == false {
 		t.Skipf("Private Networks test is disabled")
diff --git a/e2etests/testing.go b/e2etests/testing.go
index e59d8caf..85a490e7 100644
--- a/e2etests/testing.go
+++ b/e2etests/testing.go
@@ -558,7 +558,6 @@ func WaitForHTTPAvailable(t *testing.T, ingressIP string, useHTTPS bool) {
 	proto := "http"
 	if useHTTPS {
 		proto = "https"
-
 	}
 
 	err := wait.Poll(1*time.Second, 2*time.Minute, func() (bool, error) {
diff --git a/internal/annotation/name_test.go b/internal/annotation/name_test.go
index 5fc8412f..b07110d8 100644
--- a/internal/annotation/name_test.go
+++ b/internal/annotation/name_test.go
@@ -69,7 +69,7 @@ func TestName_AddToService(t *testing.T) {
 		{
 			name:  "set unsupported value",
 			value: struct{}{},
-			err:   fmt.Errorf("annotation/Spec.AddToService: %v: unsupported type: %T", ann, struct{}{}),
+			err:   fmt.Errorf("annotation/Name.AnnotateService: %v: unsupported type: %T", ann, struct{}{}),
 		},
 		{
 			name:  "does not overwrite unrelated annotations",
@@ -100,12 +100,6 @@ func TestName_AddToService(t *testing.T) {
 	}
 }
 
-type stringer struct{ Value string }
-
-func (s stringer) String() string {
-	return s.Value
-}
-
 func TestName_StringFromService(t *testing.T) {
 	tests := []struct {
 		name           string
@@ -144,54 +138,6 @@ func TestName_StringFromService(t *testing.T) {
 	}
 }
 
-type typedAccessorTest struct {
-	name           string
-	svcAnnotations map[annotation.Name]interface{}
-	err            error
-	expected       interface{}
-}
-
-func (tt *typedAccessorTest) run(t *testing.T, call func(svc *v1.Service) (interface{}, error)) {
-	var svc v1.Service
-
-	t.Helper()
-
-	for k, v := range tt.svcAnnotations {
-		if err := k.AnnotateService(&svc, v); !assert.NoError(t, err) {
-			return
-		}
-	}
-
-	actual, err := call(&svc)
-	if tt.err != nil {
-		if errors.Is(err, tt.err) {
-			return
-		}
-		assert.EqualError(t, err, tt.err.Error())
-		return
-	}
-	assert.NoError(t, err)
-	// Don't use assert.Equal to compare nil values, as it requires the nil
-	// values to be casted to the correct type.
-	if tt.expected == nil && actual == nil {
-		return
-	}
-	assert.Equal(t, tt.expected, actual)
-}
-
-func runAllTypedAccessorTests(
-	t *testing.T, tests []typedAccessorTest, call func(svc *v1.Service) (interface{}, error),
-) {
-	t.Helper()
-
-	for _, tt := range tests {
-		tt := tt
-		t.Run(tt.name, func(t *testing.T) {
-			tt.run(t, call)
-		})
-	}
-}
-
 func TestName_StringsFromService(t *testing.T) {
 	tests := []typedAccessorTest{
 		{
@@ -453,3 +399,87 @@ func TestName_CertificatesFromService(t *testing.T) {
 		return ann.CertificatesFromService(svc)
 	})
 }
+
+func TestName_CertificateTypeFromService(t *testing.T) {
+	tests := []typedAccessorTest{
+		{
+			name: "uploaded certificate",
+			svcAnnotations: map[annotation.Name]interface{}{
+				ann: hcloud.CertificateTypeUploaded,
+			},
+			expected: hcloud.CertificateTypeUploaded,
+		},
+		{
+			name: "managed certificate",
+			svcAnnotations: map[annotation.Name]interface{}{
+				ann: hcloud.CertificateTypeManaged,
+			},
+			expected: hcloud.CertificateTypeManaged,
+		},
+		{
+			name: "unsupported certificate type",
+			svcAnnotations: map[annotation.Name]interface{}{
+				ann: "unsupported type",
+			},
+			err: fmt.Errorf("annotation/Name.CertificateTypeFromService: annotation/Name.CertificateTypeFromService: unsupported certificate type: unsupported type"),
+		},
+	}
+
+	runAllTypedAccessorTests(t, tests, func(svc *v1.Service) (interface{}, error) {
+		return ann.CertificateTypeFromService(svc)
+	})
+}
+
+type stringer struct{ Value string }
+
+func (s stringer) String() string {
+	return s.Value
+}
+
+type typedAccessorTest struct {
+	name           string
+	svcAnnotations map[annotation.Name]interface{}
+	err            error
+	expected       interface{}
+}
+
+func (tt *typedAccessorTest) run(t *testing.T, call func(svc *v1.Service) (interface{}, error)) {
+	var svc v1.Service
+
+	t.Helper()
+
+	for k, v := range tt.svcAnnotations {
+		if err := k.AnnotateService(&svc, v); !assert.NoError(t, err) {
+			return
+		}
+	}
+
+	actual, err := call(&svc)
+	if tt.err != nil {
+		if errors.Is(err, tt.err) {
+			return
+		}
+		assert.EqualError(t, err, tt.err.Error())
+		return
+	}
+	assert.NoError(t, err)
+	// Don't use assert.Equal to compare nil values, as it requires the nil
+	// values to be casted to the correct type.
+	if tt.expected == nil && actual == nil {
+		return
+	}
+	assert.Equal(t, tt.expected, actual)
+}
+
+func runAllTypedAccessorTests(
+	t *testing.T, tests []typedAccessorTest, call func(svc *v1.Service) (interface{}, error),
+) {
+	t.Helper()
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			tt.run(t, call)
+		})
+	}
+}
diff --git a/internal/hcops/certificates_test.go b/internal/hcops/certificates_test.go
new file mode 100644
index 00000000..90b74cb9
--- /dev/null
+++ b/internal/hcops/certificates_test.go
@@ -0,0 +1,232 @@
+package hcops_test
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"testing"
+
+	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/hcops"
+	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/mocks"
+	"github.com/hetznercloud/hcloud-go/hcloud"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestCertificateOps_GetCertificateByNameOrID(t *testing.T) {
+	tests := []certificateOpsTestCase{
+		{
+			Name: "certificate not found",
+			Mock: func(t *testing.T, tt *certificateOpsTestCase) {
+				tt.CertClient.
+					On("Get", tt.Ctx, "15").
+					Return(nil, nil, nil)
+			},
+			Perform: func(t *testing.T, tt *certificateOpsTestCase) {
+				cert, err := tt.CertOps.GetCertificateByNameOrID(tt.Ctx, "15")
+				assert.ErrorIs(t, err, hcops.ErrNotFound)
+				assert.Nil(t, cert)
+			},
+		},
+		{
+			Name:      "error calling API",
+			ClientErr: errors.New("some error"),
+			Mock: func(t *testing.T, tt *certificateOpsTestCase) {
+				tt.CertClient.
+					On("Get", tt.Ctx, "some-cert").
+					Return(nil, nil, tt.ClientErr)
+			},
+			Perform: func(t *testing.T, tt *certificateOpsTestCase) {
+				cert, err := tt.CertOps.GetCertificateByNameOrID(tt.Ctx, "some-cert")
+				assert.ErrorIs(t, err, tt.ClientErr)
+				assert.Nil(t, cert)
+			},
+		},
+		{
+			Name:        "certificate found",
+			Certificate: &hcloud.Certificate{ID: 16},
+			Mock: func(t *testing.T, tt *certificateOpsTestCase) {
+				tt.CertClient.
+					On("Get", tt.Ctx, "16").
+					Return(tt.Certificate, nil, nil)
+			},
+			Perform: func(t *testing.T, tt *certificateOpsTestCase) {
+				cert, err := tt.CertOps.GetCertificateByNameOrID(tt.Ctx, "16")
+				assert.NoError(t, err)
+				assert.Equal(t, tt.Certificate, cert)
+			},
+		},
+	}
+
+	runCertificateOpsTestCases(t, tests)
+}
+
+func TestCertificateOps_GetCertificateByLabel(t *testing.T) {
+	tests := []certificateOpsTestCase{
+		{
+			Name: "call to backend fails",
+			Mock: func(t *testing.T, tt *certificateOpsTestCase) {
+				tt.CertClient.
+					On("AllWithOpts", tt.Ctx, hcloud.CertificateListOpts{
+						ListOpts: hcloud.ListOpts{LabelSelector: "key=value"},
+					}).
+					Return(nil, errors.New("test error"))
+			},
+			Perform: func(t *testing.T, tt *certificateOpsTestCase) {
+				cert, err := tt.CertOps.GetCertificateByLabel(tt.Ctx, "key=value")
+				assert.Nil(t, cert)
+				assert.Error(t, err)
+				assert.True(t, strings.HasSuffix(err.Error(), "test error"))
+			},
+		},
+		{
+			Name: "no matching certificate found",
+			Mock: func(t *testing.T, tt *certificateOpsTestCase) {
+				tt.CertClient.
+					On("AllWithOpts", tt.Ctx, hcloud.CertificateListOpts{
+						ListOpts: hcloud.ListOpts{LabelSelector: "key=value"},
+					}).
+					Return(nil, nil)
+			},
+			Perform: func(t *testing.T, tt *certificateOpsTestCase) {
+				cert, err := tt.CertOps.GetCertificateByLabel(tt.Ctx, "key=value")
+				assert.ErrorIs(t, err, hcops.ErrNotFound)
+				assert.Nil(t, cert)
+			},
+		},
+		{
+			Name: "more than one matching certificate found",
+			Mock: func(t *testing.T, tt *certificateOpsTestCase) {
+				tt.CertClient.
+					On("AllWithOpts", tt.Ctx, hcloud.CertificateListOpts{
+						ListOpts: hcloud.ListOpts{LabelSelector: "key=value"},
+					}).
+					Return([]*hcloud.Certificate{{ID: 1}, {ID: 2}}, nil)
+			},
+			Perform: func(t *testing.T, tt *certificateOpsTestCase) {
+				cert, err := tt.CertOps.GetCertificateByLabel(tt.Ctx, "key=value")
+				assert.ErrorIs(t, err, hcops.ErrNonUniqueResult)
+				assert.Nil(t, cert)
+			},
+		},
+		{
+			Name: "exactly one certificate found",
+			Mock: func(t *testing.T, tt *certificateOpsTestCase) {
+				tt.CertClient.
+					On("AllWithOpts", tt.Ctx, hcloud.CertificateListOpts{
+						ListOpts: hcloud.ListOpts{LabelSelector: "key=value"},
+					}).
+					Return([]*hcloud.Certificate{{ID: 1}}, nil)
+			},
+			Perform: func(t *testing.T, tt *certificateOpsTestCase) {
+				cert, err := tt.CertOps.GetCertificateByLabel(tt.Ctx, "key=value")
+				assert.NoError(t, err)
+				assert.Equal(t, &hcloud.Certificate{ID: 1}, cert)
+			},
+		},
+	}
+
+	runCertificateOpsTestCases(t, tests)
+}
+
+func TestCertificateOps_CreateManagedCertificate(t *testing.T) {
+	tests := []certificateOpsTestCase{
+		{
+			Name: "certificate creation fails",
+			Mock: func(t *testing.T, tt *certificateOpsTestCase) {
+				tt.CertClient.
+					On("CreateCertificate", tt.Ctx, mock.AnythingOfType("hcloud.CertificateCreateOpts")).
+					Return(nil, nil, errors.New("test error"))
+			},
+			Perform: func(t *testing.T, tt *certificateOpsTestCase) {
+				err := tt.CertOps.CreateManagedCertificate(
+					tt.Ctx,
+					"test-cert",
+					[]string{"example.com", "*.example.com"},
+					map[string]string{"key": "value"},
+				)
+				assert.Error(t, err)
+				assert.True(t, strings.HasSuffix(err.Error(), "test error"))
+			},
+		},
+		{
+			Name: "certificate already exists",
+			Mock: func(t *testing.T, tt *certificateOpsTestCase) {
+				err := hcloud.Error{Code: hcloud.ErrorCodeUniquenessError}
+				tt.CertClient.
+					On("CreateCertificate", tt.Ctx, mock.AnythingOfType("hcloud.CertificateCreateOpts")).
+					Return(nil, nil, err)
+			},
+			Perform: func(t *testing.T, tt *certificateOpsTestCase) {
+				err := tt.CertOps.CreateManagedCertificate(
+					tt.Ctx,
+					"test-cert",
+					[]string{"example.com", "*.example.com"},
+					map[string]string{"key": "value"},
+				)
+				assert.ErrorIs(t, err, hcops.ErrAlreadyExists)
+			},
+		},
+		{
+			Name: "certificate creation successful",
+			Mock: func(t *testing.T, tt *certificateOpsTestCase) {
+				res := hcloud.CertificateCreateResult{Certificate: &hcloud.Certificate{ID: 1}}
+				tt.CertClient.
+					On("CreateCertificate", tt.Ctx, hcloud.CertificateCreateOpts{
+						Name:        "test-cert",
+						Type:        hcloud.CertificateTypeManaged,
+						DomainNames: []string{"example.com", "*.example.com"},
+						Labels:      map[string]string{"key": "value"},
+					}).
+					Return(res, nil, nil)
+			},
+			Perform: func(t *testing.T, tt *certificateOpsTestCase) {
+				err := tt.CertOps.CreateManagedCertificate(
+					tt.Ctx,
+					"test-cert",
+					[]string{"example.com", "*.example.com"},
+					map[string]string{"key": "value"},
+				)
+				assert.NoError(t, err)
+			},
+		},
+	}
+
+	runCertificateOpsTestCases(t, tests)
+}
+
+type certificateOpsTestCase struct {
+	Name        string
+	Mock        func(t *testing.T, tt *certificateOpsTestCase)
+	Perform     func(t *testing.T, tt *certificateOpsTestCase)
+	Certificate *hcloud.Certificate
+	ClientErr   error
+
+	// Set in run before actual test execution
+	Ctx        context.Context
+	CertOps    *hcops.CertificateOps
+	CertClient *mocks.CertificateClient
+}
+
+func (tt *certificateOpsTestCase) run(t *testing.T) {
+	t.Helper()
+
+	tt.Ctx = context.Background()
+	tt.CertClient = &mocks.CertificateClient{}
+	tt.CertClient.Test(t)
+	tt.CertOps = &hcops.CertificateOps{CertClient: tt.CertClient}
+
+	if tt.Mock != nil {
+		tt.Mock(t, tt)
+	}
+	tt.Perform(t, tt)
+
+	tt.CertClient.AssertExpectations(t)
+}
+
+func runCertificateOpsTestCases(t *testing.T, tests []certificateOpsTestCase) {
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.Name, tt.run)
+	}
+}
diff --git a/internal/hcops/load_balancer_internal_test.go b/internal/hcops/load_balancer_internal_test.go
index 0252ec0d..72a2fa99 100644
--- a/internal/hcops/load_balancer_internal_test.go
+++ b/internal/hcops/load_balancer_internal_test.go
@@ -1,23 +1,35 @@
 package hcops
 
 import (
+	"fmt"
 	"testing"
 	"time"
 
 	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/annotation"
+	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/mocks"
 	"github.com/hetznercloud/hcloud-go/hcloud"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
 	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
 )
 
 func TestHCLBServiceOptsBuilder(t *testing.T) {
-	tests := []struct {
+	type testCase struct {
 		name               string
 		servicePort        v1.ServicePort
+		serviceUID         string
 		serviceAnnotations map[annotation.Name]interface{}
 		expectedAddOpts    hcloud.LoadBalancerAddServiceOpts
 		expectedUpdateOpts hcloud.LoadBalancerUpdateServiceOpts
-	}{
+		mock               func(t *testing.T, tt *testCase)
+
+		// Set during test setup
+		certClient *mocks.CertificateClient
+	}
+
+	tests := []testCase{
 		{
 			name:        "defaults",
 			servicePort: v1.ServicePort{Port: 80, NodePort: 8080},
@@ -108,6 +120,87 @@ func TestHCLBServiceOptsBuilder(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:        "add certificates by name",
+			servicePort: v1.ServicePort{Port: 83, NodePort: 8083},
+			serviceAnnotations: map[annotation.Name]interface{}{
+				annotation.LBSvcProtocol:         hcloud.LoadBalancerServiceProtocolHTTPS,
+				annotation.LBSvcHTTPCertificates: []*hcloud.Certificate{{Name: "cert-1"}, {Name: "cert-2"}},
+			},
+			mock: func(t *testing.T, tt *testCase) {
+				tt.certClient.
+					On("Get", mock.Anything, "cert-1").
+					Return(&hcloud.Certificate{ID: 1, Name: "cert-1"}, nil, nil)
+				tt.certClient.
+					On("Get", mock.Anything, "cert-2").
+					Return(&hcloud.Certificate{ID: 2, Name: "cert-2"}, nil, nil)
+			},
+			expectedAddOpts: hcloud.LoadBalancerAddServiceOpts{
+				ListenPort:      hcloud.Int(83),
+				DestinationPort: hcloud.Int(8083),
+				Protocol:        hcloud.LoadBalancerServiceProtocolHTTPS,
+				HTTP: &hcloud.LoadBalancerAddServiceOptsHTTP{
+					Certificates: []*hcloud.Certificate{{ID: 1}, {ID: 2}},
+				},
+				HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{
+					Protocol: hcloud.LoadBalancerServiceProtocolTCP,
+					Port:     hcloud.Int(8083),
+				},
+			},
+			expectedUpdateOpts: hcloud.LoadBalancerUpdateServiceOpts{
+				DestinationPort: hcloud.Int(8083),
+				Protocol:        hcloud.LoadBalancerServiceProtocolHTTPS,
+				HTTP: &hcloud.LoadBalancerUpdateServiceOptsHTTP{
+					Certificates: []*hcloud.Certificate{{ID: 1}, {ID: 2}},
+				},
+				HealthCheck: &hcloud.LoadBalancerUpdateServiceOptsHealthCheck{
+					Protocol: hcloud.LoadBalancerServiceProtocolTCP,
+					Port:     hcloud.Int(8083),
+				},
+			},
+		},
+		{
+			name:        "add managed certificate by service uid label",
+			servicePort: v1.ServicePort{Port: 83, NodePort: 8083},
+			serviceUID:  "some-service-uid",
+			serviceAnnotations: map[annotation.Name]interface{}{
+				annotation.LBSvcProtocol:                      hcloud.LoadBalancerServiceProtocolHTTPS,
+				annotation.LBSvcHTTPCertificateType:           "managed",
+				annotation.LBSvcHTTPManagedCertificateDomains: []string{"*.example.com", "example.com"},
+			},
+			mock: func(t *testing.T, tt *testCase) {
+				tt.certClient.
+					On("AllWithOpts", mock.Anything, hcloud.CertificateListOpts{
+						ListOpts: hcloud.ListOpts{
+							LabelSelector: fmt.Sprintf("%s=%s", LabelServiceUID, "some-service-uid"),
+						},
+					}).
+					Return([]*hcloud.Certificate{{ID: 1}}, nil, nil)
+			},
+			expectedAddOpts: hcloud.LoadBalancerAddServiceOpts{
+				ListenPort:      hcloud.Int(83),
+				DestinationPort: hcloud.Int(8083),
+				Protocol:        hcloud.LoadBalancerServiceProtocolHTTPS,
+				HTTP: &hcloud.LoadBalancerAddServiceOptsHTTP{
+					Certificates: []*hcloud.Certificate{{ID: 1}},
+				},
+				HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{
+					Protocol: hcloud.LoadBalancerServiceProtocolTCP,
+					Port:     hcloud.Int(8083),
+				},
+			},
+			expectedUpdateOpts: hcloud.LoadBalancerUpdateServiceOpts{
+				DestinationPort: hcloud.Int(8083),
+				Protocol:        hcloud.LoadBalancerServiceProtocolHTTPS,
+				HTTP: &hcloud.LoadBalancerUpdateServiceOptsHTTP{
+					Certificates: []*hcloud.Certificate{{ID: 1}},
+				},
+				HealthCheck: &hcloud.LoadBalancerUpdateServiceOptsHealthCheck{
+					Protocol: hcloud.LoadBalancerServiceProtocolTCP,
+					Port:     hcloud.Int(8083),
+				},
+			},
+		},
 		{
 			name:        "add health check with default protocol",
 			servicePort: v1.ServicePort{Port: 83, NodePort: 8083},
@@ -222,9 +315,21 @@ func TestHCLBServiceOptsBuilder(t *testing.T) {
 	for _, tt := range tests {
 		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
+			tt.certClient = &mocks.CertificateClient{}
+			tt.certClient.Test(t)
+
+			if tt.mock != nil {
+				tt.mock(t, &tt)
+			}
+
 			builder := &hclbServiceOptsBuilder{
-				Port:    tt.servicePort,
-				Service: &v1.Service{},
+				Port: tt.servicePort,
+				Service: &v1.Service{
+					ObjectMeta: metav1.ObjectMeta{
+						UID: types.UID(tt.serviceUID),
+					},
+				},
+				CertOps: &CertificateOps{CertClient: tt.certClient},
 			}
 			for k, v := range tt.serviceAnnotations {
 				if err := k.AnnotateService(builder.Service, v); err != nil {
diff --git a/internal/hcops/load_balancer_test.go b/internal/hcops/load_balancer_test.go
index 78b5ba82..8b623be9 100644
--- a/internal/hcops/load_balancer_test.go
+++ b/internal/hcops/load_balancer_test.go
@@ -1315,6 +1315,59 @@ func TestLoadBalancerOps_ReconcileHCLBServices(t *testing.T) {
 				assert.True(t, changed)
 			},
 		},
+		{
+			name:         "create managed certificate",
+			servicePorts: []v1.ServicePort{{Port: 443, NodePort: 8443}},
+			initialLB:    &hcloud.LoadBalancer{ID: 11},
+			serviceAnnotations: map[annotation.Name]interface{}{
+				annotation.LBSvcHTTPCertificateType:           hcloud.CertificateTypeManaged,
+				annotation.LBSvcHTTPManagedCertificateDomains: []string{"example.com", "*.example.com"},
+			},
+			serviceUID: "some service uid",
+			mock: func(t *testing.T, tt *LBReconcilementTestCase) {
+				cert := &hcloud.Certificate{ID: 1}
+
+				tt.fx.CertClient.
+					On("CreateCertificate", mock.Anything, hcloud.CertificateCreateOpts{
+						Name:        "ccm-managed-certificate-some service uid",
+						Type:        hcloud.CertificateTypeManaged,
+						DomainNames: []string{"example.com", "*.example.com"},
+						Labels:      map[string]string{hcops.LabelServiceUID: tt.serviceUID},
+					}).
+					Return(hcloud.CertificateCreateResult{Certificate: cert}, nil, nil)
+
+				tt.fx.CertClient.
+					On(
+						"AllWithOpts",
+						mock.Anything,
+						hcloud.CertificateListOpts{
+							ListOpts: hcloud.ListOpts{
+								LabelSelector: fmt.Sprintf("%s=%s", hcops.LabelServiceUID, tt.serviceUID),
+							},
+						}).
+					Return([]*hcloud.Certificate{cert}, nil, nil)
+
+				opts := hcloud.LoadBalancerAddServiceOpts{
+					Protocol:        hcloud.LoadBalancerServiceProtocolTCP,
+					ListenPort:      hcloud.Int(443),
+					DestinationPort: hcloud.Int(8443),
+					HTTP: &hcloud.LoadBalancerAddServiceOptsHTTP{
+						Certificates: []*hcloud.Certificate{cert},
+					},
+					HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{
+						Protocol: hcloud.LoadBalancerServiceProtocolTCP,
+						Port:     hcloud.Int(8443),
+					},
+				}
+				action := tt.fx.MockAddService(opts, tt.initialLB, nil)
+				tt.fx.MockWatchProgress(action, nil)
+			},
+			perform: func(t *testing.T, tt *LBReconcilementTestCase) {
+				changed, err := tt.fx.LBOps.ReconcileHCLBServices(tt.fx.Ctx, tt.initialLB, tt.service)
+				assert.NoError(t, err)
+				assert.True(t, changed)
+			},
+		},
 		{
 			name: "replace hc Load Balancer services",
 			serviceAnnotations: map[annotation.Name]interface{}{
diff --git a/internal/hcops/testing.go b/internal/hcops/testing.go
index c91d5438..17914ceb 100644
--- a/internal/hcops/testing.go
+++ b/internal/hcops/testing.go
@@ -39,7 +39,7 @@ func NewLoadBalancerOpsFixture(t *testing.T) *LoadBalancerOpsFixture {
 
 	fx.LBOps = &LoadBalancerOps{
 		LBClient:      fx.LBClient,
-		CertClient:    fx.CertClient,
+		CertOps:       &CertificateOps{CertClient: fx.CertClient},
 		ActionClient:  fx.ActionClient,
 		NetworkClient: fx.NetworkClient,
 	}