installer: install dns zone manager
diff --git a/charts/flux-bootstrap/templates/known-hosts.yaml b/charts/flux-bootstrap/templates/known-hosts.yaml
index dc40b39..072c0d6 100644
--- a/charts/flux-bootstrap/templates/known-hosts.yaml
+++ b/charts/flux-bootstrap/templates/known-hosts.yaml
@@ -3,5 +3,5 @@
 metadata:
   name: known-hosts
   namespace: {{ .Release.Namespace }}
-data:
-  known_hosts: {{ printf "%s %s" .Values.repositoryHost .Values.repositoryHostPublicKey | toYaml | indent 2 }}
+binaryData:
+  known_hosts: {{ .Values.repositoryHostPublicKeys | b64enc }}
diff --git a/charts/flux-bootstrap/values.yaml b/charts/flux-bootstrap/values.yaml
index 4b5fca1..8221196 100644
--- a/charts/flux-bootstrap/values.yaml
+++ b/charts/flux-bootstrap/values.yaml
@@ -4,7 +4,7 @@
   pullPolicy: Always
 repositoryAddress: ""
 repositoryHost: ""
-positoryHostPublicKey: ""
+positoryHostPublicKeys: []
 repository:
   address: ssh://git@<host>/<org>/<repository>
   branch: master
diff --git a/core/installer/app.go b/core/installer/app.go
index de294cb..3210667 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -114,6 +114,7 @@
 		CreateCSIDriverSMB(valuesTmpls, tmpls),
 		CreateResourceRendererController(valuesTmpls, tmpls),
 		CreateHeadscaleController(valuesTmpls, tmpls),
+		CreateDNSZoneManager(valuesTmpls, tmpls),
 	}
 	for _, a := range CreateStoreApps() {
 		ret = append(ret, a.App)
@@ -574,6 +575,24 @@
 	}
 }
 
+func CreateDNSZoneManager(fs embed.FS, tmpls *template.Template) App {
+	schema, err := fs.ReadFile("values-tmpl/dns-zone-controller.jsonschema")
+	if err != nil {
+		panic(err)
+	}
+	return App{
+		"dns-zone-manager",
+		[]string{"dns-zone-manager"},
+		[]*template.Template{
+			tmpls.Lookup("dns-zone-storage.yaml"),
+			tmpls.Lookup("coredns.yaml"),
+			tmpls.Lookup("dns-zone-controller.yaml"),
+		},
+		string(schema),
+		tmpls.Lookup("dns-zone-controller.md"),
+	}
+}
+
 type httpAppRepository struct {
 	apps []StoreApp
 }
diff --git a/core/installer/bootstrapper.go b/core/installer/bootstrapper.go
index 4ad44d7..953666c 100644
--- a/core/installer/bootstrapper.go
+++ b/core/installer/bootstrapper.go
@@ -66,21 +66,31 @@
 	if err := b.installFluxcd(ss, env.Name); err != nil {
 		return err
 	}
+	fmt.Println("Fluxcd installed")
 	repo, err := ss.GetRepo("config")
 	if err != nil {
+		fmt.Println("Failed to get config repo")
 		return err
 	}
 	repoIO := NewRepoIO(repo, ss.Signer)
+	fmt.Println("Configuring main repo")
 	if err := configureMainRepo(repoIO, env); err != nil {
 		return err
 	}
+	fmt.Println("Installing infrastructure services")
 	nsGen := NewPrefixGenerator(env.NamespacePrefix)
 	if err := b.installInfrastructureServices(repoIO, nsGen, b.ns, env); err != nil {
 		return err
 	}
+	fmt.Println("Installing DNS Zone Manager")
+	if err := b.installDNSZoneManager(ss, repoIO, nsGen, b.ns, env); err != nil {
+		return err
+	}
+	fmt.Println("Installing env manager")
 	if err := b.installEnvManager(ss, repoIO, nsGen, b.ns, env); err != nil {
 		return err
 	}
+	fmt.Println("Environment ready to use")
 	return nil
 }
 
@@ -304,7 +314,7 @@
 		return err
 	}
 	fmt.Println("Installing Flux")
-	ssPublic, err := ss.GetPublicKey()
+	ssPublicKeys, err := ss.GetPublicKeys()
 	if err != nil {
 		return err
 	}
@@ -312,7 +322,7 @@
 	if err := b.installFluxBootstrap(
 		ss.GetRepoAddress("config"),
 		host,
-		string(ssPublic),
+		ssPublicKeys,
 		string(keys.RawPrivateKey()),
 		envName,
 	); err != nil {
@@ -321,7 +331,7 @@
 	return nil
 }
 
-func (b Bootstrapper) installFluxBootstrap(repoAddr, repoHost, repoHostPubKey, privateKey, envName string) error {
+func (b Bootstrapper) installFluxBootstrap(repoAddr, repoHost string, repoHostPubKeys []string, privateKey, envName string) error {
 	config, err := b.ha.New(envName)
 	if err != nil {
 		return err
@@ -330,17 +340,21 @@
 	if err != nil {
 		return err
 	}
+	var lines []string
+	for _, k := range repoHostPubKeys {
+		lines = append(lines, fmt.Sprintf("%s %s", repoHost, k))
+	}
 	values := map[string]any{
 		"image": map[string]any{
 			"repository": "fluxcd/flux-cli",
 			"tag":        "v2.1.2",
 			"pullPolicy": "IfNotPresent",
 		},
-		"repositoryAddress":       repoAddr,
-		"repositoryHost":          repoHost,
-		"repositoryHostPublicKey": repoHostPubKey,
-		"privateKey":              privateKey,
-		"installationNamespace":   fmt.Sprintf("%s-flux", envName),
+		"repositoryAddress":        repoAddr,
+		"repositoryHost":           repoHost,
+		"repositoryHostPublicKeys": strings.Join(lines, "\n"),
+		"privateKey":               privateKey,
+		"installationNamespace":    fmt.Sprintf("%s-flux", envName),
 	}
 	installer := action.NewInstall(config)
 	installer.Namespace = envName
@@ -499,6 +513,46 @@
 	return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), derived.Values, derived)
 }
 
+func (b Bootstrapper) installDNSZoneManager(ss *soft.Client, repo RepoIO, nsGen NamespaceGenerator, nsCreator NamespaceCreator, env EnvConfig) error {
+	const (
+		volumeClaimName = "dns-zone-configs"
+		volumeMountPath = "/etc/pcloud/dns-zone-configs"
+	)
+	ns, err := nsGen.Generate("dns-zone-manager")
+	if err != nil {
+		return err
+	}
+	if err := nsCreator.Create(ns); err != nil {
+		return err
+	}
+	appRepo := NewInMemoryAppRepository(CreateAllApps())
+	{
+		app, err := appRepo.Find("dns-zone-manager")
+		if err != nil {
+			return err
+		}
+		derived := Derived{
+			Global: Values{
+				PCloudEnvName: env.Name,
+			},
+			Values: map[string]any{
+				"Volume": map[string]any{
+					"ClaimName": volumeClaimName,
+					"MountPath": volumeMountPath,
+					"Size":      "1Gi",
+				},
+			},
+			Release: Release{
+				Namespace: ns,
+			},
+		}
+		if err := repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), derived.Values, derived); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 type HelmActionConfigFactory interface {
 	New(namespace string) (*action.Configuration, error)
 }
diff --git a/core/installer/soft/client.go b/core/installer/soft/client.go
index 5686ba5..ae1206d 100644
--- a/core/installer/soft/client.go
+++ b/core/installer/soft/client.go
@@ -49,7 +49,7 @@
 		if err != nil {
 			return err
 		}
-		if _, err := client.GetPublicKey(); err != nil {
+		if _, err := client.GetPublicKeys(); err != nil {
 			return err
 		}
 		return nil
@@ -142,6 +142,7 @@
 }
 
 func CloneRepository(addr RepositoryAddress, signer ssh.Signer) (*Repository, error) {
+	fmt.Printf("Cloning repository: %s %s\n", addr.Addr, addr.Name)
 	c, err := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
 		URL: addr.FullAddress(),
 		Auth: &gitssh.PublicKeys{
@@ -209,14 +210,14 @@
 	}
 }
 
-func (ss *Client) GetPublicKey() ([]byte, error) {
-	var ret []byte
+func (ss *Client) GetPublicKeys() ([]string, error) {
+	var ret []string
 	config := &ssh.ClientConfig{
 		Auth: []ssh.AuthMethod{
 			ssh.PublicKeys(ss.Signer),
 		},
 		HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
-			ret = ssh.MarshalAuthorizedKey(key)
+			ret = append(ret, string(ssh.MarshalAuthorizedKey(key)))
 			return nil
 		},
 	}
diff --git a/core/installer/values-tmpl/core-auth.yaml b/core/installer/values-tmpl/core-auth.yaml
index f38c2d1..219b6c4 100644
--- a/core/installer/values-tmpl/core-auth.yaml
+++ b/core/installer/values-tmpl/core-auth.yaml
@@ -113,7 +113,7 @@
                     default_browser_return_url: https://accounts-ui.{{ .Global.Domain }}/
               registration:
                 lifespan: 10m
-                ui_url: https://accounts-ui.{{ .Global.Domain }}/registration
+                ui_url: https://accounts-ui.{{ .Global.Domain }}/register
                 after:
                   password:
                     hooks:
diff --git a/core/installer/values-tmpl/coredns-config.yaml b/core/installer/values-tmpl/coredns-config.yaml
deleted file mode 100644
index 65aba69..0000000
--- a/core/installer/values-tmpl/coredns-config.yaml
+++ /dev/null
@@ -1,53 +0,0 @@
----
-# Source: coredns/templates/configmap.yaml
-apiVersion: v1
-kind: ConfigMap
-metadata:
-  name: dodo-dns
-  namespace: dodo-core-coredns
-data:
-  dodo.conf: |-
-    t10.lekva.me:53 {
-        file /etc/dodo/t10.lekva.me.db
-        errors
-        log
-        health {
-            lameduck 5s
-        }
-        ready
-        cache 30
-        loop
-        reload
-        loadbalance
-    }
-
-    shve.li:53 {
-        file /etc/dodo/shve.li.db
-        dnssec {
-            key file Kshve.li.+013+55992
-        }
-        errors
-        log
-        health {
-            lameduck 5s
-        }
-        ready
-        cache 30
-        loop
-        reload
-        loadbalance
-    }
-
-  shve.li.db:     |
-      shve.li.   IN SOA ns1.shve.li. hostmaster.shve.li. 2015082541 7200 3600 1209600 3600
-      @ 10800 IN A 65.109.222.108
-      * 10800 IN CNAME shve.li.
-      p 10800 IN CNAME shve.li.
-      *.p 10800 IN A 10.1.0.1
-
-  t10.lekva.me.db:     |
-      t10.lekva.me.   IN SOA ns1.lekva.me. hostmaster.lekva.me. 2015082541 7200 3600 1209600 3600
-      * 10800 IN CNAME t10.lekva.me.
-      @ 10800 IN A 65.109.222.107
-      p 10800 IN CNAME t10.lekva.me.
-      *.p 10800 IN A 10.1.0.1
diff --git a/core/installer/values-tmpl/coredns.yaml b/core/installer/values-tmpl/coredns.yaml
index 4310c38..cfd57e6 100644
--- a/core/installer/values-tmpl/coredns.yaml
+++ b/core/installer/values-tmpl/coredns.yaml
@@ -1,398 +1,85 @@
-# apiVersion: helm.toolkit.fluxcd.io/v2beta1
-# kind: HelmRelease
-# metadata:
-#   name: rpuppy
-#   namespace: {{ .Release.Namespace }}
-# spec:
-#   chart:
-#     spec:
-#       chart: charts/rpuppy
-#       sourceRef:
-#         kind: GitRepository
-#         name: pcloud
-#         namespace: {{ .Global.Id }}
-#   interval: 1m0s
-#   values:
-# Default values for coredns.
-# This is a YAML-formatted file.
-# Declare variables to be passed into your templates.
-
-image:
-  repository: coredns/coredns
-  # Overrides the image tag whose default is the chart appVersion.
-  tag: ""
-  pullPolicy: IfNotPresent
-  ## Optionally specify an array of imagePullSecrets.
-  ## Secrets must be manually created in the namespace.
-  ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
-  ##
-  pullSecrets: []
-  # pullSecrets:
-  #   - name: myRegistryKeySecretName
-
-replicaCount: 1
-
-resources:
-  limits:
-    cpu: 100m
-    memory: 128Mi
-  requests:
-    cpu: 100m
-    memory: 128Mi
-
-rollingUpdate:
-  maxUnavailable: 1
-  maxSurge: 25%
-
-terminationGracePeriodSeconds: 30
-
-podAnnotations: {}
-#  cluster-autoscaler.kubernetes.io/safe-to-evict: "false"
-
-serviceType: "ClusterIP"
-
-prometheus:
-  service:
-    enabled: false
-    annotations:
-      prometheus.io/scrape: "true"
-      prometheus.io/port: "9153"
-  monitor:
-    enabled: false
-    additionalLabels: {}
-    namespace: ""
-    interval: ""
-
-service:
-# clusterIP: ""
-# clusterIPs: []
-# loadBalancerIP: ""
-# externalIPs: []
-# externalTrafficPolicy: ""
-# ipFamilyPolicy: ""
-  # The name of the Service
-  # If not set, a name is generated using the fullname template
+apiVersion: helm.toolkit.fluxcd.io/v2beta1
+kind: HelmRelease
+metadata:
   name: coredns
-  annotations: {}
-    # metallb.universe.tf/address-pool: local
-
-serviceAccount:
-  create: false
-  # The name of the ServiceAccount to use
-  # If not set and create is true, a name is generated using the fullname template
-  name: ""
-  annotations: {}
-
-rbac:
-  # If true, create & use RBAC resources
-  create: true
-  # If true, create and use PodSecurityPolicy
-  pspEnable: false
-  # The name of the ServiceAccount to use.
-  # If not set and create is true, a name is generated using the fullname template
-  # name:
-
-# isClusterService specifies whether chart should be deployed as cluster-service or normal k8s app.
-isClusterService: true
-
-# Optional priority class to be used for the coredns pods. Used for autoscaler if autoscaler.priorityClassName not set.
-priorityClassName: ""
-
-# Configure the pod level securityContext.
-podSecurityContext: {}
-
-# Configure SecurityContext for Pod.
-# Ensure that required linux capability to bind port number below 1024 is assigned (`CAP_NET_BIND_SERVICE`).
-securityContext:
-  capabilities:
-    add:
-      - NET_BIND_SERVICE
-
-# Default zone is what Kubernetes recommends:
-# https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#coredns-configmap-options
-servers:
-- zones:
-  - zone: .
-  port: 53
-  # If serviceType is nodePort you can specify nodePort here
-  # nodePort: 30053
-  # hostPort: 53
-  plugins:
-  - name: log
-  # Serves a /health endpoint on :8080, required for livenessProbe
-  - name: health
-    configBlock: |-
-      lameduck 5s
-  # Serves a /ready endpoint on :8181, required for readinessProbe
-  - name: ready
-
-# Complete example with all the options:
-# - zones:                 # the `zones` block can be left out entirely, defaults to "."
-#   - zone: hello.world.   # optional, defaults to "."
-#     scheme: tls://       # optional, defaults to "" (which equals "dns://" in CoreDNS)
-#   - zone: foo.bar.
-#     scheme: dns://
-#     use_tcp: true        # set this parameter to optionally expose the port on tcp as well as udp for the DNS protocol
-#                          # Note that this will not work if you are also exposing tls or grpc on the same server
-#   port: 12345            # optional, defaults to "" (which equals 53 in CoreDNS)
-#   plugins:               # the plugins to use for this server block
-#   - name: kubernetes     # name of plugin, if used multiple times ensure that the plugin supports it!
-#     parameters: foo bar  # list of parameters after the plugin
-#     configBlock: |-      # if the plugin supports extra block style config, supply it here
-#       hello world
-#       foo bar
-
-# Extra configuration that is applied outside of the default zone block.
-# Example to include additional config files, which may come from extraVolumes:
-# extraConfig:
-#   import:
-#     parameters: /opt/coredns/*.conf
-extraConfig:
-  import:
-    parameters: /etc/dodo/dodo.conf
-
-# To use the livenessProbe, the health plugin needs to be enabled in CoreDNS' server config
-livenessProbe:
-  enabled: true
-  initialDelaySeconds: 60
-  periodSeconds: 10
-  timeoutSeconds: 5
-  failureThreshold: 5
-  successThreshold: 1
-# To use the readinessProbe, the ready plugin needs to be enabled in CoreDNS' server config
-readinessProbe:
-  enabled: true
-  initialDelaySeconds: 30
-  periodSeconds: 10
-  timeoutSeconds: 5
-  failureThreshold: 5
-  successThreshold: 1
-
-# expects input structure as per specification https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.11/#affinity-v1-core
-# for example:
-#   affinity:
-#     nodeAffinity:
-#      requiredDuringSchedulingIgnoredDuringExecution:
-#        nodeSelectorTerms:
-#        - matchExpressions:
-#          - key: foo.bar.com/role
-#            operator: In
-#            values:
-#            - master
-affinity: {}
-
-# expects input structure as per specification https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#topologyspreadconstraint-v1-core
-# and supports Helm templating.
-# For example:
-#   topologySpreadConstraints:
-#     - labelSelector:
-#         matchLabels:
-#           app.kubernetes.io/name: '{{ template "coredns.name" . }}'
-#           app.kubernetes.io/instance: '{{ .Release.Name }}'
-#       topologyKey: topology.kubernetes.io/zone
-#       maxSkew: 1
-#       whenUnsatisfiable: ScheduleAnyway
-#     - labelSelector:
-#         matchLabels:
-#           app.kubernetes.io/name: '{{ template "coredns.name" . }}'
-#           app.kubernetes.io/instance: '{{ .Release.Name }}'
-#       topologyKey: kubernetes.io/hostname
-#       maxSkew: 1
-#       whenUnsatisfiable: ScheduleAnyway
-topologySpreadConstraints: []
-
-# Node labels for pod assignment
-# Ref: https://kubernetes.io/docs/user-guide/node-selection/
-nodeSelector: {}
-
-# expects input structure as per specification https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.11/#toleration-v1-core
-# for example:
-#   tolerations:
-#   - key: foo.bar.com/role
-#     operator: Equal
-#     value: master
-#     effect: NoSchedule
-tolerations: []
-
-# https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
-podDisruptionBudget: {}
-
-# configure custom zone files as per https://coredns.io/2017/05/08/custom-dns-entries-for-kubernetes/
-zoneFiles: []
- # - filename: bar.ge.db
- #   domain: bar.ge
- #   contents: |
- #     bar.ge.   IN SOA sns.dns.icann.com. noc.dns.icann.com. 2015082541 7200 3600 1209600 3600
- #     * 10800 IN CNAME bar.ge.
- #     bar.ge.   IN A   192.168.99.102
- #     *.t1 10800 IN A 65.109.222.106
- #     *.t2 10800 IN A 65.109.222.107
- #     *.t3 10800 IN A 65.109.222.108
- #     *.t4 10800 IN A 65.109.222.109
- #     *.t5 10800 IN A 65.109.222.100
- #     @ 10800 IN A 65.109.222.106
- #     www 10800 IN CNAME bar.ge.
-
-# optional array of sidecar containers
-extraContainers: []
-# - name: rename-keys
-#   image: giolekva/rename-keys:latest
-#   imagePullPolicy: Always
-#   command: ["/usr/bin/rename-keys.sh"]
-#   volumeMounts:
-#     - name: dodo
-#       mountPath: /etc/dodo
-# optional array of extra volumes to create
-extraVolumes:
-- name: keys
-  persistentVolumeClaim:
-    claimName: keys
-# - name: dodo
-#   configMap:
-#     name: dodo-dns
-# - name: some-volume-name
-#   emptyDir: {}
-# optional array of mount points for extraVolumes
-extraVolumeMounts:
-- name: keys
-  mountPath: /etc/dodo
-# - name: dodo
-#   mountPath: /etc/dodo
-# - name: some-volume-name
-#   mountPath: /etc/wherever
-
-# optional array of secrets to mount inside coredns container
-# possible usecase: need for secure connection with etcd backend
-extraSecrets: []
-# - name: etcd-client-certs
-#   mountPath: /etc/coredns/tls/etcd
-#   defaultMode: 420
-# - name: some-fancy-secret
-#   mountPath: /etc/wherever
-#   defaultMode: 440
-
-# To support legacy deployments using CoreDNS with the "k8s-app: kube-dns" label selectors.
-# See https://github.com/coredns/helm/blob/master/charts/coredns/README.md#adopting-existing-coredns-resources
-# k8sAppLabelOverride: "kube-dns"
-
-# Custom labels to apply to Deployment, Pod, Configmap, Service, ServiceMonitor. Including autoscaler if enabled.
-customLabels: {}
-
-# Custom annotations to apply to Deployment, Pod, Configmap, Service, ServiceMonitor. Including autoscaler if enabled.
-customAnnotations: {}
-
-## Alternative configuration for HPA deployment if wanted
-## Create HorizontalPodAutoscaler object.
-##
-# hpa:
-#   enabled: false
-#   minReplicas: 1
-#   maxReplicas: 10
-#   metrics:
-#    metrics:
-#    - type: Resource
-#      resource:
-#        name: memory
-#        target:
-#          type: Utilization
-#          averageUtilization: 60
-#    - type: Resource
-#      resource:
-#        name: cpu
-#        target:
-#          type: Utilization
-#          averageUtilization: 60
-
-hpa:
-  enabled: false
-  minReplicas: 1
-  maxReplicas: 2
-  metrics: []
-
-## Configue a cluster-proportional-autoscaler for coredns
-# See https://github.com/kubernetes-incubator/cluster-proportional-autoscaler
-autoscaler:
-  # Enabled the cluster-proportional-autoscaler
-  enabled: false
-
-  # Number of cores in the cluster per coredns replica
-  coresPerReplica: 256
-  # Number of nodes in the cluster per coredns replica
-  nodesPerReplica: 16
-  # Min size of replicaCount
-  min: 0
-  # Max size of replicaCount (default of 0 is no max)
-  max: 0
-  # Whether to include unschedulable nodes in the nodes/cores calculations - this requires version 1.8.0+ of the autoscaler
-  includeUnschedulableNodes: false
-  # If true does not allow single points of failure to form
-  preventSinglePointFailure: true
-
-  # Annotations for the coredns proportional autoscaler pods
-  podAnnotations: {}
-
-  ## Optionally specify some extra flags to pass to cluster-proprtional-autoscaler.
-  ## Useful for e.g. the nodelabels flag.
-  # customFlags:
-  #   - --nodelabels=topology.kubernetes.io/zone=us-east-1a
-
-  image:
-    repository: registry.k8s.io/cpa/cluster-proportional-autoscaler
-    tag: "1.8.5"
-    pullPolicy: IfNotPresent
-    ## Optionally specify an array of imagePullSecrets.
-    ## Secrets must be manually created in the namespace.
-    ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
-    ##
-    pullSecrets: []
-    # pullSecrets:
-    #   - name: myRegistryKeySecretName
-
-  # Optional priority class to be used for the autoscaler pods. priorityClassName used if not set.
-  priorityClassName: ""
-
-  # expects input structure as per specification https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.11/#affinity-v1-core
-  affinity: {}
-
-  # Node labels for pod assignment
-  # Ref: https://kubernetes.io/docs/user-guide/node-selection/
-  nodeSelector: {}
-
-  # expects input structure as per specification https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.11/#toleration-v1-core
-  tolerations: []
-
-  # resources for autoscaler pod
-  resources:
-    requests:
-      cpu: "20m"
-      memory: "10Mi"
-    limits:
-      cpu: "20m"
-      memory: "10Mi"
-
-  # Options for autoscaler configmap
-  configmap:
-    ## Annotations for the coredns-autoscaler configmap
-    # i.e. strategy.spinnaker.io/versioned: "false" to ensure configmap isn't renamed
-    annotations: {}
-
-  # Enables the livenessProbe for cluster-proportional-autoscaler - this requires version 1.8.0+ of the autoscaler
-  livenessProbe:
-    enabled: true
-    initialDelaySeconds: 10
-    periodSeconds: 5
-    timeoutSeconds: 5
-    failureThreshold: 3
-    successThreshold: 1
-
-  # optional array of sidecar containers
-  extraContainers: []
-  # - name: some-container-name
-  #   image: some-image:latest
-  #   imagePullPolicy: Always
-
-deployment:
-  enabled: true
-  name: ""
-  ## Annotations for the coredns deployment
-  annotations: {}
+  namespace: {{ .Release.Namespace }}
+spec:
+  chart:
+    spec:
+      chart: charts/coredns
+      sourceRef:
+        kind: GitRepository
+        name: pcloud
+        namespace: {{ .Global.PCloudEnvName }}
+  interval: 1m0s
+  values:
+    image:
+      repository: coredns/coredns
+      tag: 1.11.1
+      pullPolicy: IfNotPresent
+    replicaCount: 1
+    resources:
+      limits:
+        cpu: 100m
+        memory: 128Mi
+      requests:
+        cpu: 100m
+        memory: 128Mi
+    rollingUpdate:
+      maxUnavailable: 1
+      maxSurge: 25%
+    terminationGracePeriodSeconds: 30
+    serviceType: "ClusterIP"
+    service:
+      name: coredns
+    serviceAccount:
+      create: false
+    rbac:
+      create: true
+      pspEnable: false
+    isClusterService: true
+    securityContext:
+      capabilities:
+        add:
+          - NET_BIND_SERVICE
+    servers:
+    - zones:
+      - zone: .
+      port: 53
+      plugins:
+      - name: log
+      - name: health
+        configBlock: |-
+          lameduck 5s
+      - name: ready
+    extraConfig:
+      import:
+        parameters: {{ .Values.Volume.MountPath }}/coredns.conf
+    extraVolumes:
+    - name: zone-configs
+      persistentVolumeClaim:
+        claimName: {{ .Values.Volume.ClaimName }}
+    extraVolumeMounts:
+    - name: zone-configs
+      mountPath: {{ .Values.Volume.MountPath}}
+    livenessProbe:
+      enabled: true
+      initialDelaySeconds: 60
+      periodSeconds: 10
+      timeoutSeconds: 5
+      failureThreshold: 5
+      successThreshold: 1
+    readinessProbe:
+      enabled: true
+      initialDelaySeconds: 30
+      periodSeconds: 10
+      timeoutSeconds: 5
+      failureThreshold: 5
+      successThreshold: 1
+    zoneFiles: []
+    hpa:
+      enabled: false
+    autoscaler:
+      enabled: false
+    deployment:
+      enabled: true
diff --git a/core/installer/values-tmpl/dns-zone-controller.jsonschema b/core/installer/values-tmpl/dns-zone-controller.jsonschema
new file mode 100644
index 0000000..7a71483
--- /dev/null
+++ b/core/installer/values-tmpl/dns-zone-controller.jsonschema
@@ -0,0 +1,14 @@
+{
+  "type": "object",
+  "properties": {
+    "Volume": {
+      "type": "object",
+	  "properties": {
+		"ClaimName": { "type": "string" },
+		"MountPath": { "type": "string" }
+	  },
+	  "additionalProperties": false
+	}
+  },
+  "additionalProperties": false
+}
diff --git a/core/installer/values-tmpl/dns-zone-controller.md b/core/installer/values-tmpl/dns-zone-controller.md
new file mode 100644
index 0000000..a6abe91
--- /dev/null
+++ b/core/installer/values-tmpl/dns-zone-controller.md
@@ -0,0 +1 @@
+Sets up DNS zone controller to automatically generate zone files of registered domains.
diff --git a/core/installer/values-tmpl/dns-zone-controller.yaml b/core/installer/values-tmpl/dns-zone-controller.yaml
new file mode 100644
index 0000000..0df7edb
--- /dev/null
+++ b/core/installer/values-tmpl/dns-zone-controller.yaml
@@ -0,0 +1,23 @@
+apiVersion: helm.toolkit.fluxcd.io/v2beta1
+kind: HelmRelease
+metadata:
+  name: dns-zone-controller
+  namespace: {{ .Release.Namespace }}
+spec:
+  chart:
+    spec:
+      chart: charts/dns-ns-controller
+      sourceRef:
+        kind: GitRepository
+        name: pcloud
+        namespace: {{ .Global.PCloudEnvName }}
+  interval: 1m0s
+  values:
+    image:
+      repository: giolekva/dns-ns-controller
+      tag: latest
+      pullPolicy: Always
+    installCRDs: true
+    volume:
+      claimName: {{ .Values.Volume.ClaimName }}
+      mountPath: {{ .Values.Volume.MountPath }}
diff --git a/core/installer/values-tmpl/dns-zone-storage.yaml b/core/installer/values-tmpl/dns-zone-storage.yaml
new file mode 100644
index 0000000..72b7848
--- /dev/null
+++ b/core/installer/values-tmpl/dns-zone-storage.yaml
@@ -0,0 +1,18 @@
+apiVersion: helm.toolkit.fluxcd.io/v2beta1
+kind: HelmRelease
+metadata:
+  name: dns-zone-storage
+  namespace: {{ .Release.Namespace }}
+spec:
+  chart:
+    spec:
+      chart: charts/volumes
+      sourceRef:
+        kind: GitRepository
+        name: pcloud
+        namespace: {{ .Global.PCloudEnvName }}
+  interval: 10m0s
+  values:
+    name: {{ .Values.Volume.ClaimName }}
+    size: {{ .Values.Volume.Size }}
+    accessMode: ReadWriteMany
diff --git a/core/installer/values-tmpl/ingress-private.yaml b/core/installer/values-tmpl/ingress-private.yaml
index e1870af..fb15cd4 100644
--- a/core/installer/values-tmpl/ingress-private.yaml
+++ b/core/installer/values-tmpl/ingress-private.yaml
@@ -28,3 +28,5 @@
         controllerValue: k8s.io/{{ .Global.Id }}-ingress-private
       extraArgs:
         default-ssl-certificate: "{{ .Global.Id }}-ingress-private/cert-wildcard.p.{{ .Global.Domain }}"
+      admissionWebhooks:
+        enabled: false
diff --git a/core/installer/values-tmpl/ingress-public.yaml b/core/installer/values-tmpl/ingress-public.yaml
index 94773e8..25379d4 100644
--- a/core/installer/values-tmpl/ingress-public.yaml
+++ b/core/installer/values-tmpl/ingress-public.yaml
@@ -15,10 +15,12 @@
   values:
     fullnameOverride: {{ .Global.PCloudEnvName }}-ingress-public
     controller:
+      kind: DaemonSet
+      hostNetwork: true
+      hostPort:
+        enabled: true
       service:
-        type: LoadBalancer
-        annotations:
-          metallb.universe.tf/loadBalancerIPs: {{ .Values.IngressPublicIP }}
+        enabled: false
       ingressClassByName: true
       ingressClassResource:
         name: {{ .Global.PCloudEnvName }}-ingress-public
@@ -27,3 +29,7 @@
         controllerValue: k8s.io/{{ .Global.PCloudEnvName }}-ingress-public
       config:
         proxy-body-size: 100M # TODO(giolekva): configurable
+    tcp:
+      "53": "{{ .Global.PCloudEnvName }}-dns-zone-manager/coredns:53"
+    udp:
+      "53": "{{ .Global.PCloudEnvName }}-dns-zone-manager/coredns:53"
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index 2c5d332..06ac89f 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -1,6 +1,7 @@
 package welcome
 
 import (
+	"bytes"
 	"embed"
 	"encoding/base64"
 	"encoding/json"
@@ -332,7 +333,7 @@
 		}
 	}
 	{
-		ssPubKey, err := ssClient.GetPublicKey()
+		ssPublicKeys, err := ssClient.GetPublicKeys()
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
@@ -342,7 +343,7 @@
 			req,
 			strings.Split(ssClient.Addr, ":")[0],
 			keys,
-			ssPubKey,
+			ssPublicKeys,
 		); err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
@@ -628,7 +629,7 @@
 	req createEnvReq,
 	repoHost string,
 	keys *keygen.KeyPair,
-	pcloudRepoPublicKey []byte,
+	configRepoPublicKeys []string,
 ) error {
 	kust, err := repoIO.ReadKustomization("environments/kustomization.yaml")
 	if err != nil {
@@ -639,6 +640,10 @@
 	if err != nil {
 		return err
 	}
+	var knownHosts bytes.Buffer
+	for _, key := range configRepoPublicKeys {
+		fmt.Fprintf(&knownHosts, "%s %s\n", repoHost, key)
+	}
 	for _, tmpl := range tmpls.Templates() {
 		dstPath := path.Join("environments", req.Name, tmpl.Name())
 		dst, err := repoIO.Writer(dstPath)
@@ -646,13 +651,14 @@
 			return err
 		}
 		defer dst.Close()
+
 		if err := tmpl.Execute(dst, map[string]string{
 			"Name":       req.Name,
 			"PrivateKey": base64.StdEncoding.EncodeToString(keys.RawPrivateKey()),
 			"PublicKey":  base64.StdEncoding.EncodeToString(keys.RawAuthorizedKey()),
 			"RepoHost":   repoHost,
 			"RepoName":   "config",
-			"KnownHosts": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s %s", repoHost, pcloudRepoPublicKey))),
+			"KnownHosts": base64.StdEncoding.EncodeToString(knownHosts.Bytes()),
 		}); err != nil {
 			return err
 		}