DNS: run separate CoreDNS instance for each PCloud env.

Previously shared CoreDNS instance was used to handle all domains. This has multiple downsides, most important which is security. For example DNS-Sec keys of all domains were persisted on the same shared volume. Also key itself was generated by PCloud env-manager as part of bootstrapping new env. Which is counter to the main aspirations of PCloud, that environment internal private data must not leak outside of the environment.

With new approach implemented in this change, environment starts up it’s own CoreDNS and DNS record manager servers. Manager generates dns-sec keys internally and only exposes public information to the outside world. PCloud infrastructure runes another instance of CoreDNS which acts as a proxy service forwarding requests to individual environments based an requested domain.

This simplifies DNS based TLS challenge solvers, as private certificate issuer of each env will point directly to the DNS record manager of the same environment.

Change-Id: Ifb0f36d2a133e3b53da22030cc7d6b9099136b3d
diff --git a/charts/certificate-issuer-private/templates/issuer.yaml b/charts/certificate-issuer-private/templates/issuer.yaml
index e05ff82..bc29bed 100644
--- a/charts/certificate-issuer-private/templates/issuer.yaml
+++ b/charts/certificate-issuer-private/templates/issuer.yaml
@@ -15,5 +15,5 @@
           groupName: dodo.cloud # TODO(gio): configurable, this and one below
           solverName: dns-resolver-pcloud
           config:
-            apiConfigMapName: {{ .Values.apiConfigMap.name }}
-            apiConfigMapNamespace: {{ .Values.apiConfigMap.namespace }}
+            createTXTAddr: {{ .Values.config.createTXTAddr }}
+            deleteTXTAddr: {{ .Values.config.deleteTXTAddr }}
diff --git a/charts/certificate-issuer-private/values.yaml b/charts/certificate-issuer-private/values.yaml
index e332987..fd0d9bd 100644
--- a/charts/certificate-issuer-private/values.yaml
+++ b/charts/certificate-issuer-private/values.yaml
@@ -4,6 +4,6 @@
   contactEmail: admin@example.com
   gandiAPIToken: token
   domain: p.example.com
-apiConfigMap:
-  name: api-config
-  namespace: pcloud-dns-zone-manager
+config:
+  createTXTAddr: http://10.44.0.1/create-txt-record
+  deleteTXTAddr: http://10.44.0.1/delete-txt-record
diff --git a/charts/dns-api/.helmignore b/charts/dns-api/.helmignore
new file mode 100644
index 0000000..0e8a0eb
--- /dev/null
+++ b/charts/dns-api/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/charts/dns-api/Chart.yaml b/charts/dns-api/Chart.yaml
new file mode 100644
index 0000000..1c00bce
--- /dev/null
+++ b/charts/dns-api/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: dns-api
+description: A Helm chart for dns-api
+type: application
+version: 0.0.1
+appVersion: "0.0.1"
diff --git a/charts/dns-api/templates/install.yaml b/charts/dns-api/templates/install.yaml
new file mode 100644
index 0000000..33d9a2d
--- /dev/null
+++ b/charts/dns-api/templates/install.yaml
@@ -0,0 +1,72 @@
+# TODO(gio): we'll need to separate intra-dns service and one accessible from k8s cluster
+apiVersion: v1
+kind: Service
+metadata:
+  name: dns-api
+  namespace: {{ .Release.Namespace }}
+  {{- if .Values.service.annotations }}
+  annotations:
+    {{- toYaml .Values.service.annotations | nindent 4 }}
+  {{- end }}
+spec:
+  type: {{ .Values.service.type }}
+  selector:
+    app: dns-api
+  ports:
+  - name: http
+    port: 80
+    targetPort: http
+    protocol: TCP
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: dns-api
+  namespace: {{ .Release.Namespace }}
+spec:
+  selector:
+    matchLabels:
+      app: dns-api
+  replicas: 1
+  template:
+    metadata:
+      labels:
+        app: dns-api
+    spec:
+      containers:
+      - name: dns-api
+        image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
+        imagePullPolicy: {{ .Values.image.pullPolicy }}
+        ports:
+        - name: http
+          containerPort: 8080
+          protocol: TCP
+        command:
+        - dns-api
+        - --port=8080
+        - --root-dir={{ .Values.volume.mountPath }}
+        - --config={{ .Values.config }}
+        - --db={{ .Values.db }}
+        - --zone={{ .Values.zone }}
+        - --public-ip={{ .Values.publicIP }}
+        - --private-ip={{ .Values.privateIP }}
+        - --nameserver-ip={{ .Values.nameserverIP }}
+        volumeMounts:
+        - name: data
+          mountPath: {{ .Values.volume.mountPath }}
+        resources:
+          requests:
+            memory: "10Mi"
+            cpu: "10m"
+          limits:
+            memory: "20Mi"
+            cpu: "100m"
+      volumes:
+      - name: data
+        persistentVolumeClaim:
+          claimName: {{ .Values.volume.claimName }}
+      tolerations:
+      - key: "pcloud"
+        operator: "Equal"
+        value: "role"
+        effect: "NoSchedule"
diff --git a/charts/dns-api/values.yaml b/charts/dns-api/values.yaml
new file mode 100644
index 0000000..6c0f3d7
--- /dev/null
+++ b/charts/dns-api/values.yaml
@@ -0,0 +1,16 @@
+image:
+  repository: giolekva/dns-api
+  tag: latest
+  pullPolicy: Always
+config: "coredns.conf"
+db: "records.db"
+zone: "example.com"
+publicIP: "1.2.3.4,5.6.7.8"
+privateIP: "10.0.1.0"
+nameserverIP: "4.3.2.1,8.7.6.5"
+volume:
+  claimName: "data"
+  mountPath: "/pcloud"
+service:
+  type: "ClusterIP"
+  annotations: {}
diff --git a/charts/dns-ns-controller/Chart.yaml b/charts/dns-ns-controller/Chart.yaml
deleted file mode 100644
index 82e665e..0000000
--- a/charts/dns-ns-controller/Chart.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-apiVersion: v2
-name: dns-ns-controller
-description: A Helm chart for dns-ns-controller
-type: application
-version: 0.0.1
-appVersion: "0.0.1"
diff --git a/charts/dns-ns-controller/templates/api-config.yaml b/charts/dns-ns-controller/templates/api-config.yaml
deleted file mode 100644
index f902fd8..0000000
--- a/charts/dns-ns-controller/templates/api-config.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-apiVersion: v1
-kind: ConfigMap
-metadata:
-  name: {{ .Values.apiConfigMapName }}
-  namespace: {{ .Release.Namespace }}
-data:
-  createTXTAddr: "http://ns-controller.{{ .Release.Namespace }}.svc.cluster.local/create-txt-record"
-  deleteTXTAddr: "http://ns-controller.{{ .Release.Namespace }}.svc.cluster.local/delete-txt-record"
diff --git a/charts/dns-ns-controller/templates/crds.yaml b/charts/dns-ns-controller/templates/crds.yaml
deleted file mode 100644
index b48b418..0000000
--- a/charts/dns-ns-controller/templates/crds.yaml
+++ /dev/null
@@ -1,69 +0,0 @@
-{{ if .Values.installCRDs }}
-apiVersion: apiextensions.k8s.io/v1
-kind: CustomResourceDefinition
-metadata:
-  annotations:
-    controller-gen.kubebuilder.io/version: v0.9.2
-  creationTimestamp: null
-  name: dnszones.dodo.cloud.dodo.cloud
-spec:
-  group: dodo.cloud.dodo.cloud
-  names:
-    kind: DNSZone
-    listKind: DNSZoneList
-    plural: dnszones
-    singular: dnszone
-  scope: Namespaced
-  versions:
-  - name: v1
-    schema:
-      openAPIV3Schema:
-        description: DNSZone is the Schema for the dnszones API
-        properties:
-          apiVersion:
-            description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
-            type: string
-          kind:
-            description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
-            type: string
-          metadata:
-            type: object
-          spec:
-            description: DNSZoneSpec defines the desired state of DNSZone
-            properties:
-              dnssec:
-                properties:
-                  enabled:
-                    type: boolean
-                  secretName:
-                    type: string
-                type: object
-              nameservers:
-                items:
-                  type: string
-                type: array
-              privateIP:
-                type: string
-              publicIPs:
-                items:
-                  type: string
-                type: array
-              zone:
-                description: Foo is an example field of DNSZone. Edit dnszone_types.go to remove/update
-                type: string
-            type: object
-          status:
-            description: DNSZoneStatus defines the observed state of DNSZone
-            properties:
-              ready:
-                description: 'INSERT ADDITIONAL STATUS FIELD - define observed state of cluster Important: Run "make" to regenerate code after modifying this file'
-                type: boolean
-              recordsToPublish:
-                type: string
-            type: object
-        type: object
-    served: true
-    storage: true
-    subresources:
-      status: {}
-{{ end }}
diff --git a/charts/dns-ns-controller/templates/install.yaml b/charts/dns-ns-controller/templates/install.yaml
deleted file mode 100644
index d6f9d36..0000000
--- a/charts/dns-ns-controller/templates/install.yaml
+++ /dev/null
@@ -1,311 +0,0 @@
-apiVersion: v1
-kind: ServiceAccount
-metadata:
-  name: ns-controller-controller-manager
-  namespace: {{ .Release.Namespace }}
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: Role
-metadata:
-  name: ns-controller-leader-election-role
-  namespace: {{ .Release.Namespace }}
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - configmaps
-  verbs:
-  - get
-  - list
-  - watch
-  - create
-  - update
-  - patch
-  - delete
-- apiGroups:
-  - coordination.k8s.io
-  resources:
-  - leases
-  verbs:
-  - get
-  - list
-  - watch
-  - create
-  - update
-  - patch
-  - delete
-- apiGroups:
-  - ""
-  resources:
-  - events
-  verbs:
-  - create
-  - patch
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  creationTimestamp: null
-  name: ns-controller-manager-role
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - secrets
-  verbs:
-  - create
-  - delete
-  - get
-  - list
-  - patch
-  - update
-  - watch
-- apiGroups:
-  - dodo.cloud.dodo.cloud
-  resources:
-  - dnszones
-  verbs:
-  - create
-  - delete
-  - get
-  - list
-  - patch
-  - update
-  - watch
-- apiGroups:
-  - dodo.cloud.dodo.cloud
-  resources:
-  - dnszones/finalizers
-  verbs:
-  - update
-- apiGroups:
-  - dodo.cloud.dodo.cloud
-  resources:
-  - dnszones/status
-  verbs:
-  - get
-  - patch
-  - update
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  name: ns-controller-metrics-reader
-rules:
-- nonResourceURLs:
-  - /metrics
-  verbs:
-  - get
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  name: ns-controller-proxy-role
-rules:
-- apiGroups:
-  - authentication.k8s.io
-  resources:
-  - tokenreviews
-  verbs:
-  - create
-- apiGroups:
-  - authorization.k8s.io
-  resources:
-  - subjectaccessreviews
-  verbs:
-  - create
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: RoleBinding
-metadata:
-  name: ns-controller-leader-election-rolebinding
-  namespace: {{ .Release.Namespace }}
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: Role
-  name: ns-controller-leader-election-role
-subjects:
-- kind: ServiceAccount
-  name: ns-controller-controller-manager
-  namespace: {{ .Release.Namespace }}
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
-  name: ns-controller-manager-rolebinding
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: ClusterRole
-  name: ns-controller-manager-role
-subjects:
-- kind: ServiceAccount
-  name: ns-controller-controller-manager
-  namespace: {{ .Release.Namespace }}
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
-  name: ns-controller-proxy-rolebinding
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: ClusterRole
-  name: ns-controller-proxy-role
-subjects:
-- kind: ServiceAccount
-  name: ns-controller-controller-manager
-  namespace: {{ .Release.Namespace }}
----
-apiVersion: v1
-data:
-  controller_manager_config.yaml: |
-    apiVersion: controller-runtime.sigs.k8s.io/v1alpha1
-    kind: ControllerManagerConfig
-    health:
-      healthProbeBindAddress: :8081
-    metrics:
-      bindAddress: 127.0.0.1:8080
-    webhook:
-      port: 9443
-    leaderElection:
-      leaderElect: true
-      resourceName: c1db6143.dodo.cloud
-    # leaderElectionReleaseOnCancel defines if the leader should step down volume
-    # when the Manager ends. This requires the binary to immediately end when the
-    # Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
-    # speeds up voluntary leader transitions as the new leader don't have to wait
-    # LeaseDuration time first.
-    # In the default scaffold provided, the program ends immediately after
-    # the manager stops, so would be fine to enable this option. However,
-    # if you are doing or is intended to do any operation such as perform cleanups
-    # after the manager stops then its usage might be unsafe.
-    # leaderElectionReleaseOnCancel: true
-kind: ConfigMap
-metadata:
-  name: ns-controller-manager-config
-  namespace: {{ .Release.Namespace }}
----
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    control-plane: controller-manager
-  name: ns-controller-controller-manager-metrics-service
-  namespace: {{ .Release.Namespace }}
-spec:
-  ports:
-  - name: https
-    port: 8443
-    protocol: TCP
-    targetPort: https
-  selector:
-    control-plane: controller-manager
----
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    control-plane: controller-manager
-  name: ns-controller # TODO(gio): move to _helpers
-  namespace: {{ .Release.Namespace }}
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    control-plane: controller-manager
----
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    control-plane: controller-manager
-  name: ns-controller-controller-manager
-  namespace: {{ .Release.Namespace }}
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      control-plane: controller-manager
-  template:
-    metadata:
-      annotations:
-        kubectl.kubernetes.io/default-container: manager
-      labels:
-        control-plane: controller-manager
-    spec:
-      volumes:
-      - name: zone-configs
-        persistentVolumeClaim:
-          claimName: {{ .Values.volume.claimName }}
-      containers:
-      - args:
-        - --secure-listen-address=0.0.0.0:8443
-        - --upstream=http://127.0.0.1:8080/
-        - --logtostderr=true
-        - --v=0
-        image: {{ .Values.kubeRBACProxy.image.repository }}:{{ .Values.kubeRBACProxy.image.tag }}
-        name: kube-rbac-proxy
-        ports:
-        - containerPort: 8443
-          name: https
-          protocol: TCP
-        resources:
-          limits:
-            cpu: 500m
-            memory: 128Mi
-          requests:
-            cpu: 5m
-            memory: 64Mi
-        securityContext:
-          allowPrivilegeEscalation: false
-          capabilities:
-            drop:
-            - ALL
-      - args:
-        - --health-probe-bind-address=:8081
-        - --metrics-bind-address=127.0.0.1:8080
-        - --leader-elect
-        - --config-dir=/etc/pcloud/dns-zone-configs
-        - --api-port=8082
-        command:
-        - /manager
-        image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
-        imagePullPolicy: {{ .Values.image.pullPolicy }}
-        volumeMounts:
-        - name: zone-configs
-          mountPath: {{ .Values.volume.mountPath }}
-        livenessProbe:
-          httpGet:
-            path: /healthz
-            port: 8081
-          initialDelaySeconds: 15
-          periodSeconds: 20
-        name: manager
-        ports:
-        - containerPort: 8082
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /readyz
-            port: 8081
-          initialDelaySeconds: 5
-          periodSeconds: 10
-        resources:
-          limits:
-            cpu: 500m
-            memory: 128Mi
-          requests:
-            cpu: 10m
-            memory: 64Mi
-        securityContext:
-          allowPrivilegeEscalation: false
-          capabilities:
-            drop:
-            - ALL
-      securityContext:
-        runAsNonRoot: true
-      serviceAccountName: ns-controller-controller-manager
-      terminationGracePeriodSeconds: 10
diff --git a/charts/dns-ns-controller/values.yaml b/charts/dns-ns-controller/values.yaml
deleted file mode 100644
index 9ed3ae2..0000000
--- a/charts/dns-ns-controller/values.yaml
+++ /dev/null
@@ -1,14 +0,0 @@
-image:
-  repository: giolekva/dns-ns-controller
-  tag: latest
-  pullPolicy: Always
-kubeRBACProxy:
-  image:
-    repository: gcr.io/kubebuilder/kube-rbac-proxy
-    tag: v0.13.0
-    pullPolicy: IfNotPresent
-installCRDs: false
-volume:
-  claimName: data
-  mountPath: /etc/zone-configs
-apiConfigMapName: api-config
diff --git a/charts/service/.helmignore b/charts/service/.helmignore
new file mode 100644
index 0000000..0e8a0eb
--- /dev/null
+++ b/charts/service/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/charts/service/Chart.yaml b/charts/service/Chart.yaml
new file mode 100644
index 0000000..6141c00
--- /dev/null
+++ b/charts/service/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: rpuppy
+description: A Helm chart for Kubernetes service definition
+type: application
+version: 0.0.1
+appVersion: "0.0.1"
diff --git a/charts/service/templates/install.yaml b/charts/service/templates/install.yaml
new file mode 100644
index 0000000..1871f44
--- /dev/null
+++ b/charts/service/templates/install.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ .Values.name }}
+  namespace: {{ .Release.Namespace }}
+  {{- if or .Values.annotations }}
+  annotations:
+    {{- toYaml .Values.annotations | nindent 4 }}
+  {{- end }}
+spec:
+  type: {{ .Values.type }}
+  selector:
+    {{- toYaml .Values.selector | nindent 4 }}
+  ports:
+    {{- toYaml .Values.ports | nindent 4 }}
diff --git a/charts/service/values.yaml b/charts/service/values.yaml
new file mode 100644
index 0000000..b476b59
--- /dev/null
+++ b/charts/service/values.yaml
@@ -0,0 +1,5 @@
+name: "example"
+type: "ClusterIP"
+ports: {}
+selector: {}
+annotations: {}
diff --git a/core/dns-api/.gitignore b/core/dns-api/.gitignore
new file mode 100644
index 0000000..d63d804
--- /dev/null
+++ b/core/dns-api/.gitignore
@@ -0,0 +1 @@
+dns-api*
diff --git a/core/dns-api/Dockerfile b/core/dns-api/Dockerfile
new file mode 100644
index 0000000..5a9a894
--- /dev/null
+++ b/core/dns-api/Dockerfile
@@ -0,0 +1,5 @@
+FROM gcr.io/distroless/static:nonroot
+
+ARG TARGETARCH
+
+COPY dns-api_${TARGETARCH} /usr/bin/dns-api
diff --git a/core/dns-api/Makefile b/core/dns-api/Makefile
new file mode 100644
index 0000000..1f127cb
--- /dev/null
+++ b/core/dns-api/Makefile
@@ -0,0 +1,39 @@
+repo_name ?= dtabidze
+podman ?= docker
+ifeq ($(podman), podman)
+manifest_dest=docker://docker.io/$(repo_name)/pcloud-installer:latest
+endif
+
+clean:
+	rm -f dns-api*
+
+# TODO(gio): fix go path
+build:
+	/usr/local/go/bin/go build -o dns-api *.go
+
+build_arm64: export CGO_ENABLED=0
+build_arm64: export GO111MODULE=on
+build_arm64: export GOOS=linux
+build_arm64: export GOARCH=arm64
+build_arm64:
+	/usr/local/go/bin/go build -o dns-api_arm64 *.go
+
+build_amd64: export CGO_ENABLED=0
+build_amd64: export GO111MODULE=on
+build_amd64: export GOOS=linux
+build_amd64: export GOARCH=amd64
+build_amd64:
+	/usr/local/go/bin/go build -o dns-api_amd64 *.go
+
+push_arm64: clean build_arm64
+	$(podman) build --platform linux/arm64 --tag=giolekva/dns-api:arm64 .
+	$(podman) push giolekva/dns-api:arm64
+
+push_amd64: clean build_amd64
+	$(podman) build --platform linux/amd64 --tag=giolekva/dns-api:amd64 .
+	$(podman) push giolekva/dns-api:amd64
+
+push: push_arm64 push_amd64
+	$(podman) manifest create giolekva/dns-api:latest giolekva/dns-api:arm64 giolekva/dns-api:amd64
+	$(podman) manifest push giolekva/dns-api:latest $(manifest_dest)
+	$(podman) manifest rm giolekva/dns-api:latest
diff --git a/core/dns-api/fs.go b/core/dns-api/fs.go
new file mode 100644
index 0000000..597a088
--- /dev/null
+++ b/core/dns-api/fs.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+	"errors"
+	"io"
+	"os"
+	"path/filepath"
+)
+
+type FS interface {
+	Exists(path string) (bool, error)
+	Reader(path string) (io.ReadCloser, error)
+	Writer(path string) (io.WriteCloser, error)
+	Read(path string) (string, error)
+	Write(path string, data string) error
+	AbsolutePath(path string) string
+}
+
+type osFS struct {
+	root string
+}
+
+func (f osFS) AbsolutePath(path string) string {
+	return filepath.Join(f.root, path)
+}
+
+func (f osFS) Exists(path string) (bool, error) {
+	_, err := os.Stat(f.AbsolutePath(path))
+	if err == nil {
+		return true, nil
+	}
+	if errors.Is(err, os.ErrNotExist) {
+		return false, nil
+	}
+	return false, nil // TODO(gio): return err
+}
+
+func (f osFS) Reader(path string) (io.ReadCloser, error) {
+	return os.Open(f.AbsolutePath(path))
+}
+
+func (f osFS) Writer(path string) (io.WriteCloser, error) {
+	return os.Create(f.AbsolutePath(path))
+}
+
+func (f osFS) Read(path string) (string, error) {
+	r, err := f.Reader(path)
+	if err != nil {
+		return "", err
+	}
+	defer r.Close()
+	d, err := io.ReadAll(r)
+	if err != nil {
+		return "", err
+	}
+	return string(d), err
+}
+
+func (f osFS) Write(path string, data string) error {
+	w, err := f.Writer(path)
+	if err != nil {
+		return err
+	}
+	defer w.Close()
+	_, err = io.WriteString(w, data)
+	return err
+}
diff --git a/core/dns-api/go.mod b/core/dns-api/go.mod
new file mode 100644
index 0000000..7384cb5
--- /dev/null
+++ b/core/dns-api/go.mod
@@ -0,0 +1,25 @@
+module github.com/giolekva/pcloud/core/dns-api
+
+go 1.21.5
+
+require (
+	github.com/Masterminds/sprig/v3 v3.2.3
+	github.com/miekg/dns v1.1.59
+)
+
+require (
+	github.com/Masterminds/goutils v1.1.1 // indirect
+	github.com/Masterminds/semver/v3 v3.2.0 // indirect
+	github.com/google/uuid v1.1.1 // indirect
+	github.com/huandu/xstrings v1.3.3 // indirect
+	github.com/imdario/mergo v0.3.11 // indirect
+	github.com/mitchellh/copystructure v1.0.0 // indirect
+	github.com/mitchellh/reflectwalk v1.0.0 // indirect
+	github.com/shopspring/decimal v1.2.0 // indirect
+	github.com/spf13/cast v1.3.1 // indirect
+	golang.org/x/crypto v0.21.0 // indirect
+	golang.org/x/mod v0.16.0 // indirect
+	golang.org/x/net v0.22.0 // indirect
+	golang.org/x/sys v0.18.0 // indirect
+	golang.org/x/tools v0.19.0 // indirect
+)
diff --git a/core/dns-api/go.sum b/core/dns-api/go.sum
new file mode 100644
index 0000000..91887fd
--- /dev/null
+++ b/core/dns-api/go.sum
@@ -0,0 +1,75 @@
+github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
+github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
+github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
+github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
+github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
+github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
+github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
+github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
+github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
+github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs=
+github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk=
+github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
+github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
+github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
+github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
+github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
+github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
+github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
+golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
+golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
+golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
+golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
+golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
+golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/core/dns-api/init.go b/core/dns-api/init.go
new file mode 100644
index 0000000..0544611
--- /dev/null
+++ b/core/dns-api/init.go
@@ -0,0 +1,158 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"text/template"
+	"time"
+
+	"github.com/Masterminds/sprig/v3"
+	"github.com/miekg/dns"
+)
+
+const coreDNSConfigTmpl = `
+{{ .zone }}:53 {
+	file {{ .dbFile }} {
+		reload 1s
+	}
+	errors
+	{{- if .dnsSecBasename }}
+	dnssec {
+		key file {{ .dnsSecBasename}}
+	}
+	{{- end }}
+	log
+	health {
+		lameduck 5s
+	}
+	ready
+	cache 30
+	loop
+	reload
+	loadbalance
+}
+`
+
+const recordsDBTmpl = `
+{{- $zone := .zone }}
+{{ $zone }}.   IN SOA ns1.{{ $zone }}. hostmaster.{{ $zone }}. {{ .nowUnix }} 7200 3600 1209600 3600
+{{- range $i, $ns := .nameserverIP }}
+ns{{ add1 $i }}.{{ $zone }}. 10800 IN A {{ $ns }}
+{{- end }}
+{{- range .publicIP }}
+{{ $zone }}. 10800 IN A {{ . }}
+*.{{ $zone }}. 10800 IN A {{ . }}
+*.*.{{ $zone }}. 10800 IN A {{ . }}
+{{- end }}
+*.p.{{ $zone }}. 10800 IN A {{ .privateIP }}
+`
+
+func NewStore(fs FS, config string, db string, zone string, publicIP []string, privateIP string, nameserverIP []string) (RecordStore, string, error) {
+	dnsSec, err := getDNSSecKey(fs, zone)
+	if err != nil {
+		return nil, "", err
+	}
+	if err := fs.Write(dnsSec.Basename+".key", string(dnsSec.Key)); err != nil {
+		return nil, "", err
+	}
+	if err := fs.Write(dnsSec.Basename+".private", string(dnsSec.Private)); err != nil {
+		return nil, "", err
+	}
+	if err := executeTemplate(fs, config, coreDNSConfigTmpl, map[string]any{
+		"zone":           zone,
+		"dbFile":         fs.AbsolutePath(db),
+		"dnsSecBasename": fs.AbsolutePath(dnsSec.Basename),
+	}); err != nil {
+		return nil, "", err
+	}
+	ok, err := fs.Exists(db)
+	if err != nil {
+		return nil, "", err
+	}
+	if !ok {
+		if err := executeTemplate(fs, db, recordsDBTmpl, map[string]any{
+			"zone":         zone,
+			"publicIP":     publicIP,
+			"privateIP":    privateIP,
+			"nameserverIP": nameserverIP,
+			"nowUnix":      NowUnix(),
+		}); err != nil {
+			return nil, "", err
+		}
+	}
+	return &fsRecordStore{zone, publicIP, fs, db}, string(dnsSec.DS), nil
+}
+
+func getDNSSecKey(fs FS, zone string) (DNSSecKey, error) {
+	const configFile = "dns-sec-key.json"
+	ok, err := fs.Exists(configFile)
+	if err != nil {
+		return DNSSecKey{}, err
+	}
+	if ok {
+		d, err := fs.Read(configFile)
+		if err != nil {
+			return DNSSecKey{}, err
+		}
+		var k DNSSecKey
+		if err := json.Unmarshal([]byte(d), &k); err != nil {
+			return DNSSecKey{}, err
+		}
+		return k, nil
+	}
+	k, err := newDNSSecKey(zone)
+	if err != nil {
+		return DNSSecKey{}, err
+	}
+	d, err := json.MarshalIndent(k, "", "\t")
+	if err != nil {
+		return DNSSecKey{}, err
+	}
+	if err := fs.Write(configFile, string(d)); err != nil {
+		return DNSSecKey{}, err
+	}
+	return k, nil
+}
+
+type DNSSecKey struct {
+	Basename string `json:"basename,omitempty"`
+	Key      []byte `json:"key,omitempty"`
+	Private  []byte `json:"private,omitempty"`
+	DS       []byte `json:"ds,omitempty"`
+}
+
+func newDNSSecKey(zone string) (DNSSecKey, error) {
+	key := &dns.DNSKEY{
+		Hdr:       dns.RR_Header{Name: dns.Fqdn(zone), Class: dns.ClassINET, Ttl: 3600, Rrtype: dns.TypeDNSKEY},
+		Algorithm: dns.ECDSAP256SHA256, Flags: 257, Protocol: 3,
+	}
+	priv, err := key.Generate(256)
+	if err != nil {
+		return DNSSecKey{}, err
+	}
+	return DNSSecKey{
+		Basename: fmt.Sprintf("K%s+%03d+%05d", key.Header().Name, key.Algorithm, key.KeyTag()),
+		Key:      []byte(key.String()),
+		Private:  []byte(key.PrivateKeyString(priv)),
+		DS:       []byte(key.ToDS(dns.SHA256).String()),
+	}, nil
+}
+
+// TODO(gio): not going to work in 15 years?
+// TODO(gio): remove 10 *
+func NowUnix() uint32 {
+	return 10 * uint32(time.Now().Unix())
+}
+
+func executeTemplate(fs FS, path string, contents string, values map[string]any) error {
+	tmpl, err := template.New("tmpl").Funcs(sprig.TxtFuncMap()).Parse(contents)
+	if err != nil {
+		return err
+	}
+	var d strings.Builder
+	if err := tmpl.Execute(&d, values); err != nil {
+		return err
+	}
+	return fs.Write(path, strings.TrimSpace(d.String())+"\n")
+}
diff --git a/core/dns-api/main.go b/core/dns-api/main.go
new file mode 100644
index 0000000..7ab1bb2
--- /dev/null
+++ b/core/dns-api/main.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+	"flag"
+	"strings"
+)
+
+var port = flag.Int("port", 8080, "Port to listen on")
+var rootDir = flag.String("root-dir", "", "Path to generate DNS sec keys")
+var config = flag.String("config", "", "Coredns config file name")
+var db = flag.String("db", "", "DNS records db file name")
+var zone = flag.String("zone", "", "Zone domain")
+var publicIPs = flag.String("public-ip", "", "Comma separated list of public IPs of the pcloud environment")
+var privateIP = flag.String("private-ip", "", "Private IP of the pcloud environment")
+var nameserverIPs = flag.String("nameserver-ip", "", "Comma separated list of nameserver IPs")
+
+func main() {
+	flag.Parse()
+	publicIP := strings.Split(*publicIPs, ",")
+	nameserverIP := strings.Split(*nameserverIPs, ",")
+	fs := osFS{*rootDir}
+	store, ds, err := NewStore(fs, *config, *db, *zone, publicIP, *privateIP, nameserverIP)
+	if err != nil {
+		panic(err)
+	}
+	server := NewServer(*port, *zone, ds, store, nameserverIP)
+	server.Start()
+}
diff --git a/core/dns-api/main_test.go b/core/dns-api/main_test.go
new file mode 100644
index 0000000..d65d52f
--- /dev/null
+++ b/core/dns-api/main_test.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+	"testing"
+)
+
+func TestDNSSecKey(t *testing.T) {
+	k, err := getDNSSecKey(osFS{"/tmp"}, "foo.bar.ge")
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(k)
+}
diff --git a/core/ns-controller/controllers/zone.go b/core/dns-api/records_file.go
similarity index 66%
rename from core/ns-controller/controllers/zone.go
rename to core/dns-api/records_file.go
index 4969771..6916a5e 100644
--- a/core/ns-controller/controllers/zone.go
+++ b/core/dns-api/records_file.go
@@ -1,4 +1,4 @@
-package controllers
+package main
 
 import (
 	"fmt"
@@ -6,17 +6,16 @@
 	"net"
 	"strings"
 	"sync"
-	"time"
 
 	"github.com/miekg/dns"
 )
 
-type ZoneFile struct {
+type RecordsFile struct {
 	lock sync.Locker
 	rrs  []dns.RR
 }
 
-func NewZoneFile(r io.Reader) (*ZoneFile, error) {
+func NewRecordsFile(r io.Reader) (*RecordsFile, error) {
 	rrs := make([]dns.RR, 0)
 	p := dns.NewZoneParser(r, "", "")
 	p.SetIncludeAllowed(false)
@@ -30,14 +29,17 @@
 			break
 		}
 	}
-	return &ZoneFile{&sync.Mutex{}, rrs}, nil
+	return &RecordsFile{&sync.Mutex{}, rrs}, nil
 }
 
-func (z *ZoneFile) DeleteTxtRecord(name, value string) {
+func (z *RecordsFile) DeleteTxtRecord(name, value string) {
 	z.lock.Lock()
 	defer z.lock.Unlock()
+	fmt.Printf("%s %s\n", name, value)
 	for i, rr := range z.rrs {
+		fmt.Printf("%+v\n", rr)
 		if txt, ok := rr.(*dns.TXT); ok {
+			fmt.Printf("%+v\n", txt)
 			if txt.Hdr.Name == name && strings.Join(txt.Txt, "") == value {
 				z.rrs = append(z.rrs[:i], z.rrs[i+1:]...)
 			}
@@ -45,19 +47,19 @@
 	}
 }
 
-func (z *ZoneFile) DeleteRecordsFor(name string) {
-	z.lock.Lock()
-	defer z.lock.Unlock()
-	rrs := make([]dns.RR, 0)
-	for _, rr := range z.rrs {
-		if rr.Header().Name != name {
-			rrs = append(rrs, rr)
-		}
-	}
-	z.rrs = rrs
-}
+// func (z *RecordsFile) DeleteRecordsFor(name string) {
+// 	z.lock.Lock()
+// 	defer z.lock.Unlock()
+// 	rrs := make([]dns.RR, 0)
+// 	for _, rr := range z.rrs {
+// 		if rr.Header().Name != name {
+// 			rrs = append(rrs, rr)
+// 		}
+// 	}
+// 	z.rrs = rrs
+// }
 
-func (z *ZoneFile) CreateOrReplaceTxtRecord(name, value string) {
+func (z *RecordsFile) CreateOrReplaceTxtRecord(name, value string) {
 	z.lock.Lock()
 	defer z.lock.Unlock()
 	for i, rr := range z.rrs {
@@ -81,7 +83,7 @@
 	})
 }
 
-func (z *ZoneFile) CreateARecord(name, value string) {
+func (z *RecordsFile) CreateARecord(name, value string) {
 	z.lock.Lock()
 	defer z.lock.Unlock()
 	z.rrs = append(z.rrs, &dns.A{
@@ -95,7 +97,7 @@
 	})
 }
 
-func (z *ZoneFile) Write(w io.Writer) error {
+func (z *RecordsFile) Write(w io.Writer) error {
 	z.lock.Lock()
 	defer z.lock.Unlock()
 	for _, rr := range z.rrs {
@@ -108,9 +110,3 @@
 	}
 	return nil
 }
-
-// TODO(gio): not going to work in 15 years?
-// TODO(gio): remove 10 *
-func NowUnix() uint32 {
-	return 10 * uint32(time.Now().Unix())
-}
diff --git a/core/dns-api/server.go b/core/dns-api/server.go
new file mode 100644
index 0000000..37db8ff
--- /dev/null
+++ b/core/dns-api/server.go
@@ -0,0 +1,90 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+)
+
+type Server struct {
+	s          *http.Server
+	m          *http.ServeMux
+	store      RecordStore
+	zone       string
+	ds         string
+	nameserver []string
+}
+
+func NewServer(port int, zone string, ds string, store RecordStore, nameserver []string) *Server {
+	m := http.NewServeMux()
+	s := &Server{
+		s: &http.Server{
+			Addr:    fmt.Sprintf(":%d", port),
+			Handler: m,
+		},
+		m:          m,
+		store:      store,
+		zone:       zone,
+		ds:         ds,
+		nameserver: nameserver,
+	}
+	m.HandleFunc("/records-to-publish", s.recordsToPublish)
+	m.HandleFunc("/create-txt-record", s.createTxtRecord)
+	m.HandleFunc("/delete-txt-record", s.deleteTxtRecord)
+	return s
+}
+
+func (s *Server) Start() error {
+	return s.s.ListenAndServe()
+}
+
+type record struct {
+	Domain string `json:"domain,omitempty"`
+	Entry  string `json:"entry,omitempty"`
+	Text   string `json:"text,omitempty"`
+}
+
+func (s *Server) recordsToPublish(w http.ResponseWriter, r *http.Request) {
+	subdomain := strings.Split(s.zone, ".")[0]
+	if _, err := fmt.Fprintln(w, s.ds); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	for i, ip := range s.nameserver {
+		if _, err := fmt.Fprintf(w, "ns%d.%s. 10800 IN A %s\n", i+1, s.zone, ip); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		if _, err := fmt.Fprintf(w, "%s 10800 IN NS ns%d.%s.\n", subdomain, i+1, s.zone); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	}
+}
+
+func (s *Server) createTxtRecord(w http.ResponseWriter, r *http.Request) {
+	var req record
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	fmt.Printf("CREATE: %+v\n", req)
+	if err := s.store.Add(req.Entry, req.Text); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) deleteTxtRecord(w http.ResponseWriter, r *http.Request) {
+	var req record
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	fmt.Printf("DELETE: %+v\n", req)
+	if err := s.store.Delete(req.Entry, req.Text); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
diff --git a/core/dns-api/store.go b/core/dns-api/store.go
new file mode 100644
index 0000000..6d0a7c1
--- /dev/null
+++ b/core/dns-api/store.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+	"fmt"
+)
+
+type RecordStore interface {
+	Add(entry, txt string) error
+	Delete(entry, txt string) error
+}
+
+type fsRecordStore struct {
+	zone     string
+	publicIP []string
+	fs       FS
+	db       string
+}
+
+func (s *fsRecordStore) read() (*RecordsFile, error) {
+	r, err := s.fs.Reader(s.db)
+	if err != nil {
+		return nil, err
+	}
+	defer r.Close()
+	return NewRecordsFile(r)
+}
+func (s *fsRecordStore) write(z *RecordsFile) error {
+	w, err := s.fs.Writer(s.db)
+	if err != nil {
+		return err
+	}
+	defer w.Close()
+	return z.Write(w)
+}
+
+func (s *fsRecordStore) Add(entry, txt string) error {
+	z, err := s.read()
+	if err != nil {
+		return err
+	}
+	fqdn := fmt.Sprintf("%s.%s.", entry, s.zone)
+	z.CreateOrReplaceTxtRecord(fqdn, txt)
+	// for _, ip := range s.publicIP {
+	// 	z.CreateARecord(fqdn, ip)
+	// }
+	return s.write(z)
+}
+
+func (s *fsRecordStore) Delete(entry, txt string) error {
+	z, err := s.read()
+	if err != nil {
+		return err
+	}
+	fqdn := fmt.Sprintf("%s.%s.", entry, s.zone)
+	z.DeleteTxtRecord(fqdn, txt)
+	// z.DeleteRecordsFor(fqdn)
+	return s.write(z)
+}
diff --git a/core/dns-challenge-solver/Makefile b/core/dns-challenge-solver/Makefile
index 545735b..e43ccb2 100644
--- a/core/dns-challenge-solver/Makefile
+++ b/core/dns-challenge-solver/Makefile
@@ -1,3 +1,9 @@
+repo_name ?= dtabidze
+podman ?= docker
+ifeq ($(podman), podman)
+manifest_dest=docker://docker.io/$(repo_name)/dns-challenge-solver:latest
+endif
+
 clean:
 	rm -f dns-challenge-solver*
 
@@ -20,14 +26,14 @@
 	/usr/local/go/bin/go build -o dns-challenge-solver_amd64 *.go
 
 push_arm64: clean build_arm64
-	podman build --platform linux/arm64 --tag=giolekva/dns-challenge-solver:arm64 .
-	podman push giolekva/dns-challenge-solver:arm64
+	$(podman) build --platform linux/arm64 --tag=giolekva/dns-challenge-solver:arm64 .
+	$(podman) push giolekva/dns-challenge-solver:arm64
 
 push_amd64: clean build_amd64
-	podman build --platform linux/amd64 --tag=giolekva/dns-challenge-solver:amd64 .
-	podman push giolekva/dns-challenge-solver:amd64
+	$(podman) build --platform linux/amd64 --tag=giolekva/dns-challenge-solver:amd64 .
+	$(podman) push giolekva/dns-challenge-solver:amd64
 
 push: push_arm64 push_amd64
-	podman manifest create giolekva/dns-challenge-solver:latest giolekva/dns-challenge-solver:arm64 giolekva/dns-challenge-solver:amd64
-	podman manifest push giolekva/dns-challenge-solver:latest docker://docker.io/giolekva/dns-challenge-solver:latest
-	podman manifest rm giolekva/dns-challenge-solver:latest
+	$(podman) manifest create giolekva/dns-challenge-solver:latest giolekva/dns-challenge-solver:arm64 giolekva/dns-challenge-solver:amd64
+	$(podman) manifest push giolekva/dns-challenge-solver:latest $(manifest_dest)
+	$(podman) manifest rm giolekva/dns-challenge-solver:latest
diff --git a/core/dns-challenge-solver/main.go b/core/dns-challenge-solver/main.go
index 9055735..8daa353 100644
--- a/core/dns-challenge-solver/main.go
+++ b/core/dns-challenge-solver/main.go
@@ -2,7 +2,6 @@
 
 import (
 	"bytes"
-	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -11,7 +10,6 @@
 	"strings"
 
 	extapi "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
 
@@ -111,11 +109,6 @@
 // be used by your provider here, you should reference a Kubernetes Secret
 // resource and fetch these credentials using a Kubernetes clientset.
 type pcloudDNSProviderConfig struct {
-	APIConfigMapName      string `json:"apiConfigMapName,omitempty"`
-	APIConfigMapNamespace string `json:"apiConfigMapNamespace,omitempty"`
-}
-
-type apiConfig struct {
 	CreateAddress string `json:"createTXTAddr,omitempty"`
 	DeleteAddress string `json:"deleteTXTAddr,omitempty"`
 }
@@ -141,13 +134,8 @@
 	if err != nil {
 		return err
 	}
-	apiCfg, err := loadAPIConfig(c.client, cfg)
-	if err != nil {
-		fmt.Printf("Failed to load API config: %s\n", err.Error())
-		return err
-	}
-	fmt.Printf("API config: %+v\n", apiCfg)
-	zm := &zoneControllerManager{apiCfg.CreateAddress, apiCfg.DeleteAddress}
+	fmt.Printf("API config: %+v\n", cfg)
+	zm := &zoneControllerManager{cfg.CreateAddress, cfg.DeleteAddress}
 	domain, entry := getDomainAndEntry(ch)
 	fmt.Printf("%s %s\n", domain, entry)
 	err = zm.CreateTextRecord(domain, entry, ch.Key)
@@ -169,13 +157,8 @@
 	if err != nil {
 		return err
 	}
-	apiCfg, err := loadAPIConfig(c.client, cfg)
-	if err != nil {
-		fmt.Printf("Failed to load API config: %s\n", err.Error())
-		return err
-	}
-	fmt.Printf("API config: %+v\n", apiCfg)
-	zm := &zoneControllerManager{apiCfg.CreateAddress, apiCfg.DeleteAddress}
+	fmt.Printf("API config: %+v\n", cfg)
+	zm := &zoneControllerManager{cfg.CreateAddress, cfg.DeleteAddress}
 	domain, entry := getDomainAndEntry(ch)
 	err = zm.DeleteTextRecord(domain, entry, ch.Key)
 	if err != nil {
@@ -220,22 +203,6 @@
 	return cfg, nil
 }
 
-func loadAPIConfig(client *kubernetes.Clientset, cfg pcloudDNSProviderConfig) (apiConfig, error) {
-	config, err := client.CoreV1().ConfigMaps(cfg.APIConfigMapNamespace).Get(context.Background(), cfg.APIConfigMapName, metav1.GetOptions{})
-	if err != nil {
-		return apiConfig{}, fmt.Errorf("unable to get api config map `%s` `%s`; %v", cfg.APIConfigMapName, cfg.APIConfigMapNamespace, err)
-	}
-	create, ok := config.Data["createTXTAddr"]
-	if !ok {
-		return apiConfig{}, fmt.Errorf("create address missing")
-	}
-	delete, ok := config.Data["deleteTXTAddr"]
-	if !ok {
-		return apiConfig{}, fmt.Errorf("delete address missing")
-	}
-	return apiConfig{create, delete}, nil
-}
-
 func getDomainAndEntry(ch *v1alpha1.ChallengeRequest) (string, string) {
 	// Both ch.ResolvedZone and ch.ResolvedFQDN end with a dot: '.'
 	resolvedFQDN := strings.TrimSuffix(ch.ResolvedFQDN, ".")
diff --git a/core/installer/app.go b/core/installer/app.go
index fcf39ff..cec7cbf 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -6,6 +6,7 @@
 	"fmt"
 	template "html/template"
 	"net"
+	"net/netip"
 	"strings"
 
 	"cuelang.org/go/cue"
@@ -16,36 +17,18 @@
 )
 
 // TODO(gio): import
-const cueBaseConfig = `
-name: string | *""
-description: string | *""
-readme: string | *""
-icon: string | *""
-namespace: string | *""
-help: [...#HelpDocument] | *[]
-
-#HelpDocument: {
-	title: string
-	contents: string
-	children: [...#HelpDocument] | *[]
-}
-
-url: string | *""
-
-#AppType: "infra" | "env"
-appType: #AppType | *"env"
-
-#Auth: {
-  enabled: bool | *false // TODO(gio): enabled by default?
-  groups: string | *"" // TODO(gio): []string
-}
-
-#Network: {
-	name: string
-	ingressClass: string
-	certificateIssuer: string | *""
-	domain: string
-	allocatePortAddr: string
+const cueEnvAppGlobal = `
+#Global: {
+	id: string | *""
+	pcloudEnvName: string | *""
+	domain: string | *""
+    privateDomain: string | *""
+    contactEmail: string | *""
+    adminPublicKey: string | *""
+    publicIP: [...string] | *[]
+    nameserverIP: [...string] | *[]
+	namespacePrefix: string | *""
+	network: #EnvNetwork
 }
 
 networks: {
@@ -64,61 +47,11 @@
 	}
 }
 
-#Image: {
-	registry: string | *"docker.io"
-	repository: string
-	name: string
-	tag: string
-	pullPolicy: string | *"IfNotPresent"
-	imageName: "\(repository)/\(name)"
-	fullName: "\(registry)/\(imageName)"
-	fullNameWithTag: "\(fullName):\(tag)"
-}
-
-#Chart: {
-	chart: string
-	sourceRef: #SourceRef
-}
-
-#SourceRef: {
-	kind: "GitRepository" | "HelmRepository"
-	name: string
-	namespace: string // TODO(gio): default global.id
-}
-
-#Global: {
-	id: string | *""
-	pcloudEnvName: string | *""
-	domain: string | *""
-    privateDomain: string | *""
-	namespacePrefix: string | *""
-	...
-}
-
-#Release: {
-	appInstanceId: string
-	namespace: string
-	repoAddr: string
-	appDir: string
-}
-
-#PortForward: {
-	allocator: string
-	protocol: "TCP" | "UDP" | *"TCP"
-	sourcePort: int
-	targetService: string
-	targetPort: int
-}
-
-portForward: [...#PortForward] | *[]
-
-global: #Global
-release: #Release
-
-_ingressPrivate: "\(global.id)-ingress-private"
-_ingressPublic: "\(global.pcloudEnvName)-ingress-public"
-_issuerPrivate: "\(global.id)-private"
-_issuerPublic: "\(global.id)-public"
+// TODO(gio): remove
+ingressPrivate: "\(global.id)-ingress-private"
+ingressPublic: "\(global.pcloudEnvName)-ingress-public"
+issuerPrivate: "\(global.id)-private"
+issuerPublic: "\(global.id)-public"
 
 #Ingress: {
 	auth: #Auth
@@ -213,6 +146,110 @@
 		"\(key)": #Ingress & value
 	}
 }
+`
+
+const cueInfraAppGlobal = `
+#Global: {
+	pcloudEnvName: string | *""
+    publicIP: [...string] | *[]
+	namespacePrefix: string | *""
+    infraAdminPublicKey: string | *""
+}
+
+// TODO(gio): remove
+ingressPublic: "\(global.pcloudEnvName)-ingress-public"
+
+ingress: {}
+_ingressValidate: {}
+`
+
+const cueBaseConfig = `
+import (
+  "net"
+)
+
+name: string | *""
+description: string | *""
+readme: string | *""
+icon: string | *""
+namespace: string | *""
+
+help: [...#HelpDocument] | *[]
+
+#HelpDocument: {
+	title: string
+	contents: string
+	children: [...#HelpDocument] | *[]
+}
+
+url: string | *""
+
+#AppType: "infra" | "env"
+appType: #AppType | *"env"
+
+#Auth: {
+  enabled: bool | *false // TODO(gio): enabled by default?
+  groups: string | *"" // TODO(gio): []string
+}
+
+#Network: {
+	name: string
+	ingressClass: string
+	certificateIssuer: string | *""
+	domain: string
+	allocatePortAddr: string
+}
+
+#Image: {
+	registry: string | *"docker.io"
+	repository: string
+	name: string
+	tag: string
+	pullPolicy: string | *"IfNotPresent"
+	imageName: "\(repository)/\(name)"
+	fullName: "\(registry)/\(imageName)"
+	fullNameWithTag: "\(fullName):\(tag)"
+}
+
+#Chart: {
+	chart: string
+	sourceRef: #SourceRef
+}
+
+#SourceRef: {
+	kind: "GitRepository" | "HelmRepository"
+	name: string
+	namespace: string // TODO(gio): default global.id
+}
+
+#EnvNetwork: {
+	dns: net.IPv4
+	dnsInClusterIP: net.IPv4
+	ingress: net.IPv4
+	headscale: net.IPv4
+	servicesFrom: net.IPv4
+	servicesTo: net.IPv4
+}
+
+#Release: {
+	appInstanceId: string
+	namespace: string
+	repoAddr: string
+	appDir: string
+}
+
+#PortForward: {
+	allocator: string
+	protocol: "TCP" | "UDP" | *"TCP"
+	sourcePort: int
+	targetService: string
+	targetPort: int
+}
+
+portForward: [...#PortForward] | *[]
+
+global: #Global
+release: #Release
 
 images: {
 	for key, value in images {
@@ -304,12 +341,11 @@
 }
 `
 
-type Rendered struct {
+type rendered struct {
 	Name      string
 	Readme    string
 	Resources CueAppData
 	Ports     []PortForward
-	Config    AppInstanceConfig
 	Data      CueAppData
 	Help      []HelpDocument
 	Url       string
@@ -322,6 +358,16 @@
 	Children []HelpDocument
 }
 
+type EnvAppRendered struct {
+	rendered
+	Config AppInstanceConfig
+}
+
+type InfraAppRendered struct {
+	rendered
+	Config InfraAppInstanceConfig
+}
+
 type PortForward struct {
 	Allocator     string `json:"allocator"`
 	Protocol      string `json:"protocol"`
@@ -347,31 +393,86 @@
 }
 
 type InfraConfig struct {
-	Name                 string   `json:"pcloudEnvName"` // #TODO(gio): change to name
-	PublicIP             []net.IP `json:"publicIP"`
-	InfraNamespacePrefix string   `json:"namespacePrefix"`
-	InfraAdminPublicKey  []byte   `json:"infraAdminPublicKey"`
+	Name                 string   `json:"pcloudEnvName,omitempty"` // #TODO(gio): change to name
+	PublicIP             []net.IP `json:"publicIP,omitempty"`
+	InfraNamespacePrefix string   `json:"namespacePrefix,omitempty"`
+	InfraAdminPublicKey  []byte   `json:"infraAdminPublicKey,omitempty"`
 }
 
 type InfraApp interface {
 	App
-	Render(release Release, infra InfraConfig, values map[string]any) (Rendered, error)
+	Render(release Release, infra InfraConfig, values map[string]any) (InfraAppRendered, error)
+}
+
+type EnvNetwork struct {
+	DNS            net.IP `json:"dns,omitempty"`
+	DNSInClusterIP net.IP `json:"dnsInClusterIP,omitempty"`
+	Ingress        net.IP `json:"ingress,omitempty"`
+	Headscale      net.IP `json:"headscale,omitempty"`
+	ServicesFrom   net.IP `json:"servicesFrom,omitempty"`
+	ServicesTo     net.IP `json:"servicesTo,omitempty"`
+}
+
+func NewEnvNetwork(subnet net.IP) (EnvNetwork, error) {
+	addr, err := netip.ParseAddr(subnet.String())
+	if err != nil {
+		return EnvNetwork{}, err
+	}
+	if !addr.Is4() {
+		return EnvNetwork{}, fmt.Errorf("Expected IPv4, got %s instead", addr)
+	}
+	dns := addr.Next()
+	ingress := dns.Next()
+	headscale := ingress.Next()
+	b := addr.AsSlice()
+	if b[3] != 0 {
+		return EnvNetwork{}, fmt.Errorf("Expected last byte to be zero, got %d instead", b[3])
+	}
+	b[3] = 10
+	servicesFrom, ok := netip.AddrFromSlice(b)
+	if !ok {
+		return EnvNetwork{}, fmt.Errorf("Must not reach")
+	}
+	b[3] = 254
+	servicesTo, ok := netip.AddrFromSlice(b)
+	if !ok {
+		return EnvNetwork{}, fmt.Errorf("Must not reach")
+	}
+	b[3] = b[2]
+	b[2] = b[1]
+	b[0] = 10
+	b[1] = 44
+	dnsInClusterIP, ok := netip.AddrFromSlice(b)
+	if !ok {
+		return EnvNetwork{}, fmt.Errorf("Must not reach")
+	}
+	return EnvNetwork{
+		DNS:            net.ParseIP(dns.String()),
+		DNSInClusterIP: net.ParseIP(dnsInClusterIP.String()),
+		Ingress:        net.ParseIP(ingress.String()),
+		Headscale:      net.ParseIP(headscale.String()),
+		ServicesFrom:   net.ParseIP(servicesFrom.String()),
+		ServicesTo:     net.ParseIP(servicesTo.String()),
+	}, nil
 }
 
 // TODO(gio): rename to EnvConfig
-type AppEnvConfig struct {
-	Id              string   `json:"id"`
-	InfraName       string   `json:"pcloudEnvName"`
-	Domain          string   `json:"domain"`
-	PrivateDomain   string   `json:"privateDomain"`
-	ContactEmail    string   `json:"contactEmail"`
-	PublicIP        []net.IP `json:"publicIP"`
-	NamespacePrefix string   `json:"namespacePrefix"`
+type EnvConfig struct {
+	Id              string     `json:"id,omitempty"`
+	InfraName       string     `json:"pcloudEnvName,omitempty"`
+	Domain          string     `json:"domain,omitempty"`
+	PrivateDomain   string     `json:"privateDomain,omitempty"`
+	ContactEmail    string     `json:"contactEmail,omitempty"`
+	AdminPublicKey  string     `json:"adminPublicKey,omitempty"`
+	PublicIP        []net.IP   `json:"publicIP,omitempty"`
+	NameserverIP    []net.IP   `json:"nameserverIP,omitempty"`
+	NamespacePrefix string     `json:"namespacePrefix,omitempty"`
+	Network         EnvNetwork `json:"network,omitempty"`
 }
 
 type EnvApp interface {
 	App
-	Render(release Release, env AppEnvConfig, values map[string]any) (Rendered, error)
+	Render(release Release, env EnvConfig, values map[string]any) (EnvAppRendered, error)
 }
 
 type cueApp struct {
@@ -471,8 +572,8 @@
 	return a.namespace
 }
 
-func (a cueApp) render(values map[string]any) (Rendered, error) {
-	ret := Rendered{
+func (a cueApp) render(values map[string]any) (rendered, error) {
+	ret := rendered{
 		Name:      a.Name(),
 		Resources: make(CueAppData),
 		Ports:     make([]PortForward, 0),
@@ -480,38 +581,38 @@
 	}
 	var buf bytes.Buffer
 	if err := json.NewEncoder(&buf).Encode(values); err != nil {
-		return Rendered{}, err
+		return rendered{}, err
 	}
 	ctx := a.cfg.Context()
 	d := ctx.CompileBytes(buf.Bytes())
 	res := a.cfg.Unify(d).Eval()
 	if err := res.Err(); err != nil {
-		return Rendered{}, err
+		return rendered{}, err
 	}
 	if err := res.Validate(); err != nil {
-		return Rendered{}, err
+		return rendered{}, err
 	}
 	full, err := json.MarshalIndent(res, "", "\t")
 	if err != nil {
-		return Rendered{}, err
+		return rendered{}, err
 	}
 	ret.Data["rendered.json"] = full
 	readme, err := res.LookupPath(cue.ParsePath("readme")).String()
 	if err != nil {
-		return Rendered{}, err
+		return rendered{}, err
 	}
 	ret.Readme = readme
 	if err := res.LookupPath(cue.ParsePath("portForward")).Decode(&ret.Ports); err != nil {
-		return Rendered{}, err
+		return rendered{}, err
 	}
 	output := res.LookupPath(cue.ParsePath("output"))
 	i, err := output.Fields()
 	if err != nil {
-		return Rendered{}, err
+		return rendered{}, err
 	}
 	for i.Next() {
 		if contents, err := cueyaml.Encode(i.Value()); err != nil {
-			return Rendered{}, err
+			return rendered{}, err
 		} else {
 			name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
 			ret.Resources[name] = contents
@@ -520,17 +621,17 @@
 	helpValue := res.LookupPath(cue.ParsePath("help"))
 	if helpValue.Exists() {
 		if err := helpValue.Decode(&ret.Help); err != nil {
-			return Rendered{}, err
+			return rendered{}, err
 		}
 	}
 	url, err := res.LookupPath(cue.ParsePath("url")).String()
 	if err != nil {
-		return Rendered{}, err
+		return rendered{}, err
 	}
 	ret.Url = url
 	icon, err := res.LookupPath(cue.ParsePath("icon")).String()
 	if err != nil {
-		return Rendered{}, err
+		return rendered{}, err
 	}
 	ret.Icon = icon
 	return ret, nil
@@ -552,11 +653,11 @@
 	return AppTypeEnv
 }
 
-func (a cueEnvApp) Render(release Release, env AppEnvConfig, values map[string]any) (Rendered, error) {
+func (a cueEnvApp) Render(release Release, env EnvConfig, values map[string]any) (EnvAppRendered, error) {
 	networks := CreateNetworks(env)
 	derived, err := deriveValues(values, a.Schema(), networks)
 	if err != nil {
-		return Rendered{}, nil
+		return EnvAppRendered{}, nil
 	}
 	ret, err := a.cueApp.render(map[string]any{
 		"global":  env,
@@ -564,18 +665,20 @@
 		"input":   derived,
 	})
 	if err != nil {
-		return Rendered{}, err
+		return EnvAppRendered{}, err
 	}
-	ret.Config = AppInstanceConfig{
-		AppId:   a.Name(),
-		Env:     env,
-		Release: release,
-		Values:  values,
-		Input:   derived,
-		Help:    ret.Help,
-		Url:     ret.Url,
-	}
-	return ret, nil
+	return EnvAppRendered{
+		rendered: ret,
+		Config: AppInstanceConfig{
+			AppId:   a.Name(),
+			Env:     env,
+			Release: release,
+			Values:  values,
+			Input:   derived,
+			Help:    ret.Help,
+			Url:     ret.Url,
+		},
+	}, nil
 }
 
 type cueInfraApp struct {
@@ -594,14 +697,35 @@
 	return AppTypeInfra
 }
 
-func (a cueInfraApp) Render(release Release, infra InfraConfig, values map[string]any) (Rendered, error) {
-	return a.cueApp.render(map[string]any{
+func (a cueInfraApp) Render(release Release, infra InfraConfig, values map[string]any) (InfraAppRendered, error) {
+	ret, err := a.cueApp.render(map[string]any{
 		"global":  infra,
 		"release": release,
 		"input":   values,
 	})
+	if err != nil {
+		return InfraAppRendered{}, err
+	}
+	return InfraAppRendered{
+		rendered: ret,
+		Config: InfraAppInstanceConfig{
+			AppId:   a.Name(),
+			Infra:   infra,
+			Release: release,
+			Values:  values,
+			Input:   values,
+		},
+	}, nil
 }
 
 func cleanName(s string) string {
 	return strings.ReplaceAll(strings.ReplaceAll(s, "\"", ""), "'", "")
 }
+
+func join[T fmt.Stringer](items []T, sep string) string {
+	var tmp []string
+	for _, i := range items {
+		tmp = append(tmp, i.String())
+	}
+	return strings.Join(tmp, ",")
+}
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 0e41a90..871dae5 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -9,18 +9,21 @@
 	"net/http"
 	"path"
 	"path/filepath"
+
+	"github.com/giolekva/pcloud/core/installer/io"
+	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 const configFileName = "config.yaml"
 const kustomizationFileName = "kustomization.yaml"
 
 type AppManager struct {
-	repoIO     RepoIO
+	repoIO     soft.RepoIO
 	nsCreator  NamespaceCreator
 	appDirRoot string
 }
 
-func NewAppManager(repoIO RepoIO, nsCreator NamespaceCreator, appDirRoot string) (*AppManager, error) {
+func NewAppManager(repoIO soft.RepoIO, nsCreator NamespaceCreator, appDirRoot string) (*AppManager, error) {
 	return &AppManager{
 		repoIO,
 		nsCreator,
@@ -28,10 +31,10 @@
 	}, nil
 }
 
-func (m *AppManager) Config() (AppEnvConfig, error) {
-	var cfg AppEnvConfig
-	if err := ReadYaml(m.repoIO, configFileName, &cfg); err != nil {
-		return AppEnvConfig{}, err
+func (m *AppManager) Config() (EnvConfig, error) {
+	var cfg EnvConfig
+	if err := soft.ReadYaml(m.repoIO, configFileName, &cfg); err != nil {
+		return EnvConfig{}, err
 	} else {
 		return cfg, nil
 	}
@@ -39,7 +42,7 @@
 
 func (m *AppManager) appConfig(path string) (AppInstanceConfig, error) {
 	var cfg AppInstanceConfig
-	if err := ReadJson(m.repoIO, path, &cfg); err != nil {
+	if err := soft.ReadJson(m.repoIO, path, &cfg); err != nil {
 		return AppInstanceConfig{}, err
 	} else {
 		return cfg, nil
@@ -47,7 +50,7 @@
 }
 
 func (m *AppManager) FindAllInstances() ([]AppInstanceConfig, error) {
-	kust, err := ReadKustomization(m.repoIO, filepath.Join(m.appDirRoot, "kustomization.yaml"))
+	kust, err := soft.ReadKustomization(m.repoIO, filepath.Join(m.appDirRoot, "kustomization.yaml"))
 	if err != nil {
 		return nil, err
 	}
@@ -64,7 +67,7 @@
 }
 
 func (m *AppManager) FindAllAppInstances(name string) ([]AppInstanceConfig, error) {
-	kust, err := ReadKustomization(m.repoIO, filepath.Join(m.appDirRoot, "kustomization.yaml"))
+	kust, err := soft.ReadKustomization(m.repoIO, filepath.Join(m.appDirRoot, "kustomization.yaml"))
 	if err != nil {
 		return nil, err
 	}
@@ -83,7 +86,7 @@
 }
 
 func (m *AppManager) FindInstance(id string) (AppInstanceConfig, error) {
-	kust, err := ReadKustomization(m.repoIO, filepath.Join(m.appDirRoot, "kustomization.yaml"))
+	kust, err := soft.ReadKustomization(m.repoIO, filepath.Join(m.appDirRoot, "kustomization.yaml"))
 	if err != nil {
 		return AppInstanceConfig{}, err
 	}
@@ -102,7 +105,7 @@
 
 func (m *AppManager) AppConfig(name string) (AppInstanceConfig, error) {
 	var cfg AppInstanceConfig
-	if err := ReadJson(m.repoIO, filepath.Join(m.appDirRoot, name, "config.json"), &cfg); err != nil {
+	if err := soft.ReadJson(m.repoIO, filepath.Join(m.appDirRoot, name, "config.json"), &cfg); err != nil {
 		return AppInstanceConfig{}, err
 	}
 	return cfg, nil
@@ -138,21 +141,21 @@
 	return nil
 }
 
-func createKustomizationChain(r RepoFS, path string) error {
+func createKustomizationChain(r soft.RepoFS, path string) error {
 	for p := filepath.Clean(path); p != "/"; {
 		parent, child := filepath.Split(p)
 		kustPath := filepath.Join(parent, "kustomization.yaml")
-		kust, err := ReadKustomization(r, kustPath)
+		kust, err := soft.ReadKustomization(r, kustPath)
 		if err != nil {
 			if errors.Is(err, fs.ErrNotExist) {
-				k := NewKustomization()
+				k := io.NewKustomization()
 				kust = &k
 			} else {
 				return err
 			}
 		}
 		kust.AddResources(child)
-		if err := WriteYaml(r, kustPath, kust); err != nil {
+		if err := soft.WriteYaml(r, kustPath, kust); err != nil {
 			return err
 		}
 		p = filepath.Clean(parent)
@@ -161,11 +164,19 @@
 }
 
 // TODO(gio): rename to CommitApp
-func InstallApp(repo RepoIO, appDir string, rendered Rendered, opts ...DoOption) error {
+func InstallApp(
+	repo soft.RepoIO,
+	appDir string,
+	name string,
+	config any,
+	ports []PortForward,
+	resources CueAppData,
+	data CueAppData,
+	opts ...soft.DoOption) error {
 	// if err := openPorts(rendered.Ports); err != nil {
 	// 	return err
 	// }
-	return repo.Do(func(r RepoFS) (string, error) {
+	return repo.Do(func(r soft.RepoFS) (string, error) {
 		if err := r.RemoveDir(appDir); err != nil {
 			return "", err
 		}
@@ -174,13 +185,13 @@
 			return "", err
 		}
 		{
-			if err := WriteYaml(r, path.Join(appDir, configFileName), rendered.Config); err != nil {
+			if err := soft.WriteYaml(r, path.Join(appDir, configFileName), config); err != nil {
 				return "", err
 			}
-			if err := WriteJson(r, path.Join(appDir, "config.json"), rendered.Config); err != nil {
+			if err := soft.WriteJson(r, path.Join(appDir, "config.json"), config); err != nil {
 				return "", err
 			}
-			for name, contents := range rendered.Data {
+			for name, contents := range data {
 				if name == "config.json" || name == "kustomization.yaml" || name == "resources" {
 					return "", fmt.Errorf("%s is forbidden", name)
 				}
@@ -198,8 +209,8 @@
 			if err := createKustomizationChain(r, resourcesDir); err != nil {
 				return "", err
 			}
-			appKust := NewKustomization()
-			for name, contents := range rendered.Resources {
+			appKust := io.NewKustomization()
+			for name, contents := range resources {
 				appKust.AddResources(name)
 				w, err := r.Writer(path.Join(resourcesDir, name))
 				if err != nil {
@@ -210,11 +221,11 @@
 					return "", err
 				}
 			}
-			if err := WriteYaml(r, path.Join(resourcesDir, "kustomization.yaml"), appKust); err != nil {
+			if err := soft.WriteYaml(r, path.Join(resourcesDir, "kustomization.yaml"), appKust); err != nil {
 				return "", err
 			}
 		}
-		return fmt.Sprintf("install: %s", rendered.Name), nil
+		return fmt.Sprintf("install: %s", name), nil
 	}, opts...)
 }
 
@@ -241,10 +252,10 @@
 	if err != nil {
 		return err
 	}
-	return InstallApp(m.repoIO, appDir, rendered)
+	return InstallApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data)
 }
 
-func (m *AppManager) Update(app EnvApp, instanceId string, values map[string]any, opts ...DoOption) error {
+func (m *AppManager) Update(app EnvApp, instanceId string, values map[string]any, opts ...soft.DoOption) error {
 	if err := m.repoIO.Pull(); err != nil {
 		return err
 	}
@@ -268,29 +279,28 @@
 	if err != nil {
 		return err
 	}
-	fmt.Println(rendered)
-	return InstallApp(m.repoIO, instanceDir, rendered, opts...)
+	return InstallApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
 }
 
 func (m *AppManager) Remove(instanceId string) error {
 	if err := m.repoIO.Pull(); err != nil {
 		return err
 	}
-	return m.repoIO.Do(func(r RepoFS) (string, error) {
+	return m.repoIO.Do(func(r soft.RepoFS) (string, error) {
 		r.RemoveDir(filepath.Join(m.appDirRoot, instanceId))
 		kustPath := filepath.Join(m.appDirRoot, "kustomization.yaml")
-		kust, err := ReadKustomization(r, kustPath)
+		kust, err := soft.ReadKustomization(r, kustPath)
 		if err != nil {
 			return "", err
 		}
 		kust.RemoveResources(instanceId)
-		WriteYaml(r, kustPath, kust)
+		soft.WriteYaml(r, kustPath, kust)
 		return fmt.Sprintf("uninstall: %s", instanceId), nil
 	})
 }
 
 // TODO(gio): deduplicate with cue definition in app.go, this one should be removed.
-func CreateNetworks(env AppEnvConfig) []Network {
+func CreateNetworks(env EnvConfig) []Network {
 	return []Network{
 		{
 			Name:              "Public",
@@ -311,11 +321,11 @@
 // InfraAppmanager
 
 type InfraAppManager struct {
-	repoIO    RepoIO
+	repoIO    soft.RepoIO
 	nsCreator NamespaceCreator
 }
 
-func NewInfraAppManager(repoIO RepoIO, nsCreator NamespaceCreator) (*InfraAppManager, error) {
+func NewInfraAppManager(repoIO soft.RepoIO, nsCreator NamespaceCreator) (*InfraAppManager, error) {
 	return &InfraAppManager{
 		repoIO,
 		nsCreator,
@@ -324,13 +334,40 @@
 
 func (m *InfraAppManager) Config() (InfraConfig, error) {
 	var cfg InfraConfig
-	if err := ReadYaml(m.repoIO, configFileName, &cfg); err != nil {
+	if err := soft.ReadYaml(m.repoIO, configFileName, &cfg); err != nil {
 		return InfraConfig{}, err
 	} else {
 		return cfg, nil
 	}
 }
 
+func (m *InfraAppManager) appConfig(path string) (InfraAppInstanceConfig, error) {
+	var cfg InfraAppInstanceConfig
+	if err := soft.ReadJson(m.repoIO, path, &cfg); err != nil {
+		return InfraAppInstanceConfig{}, err
+	} else {
+		return cfg, nil
+	}
+}
+
+func (m *InfraAppManager) FindInstance(id string) (InfraAppInstanceConfig, error) {
+	kust, err := soft.ReadKustomization(m.repoIO, filepath.Join("/infrastructure", "kustomization.yaml"))
+	if err != nil {
+		return InfraAppInstanceConfig{}, err
+	}
+	for _, app := range kust.Resources {
+		if app == id {
+			cfg, err := m.appConfig(filepath.Join("/infrastructure", app, "config.json"))
+			if err != nil {
+				return InfraAppInstanceConfig{}, err
+			}
+			cfg.Id = id
+			return cfg, nil
+		}
+	}
+	return InfraAppInstanceConfig{}, nil
+}
+
 func (m *InfraAppManager) Install(app InfraApp, appDir string, namespace string, values map[string]any) error {
 	appDir = filepath.Clean(appDir)
 	if err := m.repoIO.Pull(); err != nil {
@@ -352,5 +389,32 @@
 	if err != nil {
 		return err
 	}
-	return InstallApp(m.repoIO, appDir, rendered)
+	return InstallApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data)
+}
+
+func (m *InfraAppManager) Update(app InfraApp, instanceId string, values map[string]any, opts ...soft.DoOption) error {
+	if err := m.repoIO.Pull(); err != nil {
+		return err
+	}
+	env, err := m.Config()
+	if err != nil {
+		return err
+	}
+	instanceDir := filepath.Join("/infrastructure", instanceId)
+	instanceConfigPath := filepath.Join(instanceDir, "config.json")
+	config, err := m.appConfig(instanceConfigPath)
+	if err != nil {
+		return err
+	}
+	release := Release{
+		AppInstanceId: instanceId,
+		Namespace:     config.Release.Namespace,
+		RepoAddr:      m.repoIO.FullAddress(),
+		AppDir:        instanceDir,
+	}
+	rendered, err := app.Render(release, env, values)
+	if err != nil {
+		return err
+	}
+	return InstallApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
 }
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
index 88b4d88..6acde70 100644
--- a/core/installer/app_repository.go
+++ b/core/installer/app_repository.go
@@ -44,13 +44,14 @@
 	"values-tmpl/memberships.cue",
 	"values-tmpl/headscale.cue",
 	"values-tmpl/launcher.cue",
+	"values-tmpl/env-dns.cue",
 }
 
 var infraAppConfigs = []string{
 	"values-tmpl/cert-manager.cue",
 	"values-tmpl/config-repo.cue",
 	"values-tmpl/csi-driver-smb.cue",
-	"values-tmpl/dns-zone-manager.cue",
+	"values-tmpl/dns-gateway.cue",
 	"values-tmpl/env-manager.cue",
 	"values-tmpl/fluxcd-reconciler.cue",
 	"values-tmpl/headscale-controller.cue",
@@ -100,9 +101,11 @@
 			panic(err)
 		}
 		if app, err := NewCueEnvApp(CueAppData{
-			"base.cue": []byte(cueBaseConfig),
-			"app.cue":  contents,
+			"base.cue":   []byte(cueBaseConfig),
+			"global.cue": []byte(cueEnvAppGlobal),
+			"app.cue":    contents,
 		}); err != nil {
+			fmt.Println(cfgFile)
 			panic(err)
 		} else {
 			ret = append(ret, app)
@@ -119,9 +122,11 @@
 			panic(err)
 		}
 		if app, err := NewCueInfraApp(CueAppData{
-			"base.cue": []byte(cueBaseConfig),
-			"app.cue":  contents,
+			"base.cue":   []byte(cueBaseConfig),
+			"global.cue": []byte(cueInfraAppGlobal),
+			"app.cue":    contents,
 		}); err != nil {
+			fmt.Println(cfgFile)
 			panic(err)
 		} else {
 			ret = append(ret, app)
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 7695796..497c235 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -5,6 +5,25 @@
 	"testing"
 )
 
+var env = EnvConfig{
+	InfraName:       "dodo",
+	Id:              "id",
+	ContactEmail:    "foo@bar.ge",
+	Domain:          "bar.ge",
+	PrivateDomain:   "p.bar.ge",
+	PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
+	NameserverIP:    []net.IP{net.ParseIP("1.2.3.4")},
+	NamespacePrefix: "id-",
+	Network: EnvNetwork{
+		DNS:            net.ParseIP("1.1.1.1"),
+		DNSInClusterIP: net.ParseIP("2.2.2.2"),
+		Ingress:        net.ParseIP("3.3.3.3"),
+		Headscale:      net.ParseIP("4.4.4.4"),
+		ServicesFrom:   net.ParseIP("5.5.5.5"),
+		ServicesTo:     net.ParseIP("6.6.6.6"),
+	},
+}
+
 func TestAuthProxyEnabled(t *testing.T) {
 	r := NewInMemoryAppRepository(CreateAllApps())
 	for _, app := range []string{"rpuppy", "Pi-hole", "url-shortener"} {
@@ -18,15 +37,6 @@
 		release := Release{
 			Namespace: "foo",
 		}
-		env := AppEnvConfig{
-			InfraName:       "dodo",
-			Id:              "id",
-			ContactEmail:    "foo@bar.ge",
-			Domain:          "bar.ge",
-			PrivateDomain:   "p.bar.ge",
-			PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
-			NamespacePrefix: "id-",
-		}
 		values := map[string]any{
 			"network":   "Public",
 			"subdomain": "woof",
@@ -58,15 +68,6 @@
 		release := Release{
 			Namespace: "foo",
 		}
-		env := AppEnvConfig{
-			InfraName:       "dodo",
-			Id:              "id",
-			ContactEmail:    "foo@bar.ge",
-			Domain:          "bar.ge",
-			PrivateDomain:   "p.bar.ge",
-			PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
-			NamespacePrefix: "id-",
-		}
 		values := map[string]any{
 			"network":   "Public",
 			"subdomain": "woof",
@@ -96,15 +97,6 @@
 	release := Release{
 		Namespace: "foo",
 	}
-	env := AppEnvConfig{
-		InfraName:       "dodo",
-		Id:              "id",
-		ContactEmail:    "foo@bar.ge",
-		Domain:          "bar.ge",
-		PrivateDomain:   "p.bar.ge",
-		PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
-		NamespacePrefix: "id-",
-	}
 	values := map[string]any{
 		"authGroups": "foo,bar",
 	}
@@ -129,15 +121,6 @@
 	release := Release{
 		Namespace: "foo",
 	}
-	env := AppEnvConfig{
-		InfraName:       "dodo",
-		Id:              "id",
-		ContactEmail:    "foo@bar.ge",
-		Domain:          "bar.ge",
-		PrivateDomain:   "p.bar.ge",
-		PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
-		NamespacePrefix: "id-",
-	}
 	values := map[string]any{
 		"subdomain": "gerrit",
 		"network":   "Private",
@@ -168,15 +151,6 @@
 	release := Release{
 		Namespace: "foo",
 	}
-	env := AppEnvConfig{
-		InfraName:       "dodo",
-		Id:              "id",
-		ContactEmail:    "foo@bar.ge",
-		Domain:          "bar.ge",
-		PrivateDomain:   "p.bar.ge",
-		PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
-		NamespacePrefix: "id-",
-	}
 	values := map[string]any{
 		"subdomain": "jenkins",
 		"network":   "Private",
@@ -202,7 +176,7 @@
 	release := Release{
 		Namespace: "foo",
 	}
-	env := InfraConfig{
+	infra := InfraConfig{
 		Name:                 "dodo",
 		PublicIP:             []net.IP{net.ParseIP("1.2.3.4")},
 		InfraNamespacePrefix: "id-",
@@ -211,7 +185,7 @@
 	values := map[string]any{
 		"sshPrivateKey": "private",
 	}
-	rendered, err := a.Render(release, env, values)
+	rendered, err := a.Render(release, infra, values)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -232,15 +206,6 @@
 	release := Release{
 		Namespace: "foo",
 	}
-	env := AppEnvConfig{
-		InfraName:       "dodo",
-		Id:              "id",
-		ContactEmail:    "foo@bar.ge",
-		Domain:          "bar.ge",
-		PrivateDomain:   "p.bar.ge",
-		PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
-		NamespacePrefix: "id-",
-	}
 	values := map[string]any{
 		"privateNetwork": map[string]any{
 			"hostname": "foo",
@@ -264,8 +229,9 @@
 		t.Fatal(err)
 	}
 	app, err := NewCueEnvApp(CueAppData{
-		"base.cue": []byte(cueBaseConfig),
-		"app.cue":  []byte(contents),
+		"base.cue":   []byte(cueBaseConfig),
+		"app.cue":    []byte(contents),
+		"global.cue": []byte(cueEnvAppGlobal),
 	})
 	if err != nil {
 		t.Fatal(err)
@@ -273,15 +239,6 @@
 	release := Release{
 		Namespace: "foo",
 	}
-	env := AppEnvConfig{
-		InfraName:       "dodo",
-		Id:              "id",
-		ContactEmail:    "foo@bar.ge",
-		Domain:          "bar.ge",
-		PrivateDomain:   "p.bar.ge",
-		PublicIP:        []net.IP{net.ParseIP("1.2.3.4")},
-		NamespacePrefix: "id-",
-	}
 	values := map[string]any{
 		"network":   "Public",
 		"subdomain": "woof",
@@ -301,3 +258,43 @@
 		t.Log(string(r))
 	}
 }
+
+func TestDNSGateway(t *testing.T) {
+	contents, err := valuesTmpls.ReadFile("values-tmpl/dns-gateway.cue")
+	if err != nil {
+		t.Fatal(err)
+	}
+	app, err := NewCueInfraApp(CueAppData{
+		"base.cue":   []byte(cueBaseConfig),
+		"app.cue":    []byte(contents),
+		"global.cue": []byte(cueInfraAppGlobal),
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	release := Release{
+		Namespace:     "foo",
+		AppInstanceId: "dns-gateway",
+		RepoAddr:      "ssh://192.168.100.210:22/config",
+		AppDir:        "/infrastructure/gns-gateway",
+	}
+	infra := InfraConfig{
+		Name:                 "dodo",
+		PublicIP:             []net.IP{net.ParseIP("135.181.48.180"), net.ParseIP("65.108.39.172")},
+		InfraNamespacePrefix: "dodo-",
+		InfraAdminPublicKey:  []byte("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/ZRj0QJ0j+3udh0ANN9mJyEzrATZIOAHfNikDMpSHqrVbPZqpeHGbdYrSksCvXPXfissIZoYU4CCXX007jY0W6e1mPf1nObYh2eUT1dHo/8UtGaf9nYk+kEGU/k3utN4Uzkxa13IFh9pYERX+o0Ad3X5wh0vi5hjOBAJVKOCD9d3aipeR9piUb+qrkFDXf9fozMFn7D9nALkpJBVuGxwl/76f8K6hRxBEmPqZwIMfklzX15nRdLEcsGFJpYLYXsonbr1P3moMJFBBbQFv6M6JO9rrwA+swXpWMoScI7m/nziSEPLAb+ziv+/OyhqzeC9CQner73V0m8+2DmtcgTuSe1qHRtOScPyIjBfxoXaUx1IUkgq1NXt8k+EBO2mxnVpKdyDCvwT1Tb7088P8f8cSLtUOmUdEiAhB8bfQFprzm2KrlufenfhMvdvQPU4VfWlkQ4smLYt2yVaaXoxZMy5yD3X6LFurNXwee/Gn6di+DWqsASAOsmpsNgSCGhT8wxM= lekva@gl-mbp-m1-max.local"),
+	}
+	values := map[string]any{
+		"servers": []EnvDNS{EnvDNS{"v1.dodo.cloud", "10.0.1.2"}},
+	}
+	rendered, err := app.Render(release, infra, values)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, r := range rendered.Resources {
+		t.Log(string(r))
+	}
+	for _, r := range rendered.Data {
+		t.Log(string(r))
+	}
+}
diff --git a/core/installer/bootstrapper.go b/core/installer/bootstrapper.go
index 9808676..298efc3 100644
--- a/core/installer/bootstrapper.go
+++ b/core/installer/bootstrapper.go
@@ -14,6 +14,7 @@
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/chart/loader"
 
+	"github.com/giolekva/pcloud/core/installer/io"
 	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
@@ -24,14 +25,15 @@
 const dnsAPIConfigMapName = "api-config"
 
 type Bootstrapper struct {
-	cl      ChartLoader
-	ns      NamespaceCreator
-	ha      HelmActionConfigFactory
-	appRepo AppRepository
+	cl         ChartLoader
+	ns         NamespaceCreator
+	ha         HelmActionConfigFactory
+	appRepo    AppRepository
+	repoClient soft.ClientGetter
 }
 
-func NewBootstrapper(cl ChartLoader, ns NamespaceCreator, ha HelmActionConfigFactory, appRepo AppRepository) Bootstrapper {
-	return Bootstrapper{cl, ns, ha, appRepo}
+func NewBootstrapper(cl ChartLoader, ns NamespaceCreator, ha HelmActionConfigFactory, appRepo AppRepository, repoClient soft.ClientGetter) Bootstrapper {
+	return Bootstrapper{cl, ns, ha, appRepo, repoClient}
 }
 
 func (b Bootstrapper) findApp(name string) (InfraApp, error) {
@@ -64,7 +66,7 @@
 		return err
 	}
 	time.Sleep(30 * time.Second)
-	ss, err := soft.WaitForClient(
+	ss, err := b.repoClient.Get(
 		netip.AddrPortFrom(env.ServiceIPs.ConfigRepo, 22).String(),
 		bootstrapJobKeys.RawPrivateKey(),
 		log.Default())
@@ -83,15 +85,11 @@
 		return err
 	}
 	fmt.Println("Fluxcd installed")
-	repo, err := ss.GetRepo("config")
+	repoIO, err := ss.GetRepo("config")
 	if err != nil {
 		fmt.Println("Failed to get config repo")
 		return err
 	}
-	repoIO, err := NewRepoIO(repo, ss.Signer)
-	if err != nil {
-		return err
-	}
 	mgr, err := NewInfraAppManager(repoIO, b.ns)
 	if err != nil {
 		return err
@@ -325,7 +323,7 @@
 	return nil
 }
 
-func (b Bootstrapper) installFluxcd(ss *soft.Client, envName string) error {
+func (b Bootstrapper) installFluxcd(ss soft.Client, envName string) error {
 	keys, err := NewSSHKeyPair("fluxcd")
 	if err != nil {
 		return err
@@ -339,15 +337,11 @@
 	if err := ss.AddRepository("config"); err != nil {
 		return err
 	}
-	repo, err := ss.GetRepo("config")
+	repoIO, err := ss.GetRepo("config")
 	if err != nil {
 		return err
 	}
-	repoIO, err := NewRepoIO(repo, ss.Signer)
-	if err != nil {
-		return err
-	}
-	if err := repoIO.Do(func(r RepoFS) (string, error) {
+	if err := repoIO.Do(func(r soft.RepoFS) (string, error) {
 		w, err := r.Writer("README.md")
 		if err != nil {
 			return "", err
@@ -364,7 +358,7 @@
 	if err != nil {
 		return err
 	}
-	host := strings.Split(ss.Addr, ":")[0]
+	host := strings.Split(ss.Address(), ":")[0]
 	if err := b.installFluxBootstrap(
 		ss.GetRepoAddress("config"),
 		host,
@@ -440,9 +434,9 @@
 	return nil
 }
 
-func configureMainRepo(repo RepoIO, bootstrap BootstrapConfig) error {
-	return repo.Do(func(r RepoFS) (string, error) {
-		if err := WriteYaml(r, "bootstrap-config.yaml", bootstrap); err != nil {
+func configureMainRepo(repo soft.RepoIO, bootstrap BootstrapConfig) error {
+	return repo.Do(func(r soft.RepoFS) (string, error) {
+		if err := soft.WriteYaml(r, "bootstrap-config.yaml", bootstrap); err != nil {
 			return "", err
 		}
 		infra := InfraConfig{
@@ -451,19 +445,19 @@
 			InfraNamespacePrefix: bootstrap.NamespacePrefix,
 			InfraAdminPublicKey:  bootstrap.AdminPublicKey,
 		}
-		if err := WriteYaml(r, "config.yaml", infra); err != nil {
+		if err := soft.WriteYaml(r, "config.yaml", infra); err != nil {
 			return "", err
 		}
-		if err := WriteYaml(r, "env-cidrs.yaml", EnvCIDRs{}); err != nil {
+		if err := soft.WriteYaml(r, "env-cidrs.yaml", EnvCIDRs{}); err != nil {
 			return "", err
 		}
-		kust := NewKustomization()
+		kust := io.NewKustomization()
 		kust.AddResources(
 			fmt.Sprintf("%s-flux", bootstrap.InfraName),
 			"infrastructure",
 			"environments",
 		)
-		if err := WriteYaml(r, "kustomization.yaml", kust); err != nil {
+		if err := soft.WriteYaml(r, "kustomization.yaml", kust); err != nil {
 			return "", err
 		}
 		{
@@ -488,19 +482,19 @@
 				return "", err
 			}
 		}
-		infraKust := NewKustomization()
+		infraKust := io.NewKustomization()
 		infraKust.AddResources("pcloud-charts.yaml")
-		if err := WriteYaml(r, "infrastructure/kustomization.yaml", infraKust); err != nil {
+		if err := soft.WriteYaml(r, "infrastructure/kustomization.yaml", infraKust); err != nil {
 			return "", err
 		}
-		if err := WriteYaml(r, "environments/kustomization.yaml", NewKustomization()); err != nil {
+		if err := soft.WriteYaml(r, "environments/kustomization.yaml", io.NewKustomization()); err != nil {
 			return "", err
 		}
 		return "initialize pcloud directory structure", nil
 	})
 }
 
-func (b Bootstrapper) installEnvManager(mgr *InfraAppManager, ss *soft.Client, env BootstrapConfig) error {
+func (b Bootstrapper) installEnvManager(mgr *InfraAppManager, ss soft.Client, env BootstrapConfig) error {
 	keys, err := NewSSHKeyPair("env-manager")
 	if err != nil {
 		return err
@@ -526,7 +520,7 @@
 	})
 }
 
-func (b Bootstrapper) installIngressPublic(mgr *InfraAppManager, ss *soft.Client, env BootstrapConfig) error {
+func (b Bootstrapper) installIngressPublic(mgr *InfraAppManager, ss soft.Client, env BootstrapConfig) error {
 	keys, err := NewSSHKeyPair("port-allocator")
 	if err != nil {
 		return err
@@ -560,27 +554,18 @@
 }
 
 func (b Bootstrapper) installDNSZoneManager(mgr *InfraAppManager, env BootstrapConfig) error {
-	const (
-		volumeClaimName = "dns-zone-configs"
-		volumeMountPath = "/etc/pcloud/dns-zone-configs"
-	)
-	app, err := b.findApp("dns-zone-manager")
+	app, err := b.findApp("dns-gateway")
 	if err != nil {
 		return err
 	}
 	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
 	appDir := filepath.Join("/infrastructure", app.Name())
 	return mgr.Install(app, appDir, namespace, map[string]any{
-		"volume": map[string]any{
-			"claimName": volumeClaimName,
-			"mountPath": volumeMountPath,
-			"size":      "1Gi",
-		},
-		"apiConfigMapName": dnsAPIConfigMapName,
+		"servers": []EnvDNS{},
 	})
 }
 
-func (b Bootstrapper) installFluxcdReconciler(mgr *InfraAppManager, ss *soft.Client, env BootstrapConfig) error {
+func (b Bootstrapper) installFluxcdReconciler(mgr *InfraAppManager, ss soft.Client, env BootstrapConfig) error {
 	app, err := b.findApp("fluxcd-reconciler")
 	if err != nil {
 		return err
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index 7b05081..ee21ba0 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -72,7 +72,7 @@
 		return err
 	}
 	log.Println("Cloned repository")
-	repoIO, err := installer.NewRepoIO(repo, signer)
+	repoIO, err := soft.NewRepoIO(repo, signer)
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/cmd/bootstrap.go b/core/installer/cmd/bootstrap.go
index dc85470..a57c361 100644
--- a/core/installer/cmd/bootstrap.go
+++ b/core/installer/cmd/bootstrap.go
@@ -11,6 +11,7 @@
 	"github.com/spf13/cobra"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 var bootstrapFlags struct {
@@ -110,6 +111,7 @@
 		nsCreator,
 		installer.NewActionConfigFactory(rootFlags.kubeConfig),
 		installer.NewInMemoryAppRepository(installer.CreateAllApps()),
+		soft.RealClientGetter{},
 	)
 	return b.Run(envConfig)
 }
diff --git a/core/installer/cmd/env_manager.go b/core/installer/cmd/env_manager.go
index 0142bee..e6b8499 100644
--- a/core/installer/cmd/env_manager.go
+++ b/core/installer/cmd/env_manager.go
@@ -6,6 +6,8 @@
 	"github.com/spf13/cobra"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/dns"
+	"github.com/giolekva/pcloud/core/installer/http"
 	"github.com/giolekva/pcloud/core/installer/soft"
 	"github.com/giolekva/pcloud/core/installer/welcome"
 )
@@ -50,24 +52,21 @@
 }
 
 func envManagerCmdRun(cmd *cobra.Command, args []string) error {
+	repoClient := soft.RealClientGetter{}
 	sshKey, err := installer.NewSSHKeyPair(envManagerFlags.sshKey)
 	if err != nil {
 		return err
 	}
-	ss, err := soft.WaitForClient(envManagerFlags.repoAddr, sshKey.RawPrivateKey(), log.Default())
+	ss, err := repoClient.Get(envManagerFlags.repoAddr, sshKey.RawPrivateKey(), log.Default())
 	if err != nil {
 		return err
 	}
 	log.Printf("Created Soft Serve client\n")
-	repo, err := ss.GetRepo(envManagerFlags.repoName)
+	repoIO, err := ss.GetRepo(envManagerFlags.repoName)
 	if err != nil {
 		return err
 	}
 	log.Printf("Cloned repo: %s\n", envManagerFlags.repoName)
-	repoIO, err := installer.NewRepoIO(repo, sshKey.Signer())
-	if err != nil {
-		return err
-	}
 	nsCreator, err := newNSCreator()
 	if err != nil {
 		return err
@@ -76,13 +75,17 @@
 	if err != nil {
 		return err
 	}
+	httpClient := http.NewClient()
 	s := welcome.NewEnvServer(
 		envManagerFlags.port,
 		ss,
 		repoIO,
+		repoClient,
 		nsCreator,
 		dnsFetcher,
 		installer.NewFixedLengthRandomNameGenerator(4),
+		httpClient,
+		dns.NewClient(),
 	)
 	log.Printf("Starting server\n")
 	s.Start()
diff --git a/core/installer/cmd/launcher.go b/core/installer/cmd/launcher.go
index be3b208..832f159 100644
--- a/core/installer/cmd/launcher.go
+++ b/core/installer/cmd/launcher.go
@@ -68,7 +68,7 @@
 	if err != nil {
 		return fmt.Errorf("failed cloning repository: %v", err)
 	}
-	repoIO, err := installer.NewRepoIO(repo, signer)
+	repoIO, err := soft.NewRepoIO(repo, signer)
 	if err != nil {
 		return fmt.Errorf("failed initializing RepoIO: %v", err)
 	}
diff --git a/core/installer/cmd/rewrite.go b/core/installer/cmd/rewrite.go
index 5eddc67..44f499a 100644
--- a/core/installer/cmd/rewrite.go
+++ b/core/installer/cmd/rewrite.go
@@ -55,7 +55,7 @@
 		return err
 	}
 	log.Println("Cloned repository")
-	repoIO, err := installer.NewRepoIO(repo, signer)
+	repoIO, err := soft.NewRepoIO(repo, signer)
 	if err != nil {
 		return err
 	}
@@ -84,7 +84,7 @@
 			return err
 		}
 		v := inst.InputToValues(app.Schema())
-		if err := mgr.Update(app, inst.Id, v, installer.WithNoCommit()); err != nil {
+		if err := mgr.Update(app, inst.Id, v, soft.WithNoCommit()); err != nil {
 			return err
 		}
 	}
diff --git a/core/installer/cmd/welcome.go b/core/installer/cmd/welcome.go
index 4390fcf..64820e6 100644
--- a/core/installer/cmd/welcome.go
+++ b/core/installer/cmd/welcome.go
@@ -3,7 +3,6 @@
 import (
 	"os"
 
-	"github.com/giolekva/pcloud/core/installer"
 	"github.com/giolekva/pcloud/core/installer/soft"
 	"github.com/giolekva/pcloud/core/installer/welcome"
 	"github.com/spf13/cobra"
@@ -80,7 +79,7 @@
 	if err != nil {
 		return err
 	}
-	repoIO, err := installer.NewRepoIO(repo, signer)
+	repoIO, err := soft.NewRepoIO(repo, signer)
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/config.go b/core/installer/config.go
index 99ae358..039371d 100644
--- a/core/installer/config.go
+++ b/core/installer/config.go
@@ -5,6 +5,11 @@
 	"net/netip"
 )
 
+type EnvDNS struct {
+	Zone    string `json:"zone,omitempty"`
+	Address string `json:"address,omitempty"`
+}
+
 type EnvServiceIPs struct {
 	ConfigRepo    netip.Addr `json:"configRepo"`
 	IngressPublic netip.Addr `json:"ingressPublic"`
diff --git a/core/installer/derived.go b/core/installer/derived.go
index b87b81e..3cc1afb 100644
--- a/core/installer/derived.go
+++ b/core/installer/derived.go
@@ -19,10 +19,19 @@
 	AllocatePortAddr  string `json:"allocatePortAddr,omitempty"`
 }
 
+type InfraAppInstanceConfig struct {
+	Id      string         `json:"id"`
+	AppId   string         `json:"appId"`
+	Infra   InfraConfig    `json:"infra"`
+	Release Release        `json:"release"`
+	Values  map[string]any `json:"values"`
+	Input   map[string]any `json:"input"`
+}
+
 type AppInstanceConfig struct {
 	Id      string         `json:"id"`
 	AppId   string         `json:"appId"`
-	Env     AppEnvConfig   `json:"env"`
+	Env     EnvConfig      `json:"env"`
 	Release Release        `json:"release"`
 	Values  map[string]any `json:"values"`
 	Input   map[string]any `json:"input"`
@@ -65,6 +74,12 @@
 			ret[k] = v
 		case KindInt:
 			ret[k] = v
+		case KindArrayString:
+			a, ok := v.([]string)
+			if !ok {
+				return nil, fmt.Errorf("expected string array")
+			}
+			ret[k] = a
 		case KindNetwork:
 			n, err := findNetwork(networks, v.(string)) // TODO(giolekva): validate
 			if err != nil {
@@ -111,6 +126,12 @@
 			ret[k] = v
 		case KindInt:
 			ret[k] = v
+		case KindArrayString:
+			a, ok := v.([]string)
+			if !ok {
+				return nil, fmt.Errorf("expected string array")
+			}
+			ret[k] = a
 		case KindNetwork:
 			vm, ok := v.(map[string]any)
 			if !ok {
diff --git a/core/installer/dns/client.go b/core/installer/dns/client.go
new file mode 100644
index 0000000..1a13a87
--- /dev/null
+++ b/core/installer/dns/client.go
@@ -0,0 +1,19 @@
+package dns
+
+import (
+	"net"
+)
+
+type Client interface {
+	Lookup(host string) ([]net.IP, error)
+}
+
+type realClient struct{}
+
+func NewClient() Client {
+	return realClient{}
+}
+
+func (c realClient) Lookup(host string) ([]net.IP, error) {
+	return net.LookupIP(host)
+}
diff --git a/core/installer/go.mod b/core/installer/go.mod
index 8e173f2..bf6e083 100644
--- a/core/installer/go.mod
+++ b/core/installer/go.mod
@@ -11,7 +11,6 @@
 	github.com/Masterminds/sprig/v3 v3.2.3
 	github.com/cenkalti/backoff/v4 v4.3.0
 	github.com/charmbracelet/keygen v0.5.0
-	github.com/giolekva/pcloud/core/ns-controller v0.0.0-20240403111418-e9c05499ec80
 	github.com/go-git/go-billy/v5 v5.5.0
 	github.com/go-git/go-git/v5 v5.12.0
 	github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0
@@ -119,11 +118,14 @@
 	github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
+	github.com/onsi/ginkgo/v2 v2.14.0 // indirect
+	github.com/onsi/gomega v1.30.0 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
 	github.com/opencontainers/image-spec v1.1.0 // indirect
 	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
 	github.com/pjbgf/sha1cd v0.3.0 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
+	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/prometheus/client_golang v1.19.0 // indirect
 	github.com/prometheus/client_model v0.6.1 // indirect
 	github.com/prometheus/common v0.52.2 // indirect
@@ -178,7 +180,6 @@
 	k8s.io/kubectl v0.29.3 // indirect
 	k8s.io/utils v0.0.0-20240310230437-4693a0247e57 // indirect
 	oras.land/oras-go v1.2.5 // indirect
-	sigs.k8s.io/controller-runtime v0.17.2 // indirect
 	sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
 	sigs.k8s.io/kustomize/api v0.16.0 // indirect
 	sigs.k8s.io/kustomize/kyaml v0.16.0 // indirect
diff --git a/core/installer/go.sum b/core/installer/go.sum
index 097b1ca..76bb831 100644
--- a/core/installer/go.sum
+++ b/core/installer/go.sum
@@ -126,8 +126,6 @@
 github.com/foxcpp/go-mockdns v1.0.0/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtVlwxvzXTQ4=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/giolekva/pcloud/core/ns-controller v0.0.0-20240403111418-e9c05499ec80 h1:CaNMc5SwslouOKj0sBMwAcvt74ragz7rQWlEEuc3WNM=
-github.com/giolekva/pcloud/core/ns-controller v0.0.0-20240403111418-e9c05499ec80/go.mod h1:m/iEA/wr6Ig1HE1PYuQi2QU2/e4cZGBG4ffF+PojJQ8=
 github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
 github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
 github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
@@ -590,8 +588,6 @@
 k8s.io/utils v0.0.0-20240310230437-4693a0247e57/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
 oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo=
 oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo=
-sigs.k8s.io/controller-runtime v0.17.2 h1:FwHwD1CTUemg0pW2otk7/U5/i5m2ymzvOXdbeGOUvw0=
-sigs.k8s.io/controller-runtime v0.17.2/go.mod h1:+MngTvIQQQhfXtwfdGw/UOQ/aIaqsYywfCINOtwMO/s=
 sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
 sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
 sigs.k8s.io/kustomize/api v0.16.0 h1:/zAR4FOQDCkgSDmVzV2uiFbuy9bhu3jEzthrHCuvm1g=
diff --git a/core/installer/http/client.go b/core/installer/http/client.go
new file mode 100644
index 0000000..14c2060
--- /dev/null
+++ b/core/installer/http/client.go
@@ -0,0 +1,19 @@
+package http
+
+import (
+	"net/http"
+)
+
+type Client interface {
+	Get(addr string) (*http.Response, error)
+}
+
+type realClient struct{}
+
+func (c realClient) Get(addr string) (*http.Response, error) {
+	return http.Get(addr)
+}
+
+func NewClient() Client {
+	return realClient{}
+}
diff --git a/core/installer/kustomization.go b/core/installer/io/kustomization.go
similarity index 98%
rename from core/installer/kustomization.go
rename to core/installer/io/kustomization.go
index 466c140..d21c71d 100644
--- a/core/installer/kustomization.go
+++ b/core/installer/io/kustomization.go
@@ -1,4 +1,4 @@
-package installer
+package io
 
 import (
 	"bytes"
diff --git a/core/installer/kube.go b/core/installer/kube.go
index b7fd9a4..db2fb85 100644
--- a/core/installer/kube.go
+++ b/core/installer/kube.go
@@ -3,19 +3,16 @@
 import (
 	"bytes"
 	"context"
-	"encoding/json"
 	"fmt"
+	"io"
+	"net/http"
 
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/runtime/schema"
-	"k8s.io/client-go/dynamic"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/clientcmd"
-
-	dnsv1 "github.com/giolekva/pcloud/core/ns-controller/api/v1"
 )
 
 type NamespaceCreator interface {
@@ -28,7 +25,7 @@
 }
 
 type ZoneStatusFetcher interface {
-	Fetch(namespace, name string) (error, bool, ZoneInfo)
+	Fetch(addr string) (string, error)
 }
 
 type realNamespaceCreator struct {
@@ -51,26 +48,20 @@
 	return err
 }
 
-type realZoneStatusFetcher struct {
-	clientset dynamic.Interface
-}
+// TODO(gio): take http client
+type realZoneStatusFetcher struct{}
 
-func (f *realZoneStatusFetcher) Fetch(namespace, name string) (error, bool, ZoneInfo) {
-	dnsZoneRes := schema.GroupVersionResource{Group: "dodo.cloud.dodo.cloud", Version: "v1", Resource: "dnszones"}
-	zoneUnstr, err := f.clientset.Resource(dnsZoneRes).Namespace(namespace).Get(context.TODO(), name, metav1.GetOptions{})
-	fmt.Printf("%+v %+v\n", zoneUnstr, err)
+func (f *realZoneStatusFetcher) Fetch(addr string) (string, error) {
+	fmt.Printf("--- %s\n", addr)
+	resp, err := http.Get(addr)
 	if err != nil {
-		return err, false, ZoneInfo{}
+		return "", err
 	}
-	var contents bytes.Buffer
-	if err := json.NewEncoder(&contents).Encode(zoneUnstr.Object); err != nil {
-		return err, false, ZoneInfo{}
+	var buf bytes.Buffer
+	if _, err := io.Copy(&buf, resp.Body); err != nil {
+		return "", err
 	}
-	var zone dnsv1.DNSZone
-	if err := json.NewDecoder(&contents).Decode(&zone); err != nil {
-		return err, false, ZoneInfo{}
-	}
-	return nil, zone.Status.Ready, ZoneInfo{zone.Spec.Zone, zone.Status.RecordsToPublish}
+	return buf.String(), nil
 }
 
 func NewNamespaceCreator(kubeconfig string) (NamespaceCreator, error) {
@@ -82,28 +73,7 @@
 }
 
 func NewZoneStatusFetcher(kubeconfig string) (ZoneStatusFetcher, error) {
-	if kubeconfig == "" {
-		config, err := rest.InClusterConfig()
-		if err != nil {
-			return nil, err
-		}
-		client, err := dynamic.NewForConfig(config)
-		if err != nil {
-			return nil, err
-		}
-		return &realZoneStatusFetcher{client}, nil
-
-	} else {
-		config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
-		if err != nil {
-			return nil, err
-		}
-		client, err := dynamic.NewForConfig(config)
-		if err != nil {
-			return nil, err
-		}
-		return &realZoneStatusFetcher{client}, nil
-	}
+	return &realZoneStatusFetcher{}, nil
 }
 
 func NewKubeConfig(kubeconfig string) (*kubernetes.Clientset, error) {
diff --git a/core/installer/schema.go b/core/installer/schema.go
index de26173..add5ceb 100644
--- a/core/installer/schema.go
+++ b/core/installer/schema.go
@@ -10,14 +10,15 @@
 type Kind int
 
 const (
-	KindBoolean Kind = 0
-	KindInt          = 7
-	KindString       = 1
-	KindStruct       = 2
-	KindNetwork      = 3
-	KindAuth         = 5
-	KindSSHKey       = 6
-	KindNumber       = 4
+	KindBoolean     Kind = 0
+	KindInt              = 7
+	KindString           = 1
+	KindStruct           = 2
+	KindNetwork          = 3
+	KindAuth             = 5
+	KindSSHKey           = 6
+	KindNumber           = 4
+	KindArrayString      = 8
 )
 
 type Schema interface {
@@ -148,6 +149,8 @@
 		return basicSchema{KindNumber}, nil
 	case cue.IntKind:
 		return basicSchema{KindInt}, nil
+	case cue.ListKind:
+		return basicSchema{KindArrayString}, nil
 	case cue.StructKind:
 		if isNetwork(v) {
 			return basicSchema{KindNetwork}, nil
diff --git a/core/installer/soft/client.go b/core/installer/soft/client.go
index ae1206d..269f3d3 100644
--- a/core/installer/soft/client.go
+++ b/core/installer/soft/client.go
@@ -19,21 +19,36 @@
 	"github.com/go-git/go-git/v5/storage/memory"
 )
 
-type Client struct {
-	Addr     string
-	Signer   ssh.Signer
+type Client interface {
+	Address() string
+	Signer() ssh.Signer
+	GetPublicKeys() ([]string, error)
+	GetRepo(name string) (RepoIO, error)
+	GetRepoAddress(name string) string
+	AddRepository(name string) error
+	AddUser(name, pubKey string) error
+	AddPublicKey(user string, pubKey string) error
+	RemovePublicKey(user string, pubKey string) error
+	MakeUserAdmin(name string) error
+	AddReadWriteCollaborator(repo, user string) error
+	AddReadOnlyCollaborator(repo, user string) error
+}
+
+type realClient struct {
+	addr     string
+	signer   ssh.Signer
 	log      *log.Logger
 	pemBytes []byte
 }
 
-func NewClient(addr string, clientPrivateKey []byte, log *log.Logger) (*Client, error) {
+func NewClient(addr string, clientPrivateKey []byte, log *log.Logger) (Client, error) {
 	signer, err := ssh.ParsePrivateKey(clientPrivateKey)
 	if err != nil {
 		return nil, err
 	}
 	log.SetPrefix("SOFT-SERVE: ")
 	log.Printf("Created signer")
-	return &Client{
+	return &realClient{
 		addr,
 		signer,
 		log,
@@ -41,8 +56,14 @@
 	}, nil
 }
 
-func WaitForClient(addr string, clientPrivateKey []byte, log *log.Logger) (*Client, error) {
-	var client *Client
+type ClientGetter interface {
+	Get(addr string, clientPrivateKey []byte, log *log.Logger) (Client, error)
+}
+
+type RealClientGetter struct{}
+
+func (c RealClientGetter) Get(addr string, clientPrivateKey []byte, log *log.Logger) (Client, error) {
+	var client Client
 	err := backoff.RetryNotify(func() error {
 		var err error
 		client, err = NewClient(addr, clientPrivateKey, log)
@@ -59,7 +80,15 @@
 	return client, err
 }
 
-func (ss *Client) AddUser(name, pubKey string) error {
+func (ss *realClient) Address() string {
+	return ss.addr
+}
+
+func (ss *realClient) Signer() ssh.Signer {
+	return ss.signer
+}
+
+func (ss *realClient) AddUser(name, pubKey string) error {
 	log.Printf("Adding user %s", name)
 	if err := ss.RunCommand("user", "create", name); err != nil {
 		return err
@@ -67,25 +96,25 @@
 	return ss.AddPublicKey(name, pubKey)
 }
 
-func (ss *Client) MakeUserAdmin(name string) error {
+func (ss *realClient) MakeUserAdmin(name string) error {
 	log.Printf("Making user %s admin", name)
 	return ss.RunCommand("user", "set-admin", name, "true")
 }
 
-func (ss *Client) AddPublicKey(user string, pubKey string) error {
+func (ss *realClient) AddPublicKey(user string, pubKey string) error {
 	log.Printf("Adding public key: %s %s\n", user, pubKey)
 	return ss.RunCommand("user", "add-pubkey", user, pubKey)
 }
 
-func (ss *Client) RemovePublicKey(user string, pubKey string) error {
+func (ss *realClient) RemovePublicKey(user string, pubKey string) error {
 	log.Printf("Removing public key: %s %s\n", user, pubKey)
 	return ss.RunCommand("user", "remove-pubkey", user, pubKey)
 }
 
-func (ss *Client) RunCommand(args ...string) error {
+func (ss *realClient) RunCommand(args ...string) error {
 	cmd := strings.Join(args, " ")
 	log.Printf("Running command %s", cmd)
-	client, err := ssh.Dial("tcp", ss.Addr, ss.sshClientConfig())
+	client, err := ssh.Dial("tcp", ss.addr, ss.sshClientConfig())
 	if err != nil {
 		return err
 	}
@@ -100,17 +129,17 @@
 	return session.Run(cmd)
 }
 
-func (ss *Client) AddRepository(name string) error {
+func (ss *realClient) AddRepository(name string) error {
 	log.Printf("Adding repository %s", name)
 	return ss.RunCommand("repo", "create", name)
 }
 
-func (ss *Client) AddReadWriteCollaborator(repo, user string) error {
+func (ss *realClient) AddReadWriteCollaborator(repo, user string) error {
 	log.Printf("Adding read-write collaborator %s %s", repo, user)
 	return ss.RunCommand("repo", "collab", "add", repo, user, "read-write")
 }
 
-func (ss *Client) AddReadOnlyCollaborator(repo, user string) error {
+func (ss *realClient) AddReadOnlyCollaborator(repo, user string) error {
 	log.Printf("Adding read-only collaborator %s %s", repo, user)
 	return ss.RunCommand("repo", "collab", "add", repo, user, "read-only")
 }
@@ -120,8 +149,12 @@
 	Addr RepositoryAddress
 }
 
-func (ss *Client) GetRepo(name string) (*Repository, error) {
-	return CloneRepository(RepositoryAddress{ss.Addr, name}, ss.Signer)
+func (ss *realClient) GetRepo(name string) (RepoIO, error) {
+	r, err := CloneRepository(RepositoryAddress{ss.addr, name}, ss.signer)
+	if err != nil {
+		return nil, err
+	}
+	return NewRepoIO(r, ss.signer)
 }
 
 type RepositoryAddress struct {
@@ -172,7 +205,7 @@
 }
 
 // TODO(giolekva): dead code
-func (ss *Client) authSSH() gitssh.AuthMethod {
+func (ss *realClient) authSSH() gitssh.AuthMethod {
 	a, err := gitssh.NewPublicKeys("git", ss.pemBytes, "")
 	if err != nil {
 		panic(err)
@@ -196,10 +229,10 @@
 	// }
 }
 
-func (ss *Client) authGit() *gitssh.PublicKeys {
+func (ss *realClient) authGit() *gitssh.PublicKeys {
 	return &gitssh.PublicKeys{
 		User:   "git",
-		Signer: ss.Signer,
+		Signer: ss.signer,
 		HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{
 			HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
 				// TODO(giolekva): verify server public key
@@ -210,18 +243,18 @@
 	}
 }
 
-func (ss *Client) GetPublicKeys() ([]string, error) {
+func (ss *realClient) GetPublicKeys() ([]string, error) {
 	var ret []string
 	config := &ssh.ClientConfig{
 		Auth: []ssh.AuthMethod{
-			ssh.PublicKeys(ss.Signer),
+			ssh.PublicKeys(ss.signer),
 		},
 		HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
 			ret = append(ret, string(ssh.MarshalAuthorizedKey(key)))
 			return nil
 		},
 	}
-	client, err := ssh.Dial("tcp", ss.Addr, config)
+	client, err := ssh.Dial("tcp", ss.addr, config)
 	if err != nil {
 		return nil, err
 	}
@@ -229,10 +262,10 @@
 	return ret, nil
 }
 
-func (ss *Client) sshClientConfig() *ssh.ClientConfig {
+func (ss *realClient) sshClientConfig() *ssh.ClientConfig {
 	return &ssh.ClientConfig{
 		Auth: []ssh.AuthMethod{
-			ssh.PublicKeys(ss.Signer),
+			ssh.PublicKeys(ss.signer),
 		},
 		HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
 			// TODO(giolekva): verify server public key
@@ -243,10 +276,10 @@
 	}
 }
 
-func (ss *Client) GetRepoAddress(name string) string {
+func (ss *realClient) GetRepoAddress(name string) string {
 	return fmt.Sprintf("%s/%s", ss.addressGit(), name)
 }
 
-func (ss *Client) addressGit() string {
-	return fmt.Sprintf("ssh://%s", ss.Addr)
+func (ss *realClient) addressGit() string {
+	return fmt.Sprintf("ssh://%s", ss.addr)
 }
diff --git a/core/installer/repoio.go b/core/installer/soft/repoio.go
similarity index 91%
rename from core/installer/repoio.go
rename to core/installer/soft/repoio.go
index 944a0a2..6a5097a 100644
--- a/core/installer/repoio.go
+++ b/core/installer/soft/repoio.go
@@ -1,4 +1,4 @@
-package installer
+package soft
 
 import (
 	"encoding/json"
@@ -11,6 +11,8 @@
 	"sync"
 	"time"
 
+	pio "github.com/giolekva/pcloud/core/installer/io"
+
 	"github.com/go-git/go-billy/v5"
 	"github.com/go-git/go-billy/v5/util"
 	"github.com/go-git/go-git/v5"
@@ -18,8 +20,6 @@
 	gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
 	"golang.org/x/crypto/ssh"
 	"sigs.k8s.io/yaml"
-
-	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 type RepoFS interface {
@@ -55,6 +55,10 @@
 	fs billy.Filesystem
 }
 
+func NewBillyRepoFS(fs billy.Filesystem) RepoFS {
+	return &repoFS{fs}
+}
+
 func (r *repoFS) Reader(path string) (io.ReadCloser, error) {
 	return r.fs.Open(path)
 }
@@ -82,12 +86,12 @@
 
 type repoIO struct {
 	*repoFS
-	repo   *soft.Repository
+	repo   *Repository
 	signer ssh.Signer
 	l      sync.Locker
 }
 
-func NewRepoIO(repo *soft.Repository, signer ssh.Signer) (RepoIO, error) {
+func NewRepoIO(repo *Repository, signer ssh.Signer) (RepoIO, error) {
 	wt, err := repo.Worktree()
 	if err != nil {
 		return nil, err
@@ -198,7 +202,7 @@
 }
 
 func WriteYaml(repo RepoFS, path string, data any) error {
-	if d, ok := data.(*Kustomization); ok {
+	if d, ok := data.(*pio.Kustomization); ok {
 		data = d
 	}
 	out, err := repo.Writer(path)
@@ -225,7 +229,7 @@
 }
 
 func WriteJson(repo RepoFS, path string, data any) error {
-	if d, ok := data.(*Kustomization); ok {
+	if d, ok := data.(*pio.Kustomization); ok {
 		data = d
 	}
 	w, err := repo.Writer(path)
@@ -237,8 +241,8 @@
 	return e.Encode(data)
 }
 
-func ReadKustomization(repo RepoFS, path string) (*Kustomization, error) {
-	ret := &Kustomization{}
+func ReadKustomization(repo RepoFS, path string) (*pio.Kustomization, error) {
+	ret := &pio.Kustomization{}
 	if err := ReadYaml(repo, path, &ret); err != nil {
 		return nil, err
 	}
diff --git a/core/installer/tasks/activate.go b/core/installer/tasks/activate.go
index ccbdbb8..6916262 100644
--- a/core/installer/tasks/activate.go
+++ b/core/installer/tasks/activate.go
@@ -10,12 +10,13 @@
 	"text/template"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 //go:embed env-tmpl
 var filesTmpls embed.FS
 
-func NewActivateEnvTask(env Env, st *state) Task {
+func NewActivateEnvTask(env installer.EnvConfig, st *state) Task {
 	return newSequentialParentTask(
 		"Activate GitOps",
 		false,
@@ -24,19 +25,19 @@
 	)
 }
 
-func AddNewEnvTask(env Env, st *state) Task {
+func AddNewEnvTask(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Commit initial configuration", func() error {
 		ssPublicKeys, err := st.ssClient.GetPublicKeys()
 		if err != nil {
 			return err
 		}
-		repoHost := strings.Split(st.ssClient.Addr, ":")[0]
-		return st.repo.Do(func(r installer.RepoFS) (string, error) {
-			kust, err := installer.ReadKustomization(r, "environments/kustomization.yaml")
+		repoHost := strings.Split(st.ssClient.Address(), ":")[0]
+		return st.repo.Do(func(r soft.RepoFS) (string, error) {
+			kust, err := soft.ReadKustomization(r, "environments/kustomization.yaml")
 			if err != nil {
 				return "", err
 			}
-			kust.AddResources(env.Name)
+			kust.AddResources(env.Id)
 			tmpls, err := template.ParseFS(filesTmpls, "env-tmpl/*.yaml")
 			if err != nil {
 				return "", err
@@ -46,14 +47,14 @@
 				fmt.Fprintf(&knownHosts, "%s %s\n", repoHost, key)
 			}
 			for _, tmpl := range tmpls.Templates() { // TODO(gio): migrate to cue
-				dstPath := path.Join("environments", env.Name, tmpl.Name())
+				dstPath := path.Join("environments", env.Id, tmpl.Name())
 				dst, err := r.Writer(dstPath)
 				if err != nil {
 					return "", err
 				}
 				defer dst.Close()
 				if err := tmpl.Execute(dst, map[string]string{
-					"Name":       env.Name,
+					"Name":       env.Id,
 					"PrivateKey": base64.StdEncoding.EncodeToString(st.keys.RawPrivateKey()),
 					"PublicKey":  base64.StdEncoding.EncodeToString(st.keys.RawAuthorizedKey()),
 					"RepoHost":   repoHost,
@@ -63,10 +64,10 @@
 					return "", err
 				}
 			}
-			if err := installer.WriteYaml(r, "environments/kustomization.yaml", kust); err != nil {
+			if err := soft.WriteYaml(r, "environments/kustomization.yaml", kust); err != nil {
 				return "", err
 			}
-			return fmt.Sprintf("%s: initialize environment", env.Name), nil
+			return fmt.Sprintf("%s: initialize environment", env.Id), nil
 		})
 	})
 	return &t
diff --git a/core/installer/tasks/dns.go b/core/installer/tasks/dns.go
index 27e044d..51b066a 100644
--- a/core/installer/tasks/dns.go
+++ b/core/installer/tasks/dns.go
@@ -2,24 +2,24 @@
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"net"
-	"text/template"
+	"strings"
 	"time"
 
-	"github.com/Masterminds/sprig/v3"
-
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/dns"
 )
 
 type Check func(ch Check) error
 
-func SetupZoneTask(env Env, ingressIP net.IP, st *state) Task {
+func SetupZoneTask(env installer.EnvConfig, mgr *installer.InfraAppManager, st *state) Task {
 	ret := newSequentialParentTask(
 		"Configure DNS",
 		true,
-		CreateZoneRecords(env.Domain, st.publicIPs, ingressIP, env, st),
-		WaitToPropagate(env.Domain, st.publicIPs),
+		SetupDNSServer(env, st),
+		WaitToPropagate(st.dnsClient, env.Domain, env.PublicIP),
 	)
 	ret.beforeStart = func() {
 		st.infoListener(fmt.Sprintf("Generating DNS zone records for %s", env.Domain))
@@ -30,100 +30,86 @@
 	return ret
 }
 
-func CreateZoneRecords(
-	name string,
-	expected []net.IP,
-	ingressIP net.IP,
-	env Env,
-	st *state,
-) Task {
-	t := newLeafTask("Generate and publish DNS records", func() error {
-		key, err := newDNSSecKey(env.Domain)
-		if err != nil {
-			return err
-		}
-		repo, err := st.ssClient.GetRepo("config")
-		if err != nil {
-			return err
-		}
-		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
-		if err != nil {
-			return err
-		}
-		return r.Do(func(r installer.RepoFS) (string, error) {
-			{
-				out, err := r.Writer("dns-zone.yaml")
-				if err != nil {
-					return "", err
-				}
-				defer out.Close()
-				dnsZoneTmpl, err := template.New("config").Funcs(sprig.TxtFuncMap()).Parse(`
-apiVersion: dodo.cloud.dodo.cloud/v1
-kind: DNSZone
-metadata:
-  name: dns-zone
-  namespace: {{ .namespace }}
-spec:
-  zone: {{ .zone }}
-  privateIP: {{ .ingressIP }}
-  publicIPs:
-{{ range .publicIPs }}
-  - {{ .String }}
-{{ end }}
-  nameservers:
-{{ range .publicIPs }}
-  - {{ .String }}
-{{ end }}
-  dnssec:
-    enabled: true
-    secretName: dnssec-key
----
-apiVersion: v1
-kind: Secret
-metadata:
-  name: dnssec-key
-  namespace: {{ .namespace }}
-type: Opaque
-data:
-  basename: {{ .dnssec.Basename | b64enc }}
-  key: {{ .dnssec.Key | toString | b64enc }}
-  private: {{ .dnssec.Private | toString | b64enc }}
-  ds: {{ .dnssec.DS | toString | b64enc }}
-`)
-				if err != nil {
-					return "", err
-				}
-				if err := dnsZoneTmpl.Execute(out, map[string]any{
-					"namespace": env.Name,
-					"zone":      env.Domain,
-					"dnssec":    key,
-					"publicIPs": st.publicIPs,
-					"ingressIP": ingressIP.String(),
-				}); err != nil {
-					return "", err
-				}
-				rootKust, err := installer.ReadKustomization(r, "kustomization.yaml")
-				if err != nil {
-					return "", err
-				}
-				rootKust.AddResources("dns-zone.yaml")
-				if err := installer.WriteYaml(r, "kustomization.yaml", rootKust); err != nil {
-					return "", err
-				}
-				return "configure dns zone", nil
+func join[T fmt.Stringer](items []T, sep string) string {
+	var tmp []string
+	for _, i := range items {
+		tmp = append(tmp, i.String())
+	}
+	return strings.Join(tmp, ",")
+}
+
+func SetupDNSServer(env installer.EnvConfig, st *state) Task {
+	t := newLeafTask("Start up DNS server", func() error {
+		addressPool := fmt.Sprintf("%s-dns", env.Id)
+		{
+			app, err := installer.FindEnvApp(st.appsRepo, "env-dns")
+			if err != nil {
+				return err
 			}
-		})
+			instanceId := app.Name()
+			appDir := fmt.Sprintf("/apps/%s", instanceId)
+			namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+			if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+				"addressPool":  addressPool,
+				"inClusterIP":  env.Network.DNSInClusterIP.String(),
+				"publicIP":     join(env.PublicIP, ","),
+				"privateIP":    env.Network.Ingress.String(),
+				"nameserverIP": join(env.NameserverIP, ","),
+			}); err != nil {
+				return err
+			}
+		}
+		{
+			app, err := installer.FindInfraApp(st.appsRepo, "dns-gateway")
+			if err != nil {
+				return err
+			}
+			cfg, err := st.infraAppManager.FindInstance("dns-gateway")
+			if err != nil {
+				return err
+			}
+			serversJSON, ok := cfg.Values["servers"]
+			if !ok {
+				serversJSON = []installer.EnvDNS{}
+			}
+			serversTmp, err := json.Marshal(serversJSON)
+			if err != nil {
+				return err
+			}
+			servers := []installer.EnvDNS{}
+			if err := json.Unmarshal(serversTmp, &servers); err != nil {
+				return err
+			}
+			servers = append(servers, installer.EnvDNS{
+				env.Domain,
+				env.Network.DNSInClusterIP.String(),
+			})
+			if err := st.infraAppManager.Update(app, "dns-gateway", map[string]any{
+				"servers": servers,
+			}); err != nil {
+				return err
+			}
+		}
+		{
+			for {
+				if _, err := st.dnsFetcher.Fetch(fmt.Sprintf("http://dns-api.%sdns.svc.cluster.local/records-to-publish", env.NamespacePrefix)); err != nil {
+					time.Sleep(5 * time.Second)
+				} else {
+					break
+				}
+			}
+		}
+		return nil
 	})
 	return &t
 }
 
 func WaitToPropagate(
+	client dns.Client,
 	name string,
 	expected []net.IP,
 ) Task {
 	t := newLeafTask("Wait to propagate", func() error {
-		time.Sleep(2 * time.Minute)
-		return nil
 		ctx := context.TODO()
 		gotExpectedIPs := func(actual []net.IP) bool {
 			for _, a := range actual {
@@ -141,7 +127,7 @@
 			return true
 		}
 		check := func(check Check) error {
-			addrs, err := net.LookupIP(name)
+			addrs, err := client.Lookup(name)
 			fmt.Printf("DNS LOOKUP: %+v\n", addrs)
 			if err == nil && gotExpectedIPs(addrs) {
 				return err
diff --git a/core/installer/tasks/env.go b/core/installer/tasks/env.go
index 68fbe57..a38e5b1 100644
--- a/core/installer/tasks/env.go
+++ b/core/installer/tasks/env.go
@@ -3,21 +3,25 @@
 import (
 	"context"
 	"fmt"
-	"net"
 
 	"github.com/charmbracelet/keygen"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/dns"
+	"github.com/giolekva/pcloud/core/installer/http"
 	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 type state struct {
 	infoListener    EnvInfoListener
-	publicIPs       []net.IP
 	nsCreator       installer.NamespaceCreator
-	repo            installer.RepoIO
+	dnsFetcher      installer.ZoneStatusFetcher
+	httpClient      http.Client
+	dnsClient       dns.Client
+	repo            soft.RepoIO
+	repoClient      soft.ClientGetter
 	ssAdminKeys     *keygen.KeyPair
-	ssClient        *soft.Client
+	ssClient        soft.Client
 	fluxUserName    string
 	keys            *keygen.KeyPair
 	appManager      *installer.AppManager
@@ -25,44 +29,35 @@
 	infraAppManager *installer.InfraAppManager
 }
 
-type Env struct {
-	PCloudEnvName   string
-	Name            string
-	ContactEmail    string
-	Domain          string
-	AdminPublicKey  string
-	NamespacePrefix string
-}
-
 type EnvInfoListener func(string)
 
-type DNSZoneRef struct {
-	Name      string
-	Namespace string
-}
-
 func NewCreateEnvTask(
-	env Env,
-	publicIPs []net.IP,
-	startIP net.IP,
+	env installer.EnvConfig,
 	nsCreator installer.NamespaceCreator,
-	repo installer.RepoIO,
+	dnsFetcher installer.ZoneStatusFetcher,
+	httpClient http.Client,
+	dnsClient dns.Client,
+	repo soft.RepoIO,
+	repoClient soft.ClientGetter,
 	mgr *installer.InfraAppManager,
 	infoListener EnvInfoListener,
-) (Task, DNSZoneRef) {
+) (Task, installer.EnvDNS) {
 	st := state{
 		infoListener:    infoListener,
-		publicIPs:       publicIPs,
 		nsCreator:       nsCreator,
+		dnsFetcher:      dnsFetcher,
+		httpClient:      httpClient,
+		dnsClient:       dnsClient,
 		repo:            repo,
+		repoClient:      repoClient,
 		infraAppManager: mgr,
 	}
 	t := newSequentialParentTask(
 		"Create env",
 		true,
 		SetupConfigRepoTask(env, &st),
-		SetupZoneTask(env, startIP, &st),
-		SetupInfra(env, startIP, &st),
+		SetupZoneTask(env, mgr, &st),
+		SetupInfra(env, &st),
 	)
 	t.afterDone = func() {
 		infoListener(fmt.Sprintf("dodo environment for %s has been provisioned successfully. Visit [https://welcome.%s](https://welcome.%s) to create administrative account and log into the system.", env.Domain, env.Domain, env.Domain))
@@ -73,13 +68,16 @@
 	})
 	pr := NewFluxcdReconciler( // TODO(gio): make reconciler address a flag
 		"http://fluxcd-reconciler.dodo-fluxcd-reconciler.svc.cluster.local",
-		fmt.Sprintf("%s-flux", env.PCloudEnvName),
+		fmt.Sprintf("%s-flux", env.InfraName),
 	)
 	er := NewFluxcdReconciler(
 		"http://fluxcd-reconciler.dodo-fluxcd-reconciler.svc.cluster.local",
-		env.Name,
+		env.Id,
 	)
 	go pr.Reconcile(rctx)
 	go er.Reconcile(rctx)
-	return t, DNSZoneRef{"dns-zone", env.Name}
+	return t, installer.EnvDNS{
+		Zone:    env.Domain,
+		Address: fmt.Sprintf("http://dns-api.%sdns.svc.cluster.local/records-to-publish", env.NamespacePrefix),
+	}
 }
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index 987ff9c..0327e4c 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -2,24 +2,19 @@
 
 import (
 	"fmt"
-	"net"
-	"net/netip"
 	"strings"
 
 	"github.com/miekg/dns"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 var initGroups = []string{"admin"}
 
-func CreateRepoClient(env Env, st *state) Task {
+func CreateRepoClient(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Create repo client", func() error {
-		repo, err := st.ssClient.GetRepo("config")
-		if err != nil {
-			return err
-		}
-		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
+		r, err := st.ssClient.GetRepo("config")
 		if err != nil {
 			return err
 		}
@@ -37,46 +32,30 @@
 	return &t
 }
 
-func SetupInfra(env Env, startIP net.IP, st *state) Task {
+func SetupInfra(env installer.EnvConfig, st *state) Task {
 	return newConcurrentParentTask(
 		"Setup core services",
 		true,
-		SetupNetwork(env, startIP, st),
+		SetupNetwork(env, st),
 		SetupCertificateIssuers(env, st),
 		SetupAuth(env, st),
 		SetupGroupMemberships(env, st),
-		SetupHeadscale(env, startIP, st),
+		SetupHeadscale(env, st),
 		SetupWelcome(env, st),
 		SetupAppStore(env, st),
 		SetupLauncher(env, st),
 	)
 }
 
-func CommitEnvironmentConfiguration(env Env, st *state) Task {
+func CommitEnvironmentConfiguration(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("commit config", func() error {
-		repo, err := st.ssClient.GetRepo("config")
+		r, err := st.ssClient.GetRepo("config")
 		if err != nil {
 			return err
 		}
-		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
-		if err != nil {
-			return err
-		}
-		r.Do(func(r installer.RepoFS) (string, error) {
-			{
-				// TODO(giolekva): private domain can be configurable as well
-				config := installer.AppEnvConfig{
-					Id:              env.Name,
-					InfraName:       env.PCloudEnvName,
-					Domain:          env.Domain,
-					PrivateDomain:   fmt.Sprintf("p.%s", env.Domain),
-					ContactEmail:    env.ContactEmail,
-					PublicIP:        st.publicIPs,
-					NamespacePrefix: fmt.Sprintf("%s-", env.Name),
-				}
-				if err := installer.WriteYaml(r, "config.yaml", config); err != nil {
-					return "", err
-				}
+		r.Do(func(r soft.RepoFS) (string, error) {
+			if err := soft.WriteYaml(r, "config.yaml", env); err != nil {
+				return "", err
 			}
 			out, err := r.Writer("pcloud-charts.yaml")
 			if err != nil {
@@ -94,16 +73,16 @@
   url: https://github.com/giolekva/pcloud
   ref:
     branch: main
-`, env.Name)
+`, env.Id)
 			if err != nil {
 				return "", err
 			}
-			rootKust, err := installer.ReadKustomization(r, "kustomization.yaml")
+			rootKust, err := soft.ReadKustomization(r, "kustomization.yaml")
 			if err != nil {
 				return "", err
 			}
 			rootKust.AddResources("pcloud-charts.yaml")
-			if err := installer.WriteYaml(r, "kustomization.yaml", rootKust); err != nil {
+			if err := soft.WriteYaml(r, "kustomization.yaml", rootKust); err != nil {
 				return "", err
 			}
 			return "configure charts repo", nil
@@ -118,19 +97,15 @@
 	Groups  []string `json:"groups"`
 }
 
-func ConfigureFirstAccount(env Env, st *state) Task {
+func ConfigureFirstAccount(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Configure first account settings", func() error {
-		repo, err := st.ssClient.GetRepo("config")
+		r, err := st.ssClient.GetRepo("config")
 		if err != nil {
 			return err
 		}
-		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
-		if err != nil {
-			return err
-		}
-		return r.Do(func(r installer.RepoFS) (string, error) {
+		return r.Do(func(r soft.RepoFS) (string, error) {
 			fa := firstAccount{false, initGroups}
-			if err := installer.WriteYaml(r, "first-account.yaml", fa); err != nil {
+			if err := soft.WriteYaml(r, "first-account.yaml", fa); err != nil {
 				return "", err
 			}
 			return "first account membership configuration", nil
@@ -139,32 +114,9 @@
 	return &t
 }
 
-func SetupNetwork(env Env, startIP net.IP, st *state) Task {
+func SetupNetwork(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Setup private and public networks", func() error {
-		startAddr, err := netip.ParseAddr(startIP.String())
-		if err != nil {
-			return err
-		}
-		if !startAddr.Is4() {
-			return fmt.Errorf("Expected IPv4, got %s instead", startAddr)
-		}
-		addr := startAddr.AsSlice()
-		if addr[3] != 0 {
-			return fmt.Errorf("Expected last byte to be zero, got %d instead", addr[3])
-		}
-		addr[3] = 10
-		fromIP, ok := netip.AddrFromSlice(addr)
-		if !ok {
-			return fmt.Errorf("Must not reach")
-		}
-		addr[3] = 254
-		toIP, ok := netip.AddrFromSlice(addr)
-		if !ok {
-			return fmt.Errorf("Must not reach")
-		}
 		{
-			ingressPrivateIP := startAddr
-			headscaleIP := ingressPrivateIP.Next()
 			app, err := installer.FindEnvApp(st.appsRepo, "metallb-ipaddresspool")
 			if err != nil {
 				return err
@@ -174,9 +126,9 @@
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
 				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
-					"name":       fmt.Sprintf("%s-ingress-private", env.Name),
-					"from":       ingressPrivateIP.String(),
-					"to":         ingressPrivateIP.String(),
+					"name":       fmt.Sprintf("%s-ingress-private", env.Id),
+					"from":       env.Network.Ingress.String(),
+					"to":         env.Network.Ingress.String(),
 					"autoAssign": false,
 					"namespace":  "metallb-system",
 				}); err != nil {
@@ -188,9 +140,9 @@
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
 				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
-					"name":       fmt.Sprintf("%s-headscale", env.Name),
-					"from":       headscaleIP.String(),
-					"to":         headscaleIP.String(),
+					"name":       fmt.Sprintf("%s-headscale", env.Id),
+					"from":       env.Network.Headscale.String(),
+					"to":         env.Network.Headscale.String(),
 					"autoAssign": false,
 					"namespace":  "metallb-system",
 				}); err != nil {
@@ -202,9 +154,9 @@
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
-					"name":       env.Name,
-					"from":       fromIP.String(),
-					"to":         toIP.String(),
+					"name":       env.Id,
+					"from":       env.Network.ServicesFrom.String(),
+					"to":         env.Network.ServicesTo.String(),
 					"autoAssign": false,
 					"namespace":  "metallb-system",
 				}); err != nil {
@@ -217,7 +169,7 @@
 			if err != nil {
 				return err
 			}
-			user := fmt.Sprintf("%s-port-allocator", env.Name)
+			user := fmt.Sprintf("%s-port-allocator", env.Id)
 			if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
 				return err
 			}
@@ -235,7 +187,7 @@
 				"privateNetwork": map[string]any{
 					"hostname": "private-network-proxy",
 					"username": "private-network-proxy",
-					"ipSubnet": fmt.Sprintf("%s/24", startIP.String()),
+					"ipSubnet": fmt.Sprintf("%s.0/24", strings.Join(strings.Split(env.Network.DNS.String(), ".")[:3], ".")),
 				},
 				"sshPrivateKey": string(keys.RawPrivateKey()),
 			}); err != nil {
@@ -247,7 +199,7 @@
 	return &t
 }
 
-func SetupCertificateIssuers(env Env, st *state) Task {
+func SetupCertificateIssuers(env installer.EnvConfig, st *state) Task {
 	pub := newLeafTask(fmt.Sprintf("Public %s", env.Domain), func() error {
 		app, err := installer.FindEnvApp(st.appsRepo, "certificate-issuer-public")
 		if err != nil {
@@ -269,12 +221,7 @@
 		instanceId := app.Name()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
-			"apiConfigMap": map[string]any{
-				"name":      "api-config", // TODO(gio): take from global pcloud config
-				"namespace": fmt.Sprintf("%s-dns-zone-manager", env.PCloudEnvName),
-			},
-		}); err != nil {
+		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{}); err != nil {
 			return err
 		}
 		return nil
@@ -282,7 +229,7 @@
 	return newSequentialParentTask("Configure TLS certificate issuers", false, &pub, &priv)
 }
 
-func SetupAuth(env Env, st *state) Task {
+func SetupAuth(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Setup", func() error {
 		app, err := installer.FindEnvApp(st.appsRepo, "core-auth")
 		if err != nil {
@@ -302,11 +249,11 @@
 		"Authentication services",
 		false,
 		&t,
-		waitForAddr(fmt.Sprintf("https://accounts-ui.%s", env.Domain)),
+		waitForAddr(st.httpClient, fmt.Sprintf("https://accounts-ui.%s", env.Domain)),
 	)
 }
 
-func SetupGroupMemberships(env Env, st *state) Task {
+func SetupGroupMemberships(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Setup", func() error {
 		app, err := installer.FindEnvApp(st.appsRepo, "memberships")
 		if err != nil {
@@ -326,11 +273,11 @@
 		"Group membership",
 		false,
 		&t,
-		waitForAddr(fmt.Sprintf("https://memberships.p.%s", env.Domain)),
+		waitForAddr(st.httpClient, fmt.Sprintf("https://memberships.p.%s", env.Domain)),
 	)
 }
 
-func SetupHeadscale(env Env, startIP net.IP, st *state) Task {
+func SetupHeadscale(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Setup", func() error {
 		app, err := installer.FindEnvApp(st.appsRepo, "headscale")
 		if err != nil {
@@ -341,7 +288,7 @@
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"subdomain": "headscale",
-			"ipSubnet":  fmt.Sprintf("%s/24", startIP),
+			"ipSubnet":  fmt.Sprintf("%s/24", env.Network.DNS.String()),
 		}); err != nil {
 			return err
 		}
@@ -351,17 +298,17 @@
 		"Setup mesh VPN",
 		false,
 		&t,
-		waitForAddr(fmt.Sprintf("https://headscale.%s/apple", env.Domain)),
+		waitForAddr(st.httpClient, fmt.Sprintf("https://headscale.%s/apple", env.Domain)),
 	)
 }
 
-func SetupWelcome(env Env, st *state) Task {
+func SetupWelcome(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Setup", func() error {
 		keys, err := installer.NewSSHKeyPair("welcome")
 		if err != nil {
 			return err
 		}
-		user := fmt.Sprintf("%s-welcome", env.Name)
+		user := fmt.Sprintf("%s-welcome", env.Id)
 		if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
 			return err
 		}
@@ -387,13 +334,13 @@
 		"Welcome service",
 		false,
 		&t,
-		waitForAddr(fmt.Sprintf("https://welcome.%s", env.Domain)),
+		waitForAddr(st.httpClient, fmt.Sprintf("https://welcome.%s", env.Domain)),
 	)
 }
 
-func SetupAppStore(env Env, st *state) Task {
+func SetupAppStore(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Application marketplace", func() error {
-		user := fmt.Sprintf("%s-appmanager", env.Name)
+		user := fmt.Sprintf("%s-appmanager", env.Id)
 		keys, err := installer.NewSSHKeyPair(user)
 		if err != nil {
 			return err
@@ -423,9 +370,9 @@
 	return &t
 }
 
-func SetupLauncher(env Env, st *state) Task {
+func SetupLauncher(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Application Launcher", func() error {
-		user := fmt.Sprintf("%s-launcher", env.Name)
+		user := fmt.Sprintf("%s-launcher", env.Id)
 		keys, err := installer.NewSSHKeyPair(user)
 		if err != nil {
 			return err
@@ -454,6 +401,7 @@
 	return &t
 }
 
+// TODO(gio-dns): remove
 type DNSSecKey struct {
 	Basename string `json:"basename,omitempty"`
 	Key      []byte `json:"key,omitempty"`
diff --git a/core/installer/tasks/init.go b/core/installer/tasks/init.go
index 4e676cd..20e428d 100644
--- a/core/installer/tasks/init.go
+++ b/core/installer/tasks/init.go
@@ -6,10 +6,11 @@
 	"path/filepath"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/io"
 	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
-func SetupConfigRepoTask(env Env, st *state) Task {
+func SetupConfigRepoTask(env installer.EnvConfig, st *state) Task {
 	ret := newSequentialParentTask(
 		"Configure Git repository",
 		true,
@@ -35,24 +36,24 @@
 	return ret
 }
 
-func NewCreateConfigRepoTask(env Env, st *state) Task {
+func NewCreateConfigRepoTask(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Install Git server", func() error {
 		appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 		app, err := installer.FindInfraApp(appsRepo, "config-repo")
 		if err != nil {
 			return err
 		}
-		adminKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-admin-keys", env.Name))
+		adminKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-admin-keys", env.Id))
 		if err != nil {
 			return err
 		}
 		st.ssAdminKeys = adminKeys
-		keys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-keys", env.Name))
+		keys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-keys", env.Id))
 		if err != nil {
 			return err
 		}
-		appDir := filepath.Join("/environments", env.Name, "config-repo")
-		return st.infraAppManager.Install(app, appDir, env.Name, map[string]any{
+		appDir := filepath.Join("/environments", env.Id, "config-repo")
+		return st.infraAppManager.Install(app, appDir, env.Id, map[string]any{
 			"privateKey": string(keys.RawPrivateKey()),
 			"publicKey":  string(keys.RawAuthorizedKey()),
 			"adminKey":   string(adminKeys.RawAuthorizedKey()),
@@ -61,10 +62,10 @@
 	return &t
 }
 
-func CreateGitClientTask(env Env, st *state) Task {
+func CreateGitClientTask(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Wait git server to come up", func() error {
-		ssClient, err := soft.WaitForClient(
-			fmt.Sprintf("soft-serve.%s.svc.cluster.local:%d", env.Name, 22),
+		ssClient, err := st.repoClient.Get(
+			fmt.Sprintf("soft-serve.%s.svc.cluster.local:%d", env.Id, 22),
 			st.ssAdminKeys.RawPrivateKey(),
 			log.Default())
 		if err != nil {
@@ -85,9 +86,9 @@
 	return &t
 }
 
-func NewInitConfigRepoTask(env Env, st *state) Task {
+func NewInitConfigRepoTask(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Configure access control lists", func() error {
-		st.fluxUserName = fmt.Sprintf("flux-%s", env.Name)
+		st.fluxUserName = fmt.Sprintf("flux-%s", env.Id)
 		keys, err := installer.NewSSHKeyPair(st.fluxUserName)
 		if err != nil {
 			return err
@@ -96,24 +97,20 @@
 		if err := st.ssClient.AddRepository("config"); err != nil {
 			return err
 		}
-		repo, err := st.ssClient.GetRepo("config")
+		repoIO, err := st.ssClient.GetRepo("config")
 		if err != nil {
 			return err
 		}
-		repoIO, err := installer.NewRepoIO(repo, st.ssClient.Signer)
-		if err != nil {
-			return err
-		}
-		if err := repoIO.Do(func(r installer.RepoFS) (string, error) {
+		if err := repoIO.Do(func(r soft.RepoFS) (string, error) {
 			w, err := r.Writer("README.md")
 			if err != nil {
 				return "", err
 			}
 			defer w.Close()
-			if _, err := fmt.Fprintf(w, "# %s PCloud environment", env.Name); err != nil {
+			if _, err := fmt.Fprintf(w, "# %s PCloud environment", env.Id); err != nil {
 				return "", err
 			}
-			if err := installer.WriteYaml(r, "kustomization.yaml", installer.NewKustomization()); err != nil {
+			if err := soft.WriteYaml(r, "kustomization.yaml", io.NewKustomization()); err != nil {
 				return "", err
 			}
 			return "init", nil
diff --git a/core/installer/tasks/tasks.go b/core/installer/tasks/tasks.go
index 1cc053b..3db7042 100644
--- a/core/installer/tasks/tasks.go
+++ b/core/installer/tasks/tasks.go
@@ -1,5 +1,9 @@
 package tasks
 
+import (
+	"fmt"
+)
+
 type Status int
 
 const (
@@ -55,6 +59,9 @@
 }
 
 func (b *basicTask) callDoneListeners(err error) {
+	if err != nil {
+		fmt.Printf("%s %s\n", b.title, err.Error())
+	}
 	for _, l := range b.listeners {
 		go l(err)
 	}
diff --git a/core/installer/tasks/web.go b/core/installer/tasks/web.go
index 5136287..c2625a5 100644
--- a/core/installer/tasks/web.go
+++ b/core/installer/tasks/web.go
@@ -4,12 +4,14 @@
 	"fmt"
 	"net/http"
 	"time"
+
+	phttp "github.com/giolekva/pcloud/core/installer/http"
 )
 
-func waitForAddr(addr string) Task {
+func waitForAddr(client phttp.Client, addr string) Task {
 	t := newLeafTask(fmt.Sprintf("Wait for %s to come up", addr), func() error {
 		for {
-			if resp, err := http.Get(addr); err != nil || resp.StatusCode != http.StatusOK {
+			if resp, err := client.Get(addr); err != nil || resp.StatusCode != http.StatusOK {
 				time.Sleep(2 * time.Second)
 			} else {
 				return nil
diff --git a/core/installer/values-tmpl/cert-manager.cue b/core/installer/values-tmpl/cert-manager.cue
index fdede37..9f9b5d1 100644
--- a/core/installer/values-tmpl/cert-manager.cue
+++ b/core/installer/values-tmpl/cert-manager.cue
@@ -57,7 +57,7 @@
 		chart: charts.certManager
 		dependsOn: [{
 			name: "ingress-public"
-			namespace: _ingressPublic
+			namespace: ingressPublic
 		}]
 		values: {
 			fullnameOverride: "\(global.pcloudEnvName)-cert-manager"
diff --git a/core/installer/values-tmpl/certificate-issuer-private.cue b/core/installer/values-tmpl/certificate-issuer-private.cue
index fc490a3..ee50b49 100644
--- a/core/installer/values-tmpl/certificate-issuer-private.cue
+++ b/core/installer/values-tmpl/certificate-issuer-private.cue
@@ -1,9 +1,4 @@
-input: {
-	apiConfigMap: {
-		name: string
-		namespace: string
-	}
-}
+input: {}
 
 name: "certificate-issuer-private"
 namespace: "ingress-private"
@@ -30,15 +25,15 @@
 		}]
 		values: {
 			issuer: {
-				name: _issuerPrivate
+				name: issuerPrivate
 				server: "https://acme-v02.api.letsencrypt.org/directory"
 				// server: "https://acme-staging-v02.api.letsencrypt.org/directory"
 				domain: global.privateDomain
 				contactEmail: global.contactEmail
 			}
-			apiConfigMap: {
-				name: input.apiConfigMap.name
-				namespace: input.apiConfigMap.namespace
+			config: {
+				createTXTAddr: "http://dns-api.\(global.id)-dns.svc.cluster.local/create-txt-record"
+				deleteTXTAddr: "http://dns-api.\(global.id)-dns.svc.cluster.local/delete-txt-record"
 			}
 		}
 	}
diff --git a/core/installer/values-tmpl/certificate-issuer-public.cue b/core/installer/values-tmpl/certificate-issuer-public.cue
index 58a4bfd..7a5d3ba 100644
--- a/core/installer/values-tmpl/certificate-issuer-public.cue
+++ b/core/installer/values-tmpl/certificate-issuer-public.cue
@@ -25,12 +25,12 @@
 		}]
 		values: {
 			issuer: {
-				name: _issuerPublic
+				name: issuerPublic
 				server: "https://acme-v02.api.letsencrypt.org/directory"
 				// server: "https://acme-staging-v02.api.letsencrypt.org/directory"
 				domain: global.domain
 				contactEmail: global.contactEmail
-				ingressClass: _ingressPublic
+				ingressClass: ingressPublic
 			}
 		}
 	}
diff --git a/core/installer/values-tmpl/core-auth.cue b/core/installer/values-tmpl/core-auth.cue
index 0e9f26f..eb19493 100644
--- a/core/installer/values-tmpl/core-auth.cue
+++ b/core/installer/values-tmpl/core-auth.cue
@@ -160,7 +160,7 @@
 				ingress: {
 					admin: {
 						enabled: true
-						className: _ingressPrivate
+						className: ingressPrivate
 						hosts: [{
 							host: "kratos.\(global.privateDomain)"
 							paths: [{
@@ -176,10 +176,10 @@
 					}
 					public: {
 						enabled: true
-						className: _ingressPublic
+						className: ingressPublic
 						annotations: {
 							"acme.cert-manager.io/http01-edit-in-place": "true"
-							"cert-manager.io/cluster-issuer": _issuerPublic
+							"cert-manager.io/cluster-issuer": issuerPublic
 						}
 						hosts: [{
 							host: "accounts.\(global.domain)"
@@ -342,7 +342,7 @@
 				ingress: {
 					admin: {
 						enabled: true
-						className: _ingressPrivate
+						className: ingressPrivate
 						hosts: [{
 							host: "hydra.\(global.privateDomain)"
 							paths: [{
@@ -356,10 +356,10 @@
 					}
 					public: {
 						enabled: true
-						className: _ingressPublic
+						className: ingressPublic
 						annotations: {
 							"acme.cert-manager.io/http01-edit-in-place": "true"
-							"cert-manager.io/cluster-issuer": _issuerPublic
+							"cert-manager.io/cluster-issuer": issuerPublic
 						}
 						hosts: [{
 							host: "hydra.\(global.domain)"
@@ -455,8 +455,8 @@
 				}
 			}
 			ui: {
-				certificateIssuer: _issuerPublic
-				ingressClassName: _ingressPublic
+				certificateIssuer: issuerPublic
+				ingressClassName: ingressPublic
 				domain: global.domain
 				internalDomain: global.privateDomain
 				hydra: "hydra-admin.\(global.namespacePrefix)core-auth.svc.cluster.local"
diff --git a/core/installer/values-tmpl/dns-gateway.cue b/core/installer/values-tmpl/dns-gateway.cue
new file mode 100644
index 0000000..31b729c
--- /dev/null
+++ b/core/installer/values-tmpl/dns-gateway.cue
@@ -0,0 +1,120 @@
+input: {
+	servers: [...#Server]
+}
+
+#Server: {
+	zone: string
+	address: string
+}
+
+name: "dns-gateway"
+namespace: "dns-gateway"
+
+images: {
+	coredns: {
+		repository: "coredns"
+		name: "coredns"
+		tag: "1.11.1"
+		pullPolicy: "IfNotPresent"
+	}
+}
+
+charts: {
+	coredns: {
+		chart: "charts/coredns"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.pcloudEnvName
+		}
+	}
+}
+
+helm: {
+	coredns: {
+		chart: charts.coredns
+		values: {
+			image: {
+				repository: images.coredns.fullName
+				tag: images.coredns.tag
+				pullPolicy: images.coredns.pullPolicy
+			}
+			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: false
+				pspEnable: false
+			}
+			isClusterService: false
+			if len(input.servers) > 0 {
+				servers: [
+					for s in input.servers {
+						zones: [{
+							zone: s.zone
+						}]
+						port: 53
+						plugins: [{
+							name: "log"
+						}, {
+							name: "forward"
+							parameters: ". \(s.address)"
+						}, {
+							name: "health"
+							configBlock: "lameduck 5s"
+						}, {
+							name: "ready"
+						}]
+					}
+			    ]
+			}
+			if len(input.servers) == 0 {
+				servers: [{
+					zones: [{
+						zone: "."
+					}]
+					port: 53
+					plugins: [{
+						name: "ready"
+					}]
+				}]
+			}
+			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-manager.cue b/core/installer/values-tmpl/dns-zone-manager.cue
deleted file mode 100644
index 0fc66bf..0000000
--- a/core/installer/values-tmpl/dns-zone-manager.cue
+++ /dev/null
@@ -1,178 +0,0 @@
-input: {
-	apiConfigMapName: string
-	volume: {
-		size: string
-		claimName: string
-		mountPath: string
-	}
-}
-
-name: "dns-zone-manager"
-namespace: "dns-zone-manager"
-
-images: {
-	dnsZoneController: {
-		repository: "giolekva"
-		name: "dns-ns-controller"
-		tag: "latest"
-		pullPolicy: "Always"
-	}
-	kubeRBACProxy: {
-		registry: "gcr.io"
-		repository: "kubebuilder"
-		name: "kube-rbac-proxy"
-		tag: "v0.13.0"
-		pullPolicy: "IfNotPresent"
-	}
-	coredns: {
-		repository: "coredns"
-		name: "coredns"
-		tag: "1.11.1"
-		pullPolicy: "IfNotPresent"
-	}
-}
-
-charts: {
-	volume: {
-		chart: "charts/volumes"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
-	}
-	dnsZoneController: {
-		chart: "charts/dns-ns-controller"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
-	}
-	coredns: {
-		chart: "charts/coredns"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
-	}
-}
-
-_volumeName: "zone-configs"
-
-helm: {
-	volume: {
-		chart: charts.volume
-		values: {
-			name: input.volume.claimName
-			size: input.volume.size
-			accessMode: "ReadWriteMany"
-		}
-	}
-	"dns-zone-controller": {
-		chart: charts.dnsZoneController
-		values: {
-			installCRDs: true
-			apiConfigMapName: input.apiConfigMapName
-			volume: {
-				claimName: input.volume.claimName
-				mountPath: input.volume.mountPath
-			}
-			image: {
-				repository: images.dnsZoneController.fullName
-				tag: images.dnsZoneController.tag
-				pullPolicy: images.dnsZoneController.pullPolicy
-			}
-			kubeRBACProxy: {
-				image: {
-					repository: images.kubeRBACProxy.fullName
-					tag: images.kubeRBACProxy.tag
-					pullPolicy: images.kubeRBACProxy.pullPolicy
-				}
-			}
-		}
-	}
-	coredns: {
-		chart: charts.coredns
-		values: {
-			image: {
-				repository: images.coredns.fullName
-				tag: images.coredns.tag
-				pullPolicy: images.coredns.pullPolicy
-			}
-			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: "\(input.volume.mountPath)/coredns.conf"
-			extraVolumes: [{
-				name: _volumeName
-				persistentVolumeClaim: claimName: input.volume.claimName
-			}]
-			extraVolumeMounts: [{
-				name: _volumeName
-				mountPath: input.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/env-dns.cue b/core/installer/values-tmpl/env-dns.cue
new file mode 100644
index 0000000..5c95a54
--- /dev/null
+++ b/core/installer/values-tmpl/env-dns.cue
@@ -0,0 +1,235 @@
+import (
+	"strings"
+)
+
+input: {}
+
+name: "env-dns"
+namespace: "dns"
+readme: "env-dns"
+description: "Environment local DNS manager"
+icon: ""
+
+images: {
+	coredns: {
+		repository: "coredns"
+		name: "coredns"
+		tag: "1.11.1"
+		pullPolicy: "IfNotPresent"
+	}
+	api: {
+		repository: "giolekva"
+		name: "dns-api"
+		tag: "latest"
+		pullPolicy: "Always"
+	}
+}
+
+charts: {
+	coredns: {
+		chart: "charts/coredns"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.id
+		}
+	}
+	api: {
+		chart: "charts/dns-api"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.id
+		}
+	}
+	volume: {
+		chart: "charts/volumes"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.id
+		}
+	}
+	service: {
+		chart: "charts/service"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.id
+		}
+	}
+	ipAddressPool: {
+		chart: "charts/metallb-ipaddresspool"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.id
+		}
+	}
+}
+
+volumes: {
+	data: {
+		name: "data"
+		accessMode: "ReadWriteMany"
+		size: "5Gi"
+	}
+}
+
+helm: {
+	coredns: {
+		chart: charts.coredns
+		values: {
+			image: {
+				repository: images.coredns.fullName
+				tag: images.coredns.tag
+				pullPolicy: images.coredns.pullPolicy
+			}
+			replicaCount: 1
+			resources: {
+				limits: {
+					cpu: "100m"
+					memory: "128Mi"
+				}
+				requests: {
+					cpu: "100m"
+					memory: "128Mi"
+				}
+			}
+			rollingUpdate: {
+				maxUnavailable: 1
+				maxSurge: "25%"
+			}
+			terminationGracePeriodSeconds: 30
+			serviceType: "LoadBalancer"
+			service: {
+				name: "coredns"
+				annotations: {
+					"metallb.universe.tf/loadBalancerIPs": global.network.dns
+				}
+			}
+			serviceAccount: create: false
+			rbac: {
+				create: false
+				pspEnable: false
+			}
+			isClusterService: false
+			servers: [{
+				zones: [{
+					zone: "."
+				}]
+				port: 53
+				plugins: [
+					{
+						name: "log"
+					},
+					{
+						name: "health"
+						configBlock: "lameduck 5s"
+					},
+					{
+						name: "ready"
+					}
+			    ]
+			}]
+			extraConfig: import: parameters: "\(_mountPath)/coredns.conf"
+			extraVolumes: [{
+				name: volumes.data.name
+				persistentVolumeClaim: claimName: volumes.data.name
+			}]
+			extraVolumeMounts: [{
+				name: volumes.data.name
+				mountPath: _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
+		}
+	}
+	api: {
+		chart: charts.api
+		values: {
+			image: {
+				repository: images.api.fullName
+				tag: images.api.tag
+				pullPolicy: images.api.pullPolicy
+			}
+			config: "coredns.conf"
+			db: "records.db"
+			zone: global.domain
+			publicIP: strings.Join(global.publicIP, ",")
+			privateIP: global.network.ingress
+			nameserverIP: strings.Join(global.nameserverIP, ",")
+			service: type: "ClusterIP"
+			volume: {
+				claimName: volumes.data.name
+				mountPath: _mountPath
+			}
+		}
+	}
+	"data-volume": {
+		chart: charts.volume
+		values: volumes.data
+	}
+	"coredns-svc-cluster": {
+		chart: charts.service
+		values: {
+			name: "dns"
+			type: "LoadBalancer"
+			protocol: "TCP"
+			ports: [{
+				name: "udp-53"
+				port: 53
+				protocol: "UDP"
+				targetPort: 53
+			}]
+			targetPort: "http"
+			selector:{
+				"app.kubernetes.io/instance": "coredns"
+				"app.kubernetes.io/name": "coredns"
+			}
+			annotations: {
+				"metallb.universe.tf/loadBalancerIPs": global.network.dnsInClusterIP
+			}
+		}
+	}
+	"ipaddresspool-dns": {
+		chart: charts.ipAddressPool
+		values: {
+			name: "\(global.id)-dns"
+			autoAssign: false
+			from: global.network.dns
+			to: global.network.dns
+			namespace: "metallb-system"
+		}
+	}
+	"ipaddresspool-dns-in-cluster": {
+		chart: charts.ipAddressPool
+		values: {
+			name: "\(global.id)-dns-in-cluster"
+			autoAssign: false
+			from: global.network.dnsInClusterIP
+			to: global.network.dnsInClusterIP
+			namespace: "metallb-system"
+		}
+	}
+}
+
+_mountPath: "/pcloud"
diff --git a/core/installer/values-tmpl/headscale.cue b/core/installer/values-tmpl/headscale.cue
index fee75ab..08b61ef 100644
--- a/core/installer/values-tmpl/headscale.cue
+++ b/core/installer/values-tmpl/headscale.cue
@@ -74,8 +74,8 @@
 				pullPolicy: images.headscale.pullPolicy
 			}
 			storage: size: "5Gi"
-			ingressClassName: _ingressPublic
-			certificateIssuer: _issuerPublic
+			ingressClassName: ingressPublic
+			certificateIssuer: issuerPublic
 			domain: _domain
 			publicBaseDomain: global.domain
 			ipAddressPool: "\(global.id)-headscale"
diff --git a/core/installer/values-tmpl/ingress-public.cue b/core/installer/values-tmpl/ingress-public.cue
index 2258945..6823a7b 100644
--- a/core/installer/values-tmpl/ingress-public.cue
+++ b/core/installer/values-tmpl/ingress-public.cue
@@ -48,7 +48,7 @@
 	"ingress-public": {
 		chart: charts.ingressNginx
 		values: {
-			fullnameOverride: _ingressPublic
+			fullnameOverride: ingressPublic
 			controller: {
 				kind: "DaemonSet"
 				hostNetwork: true
@@ -56,10 +56,10 @@
 				service: enabled: false
 				ingressClassByName: true
 				ingressClassResource: {
-					name: _ingressPublic
+					name: ingressPublic
 					enabled: true
 					default: false
-					controllerValue: "k8s.io/\(_ingressPublic)"
+					controllerValue: "k8s.io/\(ingressPublic)"
 				}
 				config: {
 					"proxy-body-size": "200M" // TODO(giolekva): configurable
@@ -75,10 +75,10 @@
 				}
 			}
 			tcp: {
-				"53": "\(global.pcloudEnvName)-dns-zone-manager/coredns:53"
+				"53": "\(global.pcloudEnvName)-dns-gateway/coredns:53"
 			}
 			udp: {
-				"53": "\(global.pcloudEnvName)-dns-zone-manager/coredns:53"
+				"53": "\(global.pcloudEnvName)-dns-gateway/coredns:53"
 			}
 		}
 	}
diff --git a/core/installer/values-tmpl/matrix.cue b/core/installer/values-tmpl/matrix.cue
index ca5dc98..97b3aca 100644
--- a/core/installer/values-tmpl/matrix.cue
+++ b/core/installer/values-tmpl/matrix.cue
@@ -88,8 +88,8 @@
 				user: "matrix"
 				password: "matrix"
 			}
-			certificateIssuer: _issuerPublic
-			ingressClassName: _ingressPublic
+			certificateIssuer: issuerPublic
+			ingressClassName: ingressPublic
 			configMerge: {
 				configName: "config-to-merge"
 				fileName: "to-merge.yaml"
diff --git a/core/installer/values-tmpl/private-network.cue b/core/installer/values-tmpl/private-network.cue
index 156b078..1cee202 100644
--- a/core/installer/values-tmpl/private-network.cue
+++ b/core/installer/values-tmpl/private-network.cue
@@ -73,15 +73,15 @@
 					enabled: true
 					type: "LoadBalancer"
 					annotations: {
-						"metallb.universe.tf/address-pool": _ingressPrivate
+						"metallb.universe.tf/address-pool": ingressPrivate
 					}
 				}
 				ingressClassByName: true
 				ingressClassResource: {
-					name: _ingressPrivate
+					name: ingressPrivate
 					enabled: true
 					default: false
-					controllerValue: "k8s.io/\(_ingressPrivate)"
+					controllerValue: "k8s.io/\(ingressPrivate)"
 				}
 				config: {
 					"proxy-body-size": "200M" // TODO(giolekva): configurable
@@ -91,7 +91,7 @@
 					"""
 				}
 				extraArgs: {
-					"default-ssl-certificate": "\(_ingressPrivate)/cert-wildcard.\(global.privateDomain)"
+					"default-ssl-certificate": "\(ingressPrivate)/cert-wildcard.\(global.privateDomain)"
 				}
 				admissionWebhooks: {
 					enabled: false
diff --git a/core/installer/values-tmpl/welcome.cue b/core/installer/values-tmpl/welcome.cue
index 0089ee8..30c6980 100644
--- a/core/installer/values-tmpl/welcome.cue
+++ b/core/installer/values-tmpl/welcome.cue
@@ -40,9 +40,9 @@
 			loginAddr: "https://launcher.\(global.domain)"
 			membershipsInitAddr: "http://memberships-api.\(global.namespacePrefix)core-auth-memberships.svc.cluster.local/api/init"
 			ingress: {
-				className: _ingressPublic
+				className: ingressPublic
 				domain: "welcome.\(global.domain)"
-				certificateIssuer: _issuerPublic
+				certificateIssuer: issuerPublic
 			}
 			clusterRoleName: "\(global.id)-welcome"
 			image: {
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
index 39d5c3c..e8c929d 100644
--- a/core/installer/welcome/appmanager.go
+++ b/core/installer/welcome/appmanager.go
@@ -211,6 +211,7 @@
 		return err
 	}
 	if err := s.m.Update(a, slug, values); err != nil {
+		fmt.Println(err)
 		return err
 	}
 	ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index 219c67c..856526d 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -18,6 +18,8 @@
 	"github.com/gorilla/mux"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/dns"
+	phttp "github.com/giolekva/pcloud/core/installer/http"
 	"github.com/giolekva/pcloud/core/installer/soft"
 	"github.com/giolekva/pcloud/core/installer/tasks"
 )
@@ -78,35 +80,44 @@
 
 type EnvServer struct {
 	port          int
-	ss            *soft.Client
-	repo          installer.RepoIO
+	ss            soft.Client
+	repo          soft.RepoIO
+	repoClient    soft.ClientGetter
 	nsCreator     installer.NamespaceCreator
 	dnsFetcher    installer.ZoneStatusFetcher
 	nameGenerator installer.NameGenerator
-	tasks         map[string]tasks.Task
+	httpClient    phttp.Client
+	dnsClient     dns.Client
+	Tasks         map[string]tasks.Task
 	envInfo       map[string]template.HTML
-	dns           map[string]tasks.DNSZoneRef
+	dns           map[string]installer.EnvDNS
 	dnsPublished  map[string]struct{}
 }
 
 func NewEnvServer(
 	port int,
-	ss *soft.Client,
-	repo installer.RepoIO,
+	ss soft.Client,
+	repo soft.RepoIO,
+	repoClient soft.ClientGetter,
 	nsCreator installer.NamespaceCreator,
 	dnsFetcher installer.ZoneStatusFetcher,
 	nameGenerator installer.NameGenerator,
+	httpClient phttp.Client,
+	dnsClient dns.Client,
 ) *EnvServer {
 	return &EnvServer{
 		port,
 		ss,
 		repo,
+		repoClient,
 		nsCreator,
 		dnsFetcher,
 		nameGenerator,
+		httpClient,
+		dnsClient,
 		make(map[string]tasks.Task),
 		make(map[string]template.HTML),
-		make(map[string]tasks.DNSZoneRef),
+		make(map[string]installer.EnvDNS),
 		make(map[string]struct{}),
 	}
 }
@@ -130,7 +141,7 @@
 		http.Error(w, "Task key not provided", http.StatusBadRequest)
 		return
 	}
-	t, ok := s.tasks[key]
+	t, ok := s.Tasks[key]
 	if !ok {
 		http.Error(w, "Task not found", http.StatusBadRequest)
 		return
@@ -142,15 +153,9 @@
 			http.Error(w, "Task dns configuration not found", http.StatusInternalServerError)
 			return
 		}
-		err, ready, info := s.dnsFetcher.Fetch(dnsRef.Namespace, dnsRef.Name)
-		// TODO(gio): check error type
-		if err != nil && (ready || len(info.Records) > 0) {
-			panic("!! SHOULD NOT REACH !!")
+		if records, err := s.dnsFetcher.Fetch(dnsRef.Address); err == nil {
+			dnsRecords = records
 		}
-		if !ready && len(info.Records) > 0 {
-			panic("!! SHOULD NOT REACH !!")
-		}
-		dnsRecords = info.Records
 	}
 	data := map[string]any{
 		"Root":       t,
@@ -175,13 +180,10 @@
 		http.Error(w, "Task dns configuration not found", http.StatusInternalServerError)
 		return
 	}
-	err, ready, info := s.dnsFetcher.Fetch(dnsRef.Namespace, dnsRef.Name)
-	// TODO(gio): check error type
-	if err != nil && (ready || len(info.Records) > 0) {
-		panic("!! SHOULD NOT REACH !!")
-	}
-	if !ready && len(info.Records) > 0 {
-		panic("!! SHOULD NOT REACH !!")
+	records, err := s.dnsFetcher.Fetch(dnsRef.Address)
+	if err != nil {
+		http.Error(w, "Task dns configuration not found", http.StatusInternalServerError)
+		return
 	}
 	r.ParseForm()
 	if apiToken, err := getFormValue(r.PostForm, "api-token"); err != nil {
@@ -189,8 +191,8 @@
 		return
 	} else {
 		p := NewGandiUpdater(apiToken)
-		zone := strings.Join(strings.Split(info.Zone, ".")[1:], ".") // TODO(gio): this is not gonna work with no subdomain case
-		if err := p.Update(zone, strings.Split(info.Records, "\n")); err != nil {
+		zone := strings.Join(strings.Split(dnsRef.Zone, ".")[1:], ".") // TODO(gio): this is not gonna work with no subdomain case
+		if err := p.Update(zone, strings.Split(records, "\n")); err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
 		}
@@ -332,7 +334,7 @@
 		return
 	}
 	var infra installer.InfraConfig
-	if err := installer.ReadYaml(s.repo, "config.yaml", &infra); err != nil {
+	if err := soft.ReadYaml(s.repo, "config.yaml", &infra); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -347,7 +349,7 @@
 		req.Name = name
 	}
 	var cidrs installer.EnvCIDRs
-	if err := installer.ReadYaml(s.repo, "env-cidrs.yaml", &cidrs); err != nil {
+	if err := soft.ReadYaml(s.repo, "env-cidrs.yaml", &cidrs); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -357,7 +359,7 @@
 		return
 	}
 	cidrs = append(cidrs, installer.EnvCIDR{req.Name, startIP})
-	if err := installer.WriteYaml(s.repo, "env-cidrs.yaml", cidrs); err != nil {
+	if err := soft.WriteYaml(s.repo, "env-cidrs.yaml", cidrs); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -365,6 +367,23 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
+	envNetwork, err := installer.NewEnvNetwork(startIP)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	env := installer.EnvConfig{
+		Id:              req.Name,
+		InfraName:       infra.Name,
+		Domain:          req.Domain,
+		PrivateDomain:   fmt.Sprintf("p.%s", req.Domain),
+		ContactEmail:    req.ContactEmail,
+		AdminPublicKey:  req.AdminPublicKey,
+		PublicIP:        infra.PublicIP,
+		NameserverIP:    infra.PublicIP,
+		NamespacePrefix: fmt.Sprintf("%s-", req.Name),
+		Network:         envNetwork,
+	}
 	key := func() string {
 		for {
 			key, err := s.nameGenerator.Generate()
@@ -377,22 +396,17 @@
 		s.envInfo[key] = template.HTML(markdown.ToHTML([]byte(info), nil, nil))
 	}
 	t, dns := tasks.NewCreateEnvTask(
-		tasks.Env{
-			PCloudEnvName:   infra.Name,
-			Name:            req.Name,
-			ContactEmail:    req.ContactEmail,
-			Domain:          req.Domain,
-			AdminPublicKey:  req.AdminPublicKey,
-			NamespacePrefix: fmt.Sprintf("%s-", req.Name),
-		},
-		infra.PublicIP,
-		startIP,
+		env,
 		s.nsCreator,
+		s.dnsFetcher,
+		s.httpClient,
+		s.dnsClient,
 		s.repo,
+		s.repoClient,
 		mgr,
 		infoUpdater,
 	)
-	s.tasks[key] = t
+	s.Tasks[key] = t
 	s.dns[key] = dns
 	go t.Start()
 	http.Redirect(w, r, fmt.Sprintf("/env/%s", key), http.StatusSeeOther)
diff --git a/core/installer/welcome/env_test.go b/core/installer/welcome/env_test.go
new file mode 100644
index 0000000..a689f54
--- /dev/null
+++ b/core/installer/welcome/env_test.go
@@ -0,0 +1,300 @@
+package welcome
+
+import (
+	"bytes"
+	"encoding/json"
+	"golang.org/x/crypto/ssh"
+	"io"
+	"io/fs"
+	"log"
+	"net"
+	"net/http"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/go-git/go-billy/v5"
+	"github.com/go-git/go-billy/v5/memfs"
+	"github.com/go-git/go-billy/v5/util"
+	// "github.com/go-git/go-git/v5"
+	// "github.com/go-git/go-git/v5/storage/memory"
+
+	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
+)
+
+type fakeNSCreator struct {
+	t *testing.T
+}
+
+func (f fakeNSCreator) Create(name string) error {
+	f.t.Logf("Create namespace: %s", name)
+	return nil
+}
+
+type fakeZoneStatusFetcher struct {
+	t *testing.T
+}
+
+func (f fakeZoneStatusFetcher) Fetch(addr string) (string, error) {
+	f.t.Logf("Fetching status: %s", addr)
+	return addr, nil
+}
+
+type mockRepoIO struct {
+	soft.RepoFS
+	addr string
+	t    *testing.T
+	l    sync.Locker
+}
+
+func (r mockRepoIO) FullAddress() string {
+	return r.addr
+}
+
+func (r mockRepoIO) Pull() error {
+	r.t.Logf("Pull: %s", r.addr)
+	return nil
+}
+
+func (r mockRepoIO) CommitAndPush(message string) error {
+	r.t.Logf("Commit and push: %s", message)
+	return nil
+}
+
+func (r mockRepoIO) Do(op soft.DoFn, _ ...soft.DoOption) error {
+	r.l.Lock()
+	defer r.l.Unlock()
+	msg, err := op(r)
+	if err != nil {
+		return err
+	}
+	return r.CommitAndPush(msg)
+}
+
+type fakeSoftServeClient struct {
+	t     *testing.T
+	envFS billy.Filesystem
+}
+
+func (f fakeSoftServeClient) Address() string {
+	return ""
+}
+
+func (f fakeSoftServeClient) Signer() ssh.Signer {
+	return nil
+}
+
+func (f fakeSoftServeClient) GetPublicKeys() ([]string, error) {
+	return []string{}, nil
+}
+
+func (f fakeSoftServeClient) GetRepo(name string) (soft.RepoIO, error) {
+	var l sync.Mutex
+	return mockRepoIO{soft.NewBillyRepoFS(f.envFS), "foo.bar", f.t, &l}, nil
+}
+
+func (f fakeSoftServeClient) GetRepoAddress(name string) string {
+	return ""
+}
+
+func (f fakeSoftServeClient) AddRepository(name string) error {
+	return nil
+}
+
+func (f fakeSoftServeClient) AddUser(name, pubKey string) error {
+	return nil
+}
+
+func (f fakeSoftServeClient) AddPublicKey(user string, pubKey string) error {
+	return nil
+}
+
+func (f fakeSoftServeClient) RemovePublicKey(user string, pubKey string) error {
+	return nil
+}
+
+func (f fakeSoftServeClient) MakeUserAdmin(name string) error {
+	return nil
+}
+
+func (f fakeSoftServeClient) AddReadWriteCollaborator(repo, user string) error {
+	return nil
+}
+
+func (f fakeSoftServeClient) AddReadOnlyCollaborator(repo, user string) error {
+	return nil
+}
+
+type fakeClientGetter struct {
+	t     *testing.T
+	envFS billy.Filesystem
+}
+
+func (f fakeClientGetter) Get(addr string, clientPrivateKey []byte, log *log.Logger) (soft.Client, error) {
+	return fakeSoftServeClient{f.t, f.envFS}, nil
+}
+
+const infraConfig = `
+infraAdminPublicKey: Zm9vYmFyCg==
+namespacePrefix: infra-
+pcloudEnvName: infra
+publicIP:
+- 1.1.1.1
+- 2.2.2.2
+`
+
+const envCidrs = ``
+
+type fixedNameGenerator struct{}
+
+func (f fixedNameGenerator) Generate() (string, error) {
+	return "test", nil
+}
+
+type fakeHttpClient struct {
+	t      *testing.T
+	counts map[string]int
+}
+
+func (f fakeHttpClient) Get(addr string) (*http.Response, error) {
+	f.t.Logf("HTTP GET: %s", addr)
+	cnt, ok := f.counts[addr]
+	if !ok {
+		cnt = 0
+	}
+	f.counts[addr] = cnt + 1
+	return &http.Response{
+		Status:     "200 OK",
+		StatusCode: http.StatusOK,
+		Proto:      "HTTP/1.0",
+		ProtoMajor: 1,
+		ProtoMinor: 0,
+		Body:       io.NopCloser(strings.NewReader("ok")),
+	}, nil
+}
+
+type fakeDnsClient struct {
+	t      *testing.T
+	counts map[string]int
+}
+
+func (f fakeDnsClient) Lookup(host string) ([]net.IP, error) {
+	f.t.Logf("HTTP GET: %s", host)
+	return []net.IP{net.ParseIP("1.1.1.1"), net.ParseIP("2.2.2.2")}, nil
+}
+
+func TestCreateNewEnv(t *testing.T) {
+	apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+	infraFS := memfs.New()
+	envFS := memfs.New()
+	nsCreator := fakeNSCreator{t}
+	infraRepo := mockRepoIO{soft.NewBillyRepoFS(infraFS), "foo.bar", t, &sync.Mutex{}}
+	infraMgr, err := installer.NewInfraAppManager(infraRepo, nsCreator)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := util.WriteFile(infraFS, "config.yaml", []byte(infraConfig), fs.ModePerm); err != nil {
+		t.Fatal(err)
+	}
+	if err := util.WriteFile(infraFS, "env-cidrs.yaml", []byte(envCidrs), fs.ModePerm); err != nil {
+		t.Fatal(err)
+	}
+	{
+		app, err := installer.FindInfraApp(apps, "dns-gateway")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if err := infraMgr.Install(app, "/infrastructure/dns-gateway", "dns-gateway", map[string]any{
+			"servers": []installer.EnvDNS{},
+		}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	cg := fakeClientGetter{t, envFS}
+	httpClient := fakeHttpClient{t, make(map[string]int)}
+	dnsClient := fakeDnsClient{t, make(map[string]int)}
+	s := NewEnvServer(
+		8181,
+		fakeSoftServeClient{t, envFS},
+		infraRepo,
+		cg,
+		nsCreator,
+		fakeZoneStatusFetcher{t},
+		fixedNameGenerator{},
+		httpClient,
+		dnsClient,
+	)
+	go s.Start()
+	req := createEnvReq{
+		Name:           "test",
+		ContactEmail:   "test@test.t",
+		Domain:         "test.t",
+		AdminPublicKey: "test",
+		SecretToken:    "test",
+	}
+	var buf bytes.Buffer
+	if err := json.NewEncoder(&buf).Encode(req); err != nil {
+		t.Fatal(err)
+	}
+	resp, err := http.Post("http://localhost:8181/", "application/json", &buf)
+	var done sync.WaitGroup
+	done.Add(1)
+	var taskErr error
+	s.Tasks["test"].OnDone(func(err error) {
+		taskErr = err
+		done.Done()
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if resp.StatusCode != http.StatusOK {
+		var buf bytes.Buffer
+		io.Copy(&buf, resp.Body)
+		t.Fatal(buf.String())
+	}
+	done.Wait()
+	http.Get("http://localhost:8181/env/test")
+	debugFS(infraFS, t, "/infrastructure/dns-gateway/resources/coredns.yaml")
+	debugFS(envFS, t)
+	if taskErr != nil {
+		t.Fatal(taskErr)
+	}
+	expected := []string{
+		"https://accounts-ui.test.t",
+		"https://welcome.test.t",
+		"https://memberships.p.test.t",
+		"https://headscale.test.t/apple",
+	}
+	for _, e := range expected {
+		if cnt, ok := httpClient.counts[e]; !ok || cnt != 1 {
+			t.Fatal(httpClient.counts)
+		}
+	}
+	if len(httpClient.counts) != 4 {
+		t.Fatal(httpClient.counts)
+	}
+}
+
+func debugFS(bfs billy.Filesystem, t *testing.T, files ...string) {
+	f := map[string]struct{}{}
+	for _, i := range files {
+		f[i] = struct{}{}
+	}
+	t.Log("----- START ------")
+	err := util.Walk(bfs, "/", func(path string, info fs.FileInfo, err error) error {
+		t.Logf("%s %t\n", path, info.IsDir())
+		if _, ok := f[path]; ok && !info.IsDir() {
+			contents, err := util.ReadFile(bfs, path)
+			if err != nil {
+				return err
+			}
+			t.Log(string(contents))
+		}
+		return nil
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log("----- END ------")
+}
diff --git a/core/installer/welcome/welcome.go b/core/installer/welcome/welcome.go
index fe6c560..c5d732c 100644
--- a/core/installer/welcome/welcome.go
+++ b/core/installer/welcome/welcome.go
@@ -14,6 +14,7 @@
 	"github.com/gorilla/mux"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 //go:embed create-account.html
@@ -27,7 +28,7 @@
 
 type Server struct {
 	port                int
-	repo                installer.RepoIO
+	repo                soft.RepoIO
 	nsCreator           installer.NamespaceCreator
 	createAccountAddr   string
 	loginAddr           string
@@ -36,7 +37,7 @@
 
 func NewServer(
 	port int,
-	repo installer.RepoIO,
+	repo soft.RepoIO,
 	nsCreator installer.NamespaceCreator,
 	createAccountAddr string,
 	loginAddr string,
@@ -250,9 +251,9 @@
 }
 
 func (s *Server) initMemberships(username string) error {
-	return s.repo.Do(func(r installer.RepoFS) (string, error) {
+	return s.repo.Do(func(r soft.RepoFS) (string, error) {
 		var fa firstaccount
-		if err := installer.ReadYaml(r, "first-account.yaml", &fa); err != nil {
+		if err := soft.ReadYaml(r, "first-account.yaml", &fa); err != nil {
 			return "", err
 		}
 		if fa.Created {
@@ -267,7 +268,7 @@
 			return "", err
 		}
 		fa.Created = true
-		if err := installer.WriteYaml(r, "first-account.yaml", fa); err != nil {
+		if err := soft.WriteYaml(r, "first-account.yaml", fa); err != nil {
 			return "", err
 		}
 		return "initialized groups for first account", nil
diff --git a/core/ns-controller/.dockerignore b/core/ns-controller/.dockerignore
deleted file mode 100644
index 0f04682..0000000
--- a/core/ns-controller/.dockerignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file
-# Ignore build and test binaries.
-bin/
-testbin/
diff --git a/core/ns-controller/.gitignore b/core/ns-controller/.gitignore
deleted file mode 100644
index c0a7a54..0000000
--- a/core/ns-controller/.gitignore
+++ /dev/null
@@ -1,25 +0,0 @@
-
-# Binaries for programs and plugins
-*.exe
-*.exe~
-*.dll
-*.so
-*.dylib
-bin
-testbin/*
-
-# Test binary, build with `go test -c`
-*.test
-
-# Output of the go coverage tool, specifically when used with LiteIDE
-*.out
-
-# Kubernetes Generated files - skip generated files, except for vendored files
-
-!vendor/**/zz_generated.*
-
-# editor and IDE paraphernalia
-.idea
-*.swp
-*.swo
-*~
diff --git a/core/ns-controller/Dockerfile b/core/ns-controller/Dockerfile
deleted file mode 100644
index 5649873..0000000
--- a/core/ns-controller/Dockerfile
+++ /dev/null
@@ -1,28 +0,0 @@
-# # Build the manager binary
-# FROM golang:1.18 as builder
-
-# WORKDIR /workspace
-# # Copy the Go Modules manifests
-# COPY go.mod go.mod
-# COPY go.sum go.sum
-# # cache deps before building and copying source so that we don't need to re-download as much
-# # and so that source changes don't invalidate our downloaded layer
-# RUN go mod download
-
-# # Copy the go source
-# COPY main.go main.go
-# COPY api/ api/
-# COPY controllers/ controllers/
-
-# # Build
-# RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o manager main.go
-
-# # Use distroless as minimal base image to package the manager binary
-# # Refer to https://github.com/GoogleContainerTools/distroless for more details
-FROM gcr.io/distroless/static:nonroot
-ARG TARGETARCH
-WORKDIR /
-COPY manager_${TARGETARCH} manager
-USER 65532:65532
-
-ENTRYPOINT ["/manager"]
diff --git a/core/ns-controller/Makefile b/core/ns-controller/Makefile
deleted file mode 100644
index 2d11b7f..0000000
--- a/core/ns-controller/Makefile
+++ /dev/null
@@ -1,160 +0,0 @@
-
-# Image URL to use all building/pushing image targets
-IMG ?= giolekva/dns-ns-controller:latest
-# ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary.
-ENVTEST_K8S_VERSION = 1.24.2
-
-# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
-ifeq (,$(shell go env GOBIN))
-GOBIN=$(shell go env GOPATH)/bin
-else
-GOBIN=$(shell go env GOBIN)
-endif
-GOBIN=/usr/local/go/bin/go
-
-# Setting SHELL to bash allows bash commands to be executed by recipes.
-# Options are set to exit when a recipe line exits non-zero or a piped command fails.
-SHELL = /usr/bin/env bash -o pipefail
-.SHELLFLAGS = -ec
-
-.PHONY: all
-all: build
-
-##@ General
-
-# The help target prints out all targets with their descriptions organized
-# beneath their categories. The categories are represented by '##@' and the
-# target descriptions by '##'. The awk commands is responsible for reading the
-# entire set of makefiles included in this invocation, looking for lines of the
-# file as xyz: ## something, and then pretty-format the target and help. Then,
-# if there's a line with ##@ something, that gets pretty-printed as a category.
-# More info on the usage of ANSI control characters for terminal formatting:
-# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters
-# More info on the awk command:
-# http://linuxcommand.org/lc3_adv_awk.php
-
-.PHONY: help
-help: ## Display this help.
-	@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
-
-##@ Development
-
-.PHONY: manifests
-manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects.
-	$(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
-
-.PHONY: generate
-generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
-	$(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..."
-
-.PHONY: fmt
-fmt: ## Run go fmt against code.
-	/usr/local/go/bin/go fmt ./...
-
-.PHONY: vet
-vet: ## Run go vet against code.
-	/usr/local/go/bin/go vet ./...
-
-.PHONY: test
-test: manifests generate fmt vet envtest ## Run tests.
-	KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" /usr/local/go/bin/go test ./... -coverprofile cover.out
-
-##@ Build
-
-.PHONY: build
-build: generate fmt vet ## Build manager binary.
-	/usr/local/go/bin/go build -o bin/manager *.go
-
-.PHONY: run
-run: manifests generate fmt vet ## Run a controller from your host.
-	/usr/local/go/bin/go run ./*.go
-
-clean:
-	rm -rf manager_arm64 manager_amd64
-
-build_arm64: export CGO_ENABLED=0
-build_arm64: export GO111MODULE=on
-build_arm64: export GOOS=linux
-build_arm64: export GOARCH=arm64
-build_arm64:
-	/usr/local/go/bin/go build -a -o manager_arm64 *.go
-
-build_amd64: export CGO_ENABLED=0
-build_amd64: export GO111MODULE=on
-build_amd64: export GOOS=linux
-build_amd64: export GOARCH=amd64
-build_amd64:
-	/usr/local/go/bin/go build -a -o manager_amd64 *.go
-
-.PHONY: docker-build
-docker-build: clean build_arm64 build_amd64 ## Build docker image with the manager.
-	podman build --platform linux/arm64 --tag ${IMG}-arm64 .
-	podman build --platform linux/amd64 --tag ${IMG}-amd64 .
-
-.PHONY: docker-push
-docker-push: ## Push docker image with the manager.
-	podman push ${IMG}-arm64
-	podman push ${IMG}-amd64
-	podman manifest create ${IMG} ${IMG}-arm64 ${IMG}-amd64
-	podman manifest push ${IMG} docker://docker.io/${IMG}
-	podman manifest rm ${IMG}
-
-##@ Deployment
-
-ifndef ignore-not-found
-  ignore-not-found = false
-endif
-
-.PHONY: install
-install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config.
-	$(KUSTOMIZE) build config/crd | kubectl apply -f -
-
-.PHONY: uninstall
-uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
-	$(KUSTOMIZE) build config/crd | kubectl delete --ignore-not-found=$(ignore-not-found) -f -
-
-.PHONY: deploy
-deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config.
-	cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG}
-	$(KUSTOMIZE) build config/default | kubectl apply -f -
-
-.PHONY: undeploy
-undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
-	$(KUSTOMIZE) build config/default | kubectl delete --ignore-not-found=$(ignore-not-found) -f -
-
-##@ Build Dependencies
-
-## Location to install dependencies to
-LOCALBIN ?= $(shell pwd)/bin
-$(LOCALBIN):
-	mkdir -p $(LOCALBIN)
-
-## Tool Binaries
-KUSTOMIZE ?= $(LOCALBIN)/kustomize
-CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
-ENVTEST ?= $(LOCALBIN)/setup-envtest
-
-## Tool Versions
-KUSTOMIZE_VERSION ?= v3.8.7
-CONTROLLER_TOOLS_VERSION ?= v0.9.2
-
-KUSTOMIZE_INSTALL_SCRIPT ?= "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh"
-.PHONY: kustomize
-kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary.
-$(KUSTOMIZE): $(LOCALBIN)
-	test -s $(LOCALBIN)/kustomize || { curl -s $(KUSTOMIZE_INSTALL_SCRIPT) | bash -s -- $(subst v,,$(KUSTOMIZE_VERSION)) $(LOCALBIN); }
-
-.PHONY: controller-gen
-controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary.
-$(CONTROLLER_GEN): $(LOCALBIN)
-	test -s $(LOCALBIN)/controller-gen || GOBIN=$(LOCALBIN) /usr/local/go/bin/go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION)
-
-.PHONY: envtest
-envtest: $(ENVTEST) ## Download envtest-setup locally if necessary.
-$(ENVTEST): $(LOCALBIN)
-	test -s $(LOCALBIN)/setup-envtest || GOBIN=$(LOCALBIN) /usr/local/go/bin/go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest
-
-generate-helm-chart: manifests kustomize
-	$(KUSTOMIZE) build config/crd > ../../charts/dns-ns-controller/templates/crds.yaml
-	cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG}
-	$(KUSTOMIZE) build config/default | sed 's/: ns-controller-system/: {{ .Release.Namespace }}/g' > ../../charts/dns-ns-controller/templates/install.yaml
diff --git a/core/ns-controller/PROJECT b/core/ns-controller/PROJECT
deleted file mode 100644
index c85b349..0000000
--- a/core/ns-controller/PROJECT
+++ /dev/null
@@ -1,16 +0,0 @@
-domain: dodo.cloud
-layout:
-- go.kubebuilder.io/v3
-projectName: ns-controller
-repo: github.com/giolekva/pcloud/core/ns-controller
-resources:
-- api:
-    crdVersion: v1
-    namespaced: true
-  controller: true
-  domain: dodo.cloud
-  group: dodo.cloud
-  kind: DNSZone
-  path: github.com/giolekva/pcloud/core/ns-controller/api/v1
-  version: v1
-version: "3"
diff --git a/core/ns-controller/README.md b/core/ns-controller/README.md
deleted file mode 100644
index 2f1b7f9..0000000
--- a/core/ns-controller/README.md
+++ /dev/null
@@ -1,94 +0,0 @@
-# ns-controller
-// TODO(user): Add simple overview of use/purpose
-
-## Description
-// TODO(user): An in-depth paragraph about your project and overview of use
-
-## Getting Started
-You’ll need a Kubernetes cluster to run against. You can use [KIND](https://sigs.k8s.io/kind) to get a local cluster for testing, or run against a remote cluster.
-**Note:** Your controller will automatically use the current context in your kubeconfig file (i.e. whatever cluster `kubectl cluster-info` shows).
-
-### Running on the cluster
-1. Install Instances of Custom Resources:
-
-```sh
-kubectl apply -f config/samples/
-```
-
-2. Build and push your image to the location specified by `IMG`:
-	
-```sh
-make docker-build docker-push IMG=<some-registry>/ns-controller:tag
-```
-	
-3. Deploy the controller to the cluster with the image specified by `IMG`:
-
-```sh
-make deploy IMG=<some-registry>/ns-controller:tag
-```
-
-### Uninstall CRDs
-To delete the CRDs from the cluster:
-
-```sh
-make uninstall
-```
-
-### Undeploy controller
-UnDeploy the controller to the cluster:
-
-```sh
-make undeploy
-```
-
-## Contributing
-// TODO(user): Add detailed information on how you would like others to contribute to this project
-
-### How it works
-This project aims to follow the Kubernetes [Operator pattern](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/)
-
-It uses [Controllers](https://kubernetes.io/docs/concepts/architecture/controller/) 
-which provides a reconcile function responsible for synchronizing resources untile the desired state is reached on the cluster 
-
-### Test It Out
-1. Install the CRDs into the cluster:
-
-```sh
-make install
-```
-
-2. Run your controller (this will run in the foreground, so switch to a new terminal if you want to leave it running):
-
-```sh
-make run
-```
-
-**NOTE:** You can also run this in one step by running: `make install run`
-
-### Modifying the API definitions
-If you are editing the API definitions, generate the manifests such as CRs or CRDs using:
-
-```sh
-make manifests
-```
-
-**NOTE:** Run `make --help` for more information on all potential `make` targets
-
-More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html)
-
-## License
-
-Copyright 2023.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
diff --git a/core/ns-controller/api/v1/dnszone_types.go b/core/ns-controller/api/v1/dnszone_types.go
deleted file mode 100644
index 9107fed..0000000
--- a/core/ns-controller/api/v1/dnszone_types.go
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
-Copyright 2023.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package v1
-
-import (
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-)
-
-// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
-// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.
-
-// DNSZoneSpec defines the desired state of DNSZone
-type DNSZoneSpec struct {
-	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
-	// Important: Run "make" to regenerate code after modifying this file
-
-	// Foo is an example field of DNSZone. Edit dnszone_types.go to remove/update
-	Zone        string     `json:"zone,omitempty"`
-	PublicIPs   []string   `json:"publicIPs,omitempty"`
-	PrivateIP   string     `json:"privateIP,omitempty"`
-	Nameservers []string   `json:"nameservers,omitempty"`
-	DNSSec      DNSSecSpec `json:"dnssec,omitempty"`
-}
-
-type DNSSecSpec struct {
-	Enabled    bool   `json:"enabled,omitempty"`
-	SecretName string `json:"secretName,omitempty"`
-}
-
-// DNSZoneStatus defines the observed state of DNSZone
-type DNSZoneStatus struct {
-	// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
-	// Important: Run "make" to regenerate code after modifying this file
-	Ready            bool   `json:"ready,omitempty"`
-	RecordsToPublish string `json:"recordsToPublish,omitempty"`
-}
-
-//+kubebuilder:object:root=true
-//+kubebuilder:subresource:status
-
-// DNSZone is the Schema for the dnszones API
-type DNSZone struct {
-	metav1.TypeMeta   `json:",inline"`
-	metav1.ObjectMeta `json:"metadata,omitempty"`
-
-	Spec   DNSZoneSpec   `json:"spec,omitempty"`
-	Status DNSZoneStatus `json:"status,omitempty"`
-}
-
-//+kubebuilder:object:root=true
-
-// DNSZoneList contains a list of DNSZone
-type DNSZoneList struct {
-	metav1.TypeMeta `json:",inline"`
-	metav1.ListMeta `json:"metadata,omitempty"`
-	Items           []DNSZone `json:"items"`
-}
-
-func init() {
-	SchemeBuilder.Register(&DNSZone{}, &DNSZoneList{})
-}
diff --git a/core/ns-controller/api/v1/groupversion_info.go b/core/ns-controller/api/v1/groupversion_info.go
deleted file mode 100644
index 1abc826..0000000
--- a/core/ns-controller/api/v1/groupversion_info.go
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
-Copyright 2023.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-// Package v1 contains API Schema definitions for the dodo.cloud v1 API group
-// +kubebuilder:object:generate=true
-// +groupName=dodo.cloud.dodo.cloud
-package v1
-
-import (
-	"k8s.io/apimachinery/pkg/runtime/schema"
-	"sigs.k8s.io/controller-runtime/pkg/scheme"
-)
-
-var (
-	// GroupVersion is group version used to register these objects
-	GroupVersion = schema.GroupVersion{Group: "dodo.cloud.dodo.cloud", Version: "v1"}
-
-	// SchemeBuilder is used to add go types to the GroupVersionKind scheme
-	SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
-
-	// AddToScheme adds the types in this group-version to the given scheme.
-	AddToScheme = SchemeBuilder.AddToScheme
-)
diff --git a/core/ns-controller/api/v1/zz_generated.deepcopy.go b/core/ns-controller/api/v1/zz_generated.deepcopy.go
deleted file mode 100644
index f5a2288..0000000
--- a/core/ns-controller/api/v1/zz_generated.deepcopy.go
+++ /dev/null
@@ -1,141 +0,0 @@
-//go:build !ignore_autogenerated
-// +build !ignore_autogenerated
-
-/*
-Copyright 2023.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-// Code generated by controller-gen. DO NOT EDIT.
-
-package v1
-
-import (
-	runtime "k8s.io/apimachinery/pkg/runtime"
-)
-
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *DNSSecSpec) DeepCopyInto(out *DNSSecSpec) {
-	*out = *in
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSSecSpec.
-func (in *DNSSecSpec) DeepCopy() *DNSSecSpec {
-	if in == nil {
-		return nil
-	}
-	out := new(DNSSecSpec)
-	in.DeepCopyInto(out)
-	return out
-}
-
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *DNSZone) DeepCopyInto(out *DNSZone) {
-	*out = *in
-	out.TypeMeta = in.TypeMeta
-	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
-	in.Spec.DeepCopyInto(&out.Spec)
-	out.Status = in.Status
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZone.
-func (in *DNSZone) DeepCopy() *DNSZone {
-	if in == nil {
-		return nil
-	}
-	out := new(DNSZone)
-	in.DeepCopyInto(out)
-	return out
-}
-
-// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
-func (in *DNSZone) DeepCopyObject() runtime.Object {
-	if c := in.DeepCopy(); c != nil {
-		return c
-	}
-	return nil
-}
-
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *DNSZoneList) DeepCopyInto(out *DNSZoneList) {
-	*out = *in
-	out.TypeMeta = in.TypeMeta
-	in.ListMeta.DeepCopyInto(&out.ListMeta)
-	if in.Items != nil {
-		in, out := &in.Items, &out.Items
-		*out = make([]DNSZone, len(*in))
-		for i := range *in {
-			(*in)[i].DeepCopyInto(&(*out)[i])
-		}
-	}
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneList.
-func (in *DNSZoneList) DeepCopy() *DNSZoneList {
-	if in == nil {
-		return nil
-	}
-	out := new(DNSZoneList)
-	in.DeepCopyInto(out)
-	return out
-}
-
-// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
-func (in *DNSZoneList) DeepCopyObject() runtime.Object {
-	if c := in.DeepCopy(); c != nil {
-		return c
-	}
-	return nil
-}
-
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *DNSZoneSpec) DeepCopyInto(out *DNSZoneSpec) {
-	*out = *in
-	if in.PublicIPs != nil {
-		in, out := &in.PublicIPs, &out.PublicIPs
-		*out = make([]string, len(*in))
-		copy(*out, *in)
-	}
-	if in.Nameservers != nil {
-		in, out := &in.Nameservers, &out.Nameservers
-		*out = make([]string, len(*in))
-		copy(*out, *in)
-	}
-	out.DNSSec = in.DNSSec
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneSpec.
-func (in *DNSZoneSpec) DeepCopy() *DNSZoneSpec {
-	if in == nil {
-		return nil
-	}
-	out := new(DNSZoneSpec)
-	in.DeepCopyInto(out)
-	return out
-}
-
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *DNSZoneStatus) DeepCopyInto(out *DNSZoneStatus) {
-	*out = *in
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneStatus.
-func (in *DNSZoneStatus) DeepCopy() *DNSZoneStatus {
-	if in == nil {
-		return nil
-	}
-	out := new(DNSZoneStatus)
-	in.DeepCopyInto(out)
-	return out
-}
diff --git a/core/ns-controller/config/crd/bases/dodo.cloud.dodo.cloud_dnszones.yaml b/core/ns-controller/config/crd/bases/dodo.cloud.dodo.cloud_dnszones.yaml
deleted file mode 100644
index f767a08..0000000
--- a/core/ns-controller/config/crd/bases/dodo.cloud.dodo.cloud_dnszones.yaml
+++ /dev/null
@@ -1,75 +0,0 @@
----
-apiVersion: apiextensions.k8s.io/v1
-kind: CustomResourceDefinition
-metadata:
-  annotations:
-    controller-gen.kubebuilder.io/version: v0.9.2
-  creationTimestamp: null
-  name: dnszones.dodo.cloud.dodo.cloud
-spec:
-  group: dodo.cloud.dodo.cloud
-  names:
-    kind: DNSZone
-    listKind: DNSZoneList
-    plural: dnszones
-    singular: dnszone
-  scope: Namespaced
-  versions:
-  - name: v1
-    schema:
-      openAPIV3Schema:
-        description: DNSZone is the Schema for the dnszones API
-        properties:
-          apiVersion:
-            description: 'APIVersion defines the versioned schema of this representation
-              of an object. Servers should convert recognized schemas to the latest
-              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
-            type: string
-          kind:
-            description: 'Kind is a string value representing the REST resource this
-              object represents. Servers may infer this from the endpoint the client
-              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
-            type: string
-          metadata:
-            type: object
-          spec:
-            description: DNSZoneSpec defines the desired state of DNSZone
-            properties:
-              dnssec:
-                properties:
-                  enabled:
-                    type: boolean
-                  secretName:
-                    type: string
-                type: object
-              nameservers:
-                items:
-                  type: string
-                type: array
-              privateIP:
-                type: string
-              publicIPs:
-                items:
-                  type: string
-                type: array
-              zone:
-                description: Foo is an example field of DNSZone. Edit dnszone_types.go
-                  to remove/update
-                type: string
-            type: object
-          status:
-            description: DNSZoneStatus defines the observed state of DNSZone
-            properties:
-              ready:
-                description: 'INSERT ADDITIONAL STATUS FIELD - define observed state
-                  of cluster Important: Run "make" to regenerate code after modifying
-                  this file'
-                type: boolean
-              recordsToPublish:
-                type: string
-            type: object
-        type: object
-    served: true
-    storage: true
-    subresources:
-      status: {}
diff --git a/core/ns-controller/config/crd/kustomization.yaml b/core/ns-controller/config/crd/kustomization.yaml
deleted file mode 100644
index d8892d5..0000000
--- a/core/ns-controller/config/crd/kustomization.yaml
+++ /dev/null
@@ -1,21 +0,0 @@
-# This kustomization.yaml is not intended to be run by itself,
-# since it depends on service name and namespace that are out of this kustomize package.
-# It should be run by config/default
-resources:
-- bases/dodo.cloud.dodo.cloud_dnszones.yaml
-#+kubebuilder:scaffold:crdkustomizeresource
-
-patchesStrategicMerge:
-# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix.
-# patches here are for enabling the conversion webhook for each CRD
-#- patches/webhook_in_dnszones.yaml
-#+kubebuilder:scaffold:crdkustomizewebhookpatch
-
-# [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix.
-# patches here are for enabling the CA injection for each CRD
-#- patches/cainjection_in_dnszones.yaml
-#+kubebuilder:scaffold:crdkustomizecainjectionpatch
-
-# the following config is for teaching kustomize how to do kustomization for CRDs.
-configurations:
-- kustomizeconfig.yaml
diff --git a/core/ns-controller/config/crd/kustomizeconfig.yaml b/core/ns-controller/config/crd/kustomizeconfig.yaml
deleted file mode 100644
index ec5c150..0000000
--- a/core/ns-controller/config/crd/kustomizeconfig.yaml
+++ /dev/null
@@ -1,19 +0,0 @@
-# This file is for teaching kustomize how to substitute name and namespace reference in CRD
-nameReference:
-- kind: Service
-  version: v1
-  fieldSpecs:
-  - kind: CustomResourceDefinition
-    version: v1
-    group: apiextensions.k8s.io
-    path: spec/conversion/webhook/clientConfig/service/name
-
-namespace:
-- kind: CustomResourceDefinition
-  version: v1
-  group: apiextensions.k8s.io
-  path: spec/conversion/webhook/clientConfig/service/namespace
-  create: false
-
-varReference:
-- path: metadata/annotations
diff --git a/core/ns-controller/config/crd/patches/cainjection_in_dnszones.yaml b/core/ns-controller/config/crd/patches/cainjection_in_dnszones.yaml
deleted file mode 100644
index 3e98178..0000000
--- a/core/ns-controller/config/crd/patches/cainjection_in_dnszones.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-# The following patch adds a directive for certmanager to inject CA into the CRD
-apiVersion: apiextensions.k8s.io/v1
-kind: CustomResourceDefinition
-metadata:
-  annotations:
-    cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME)
-  name: dnszones.dodo.cloud.dodo.cloud
diff --git a/core/ns-controller/config/crd/patches/webhook_in_dnszones.yaml b/core/ns-controller/config/crd/patches/webhook_in_dnszones.yaml
deleted file mode 100644
index 4d48e74..0000000
--- a/core/ns-controller/config/crd/patches/webhook_in_dnszones.yaml
+++ /dev/null
@@ -1,16 +0,0 @@
-# The following patch enables a conversion webhook for the CRD
-apiVersion: apiextensions.k8s.io/v1
-kind: CustomResourceDefinition
-metadata:
-  name: dnszones.dodo.cloud.dodo.cloud
-spec:
-  conversion:
-    strategy: Webhook
-    webhook:
-      clientConfig:
-        service:
-          namespace: system
-          name: webhook-service
-          path: /convert
-      conversionReviewVersions:
-      - v1
diff --git a/core/ns-controller/config/default/kustomization.yaml b/core/ns-controller/config/default/kustomization.yaml
deleted file mode 100644
index 35d9b15..0000000
--- a/core/ns-controller/config/default/kustomization.yaml
+++ /dev/null
@@ -1,74 +0,0 @@
-# Adds namespace to all resources.
-namespace: ns-controller-system
-
-# Value of this field is prepended to the
-# names of all resources, e.g. a deployment named
-# "wordpress" becomes "alices-wordpress".
-# Note that it should also match with the prefix (text before '-') of the namespace
-# field above.
-namePrefix: ns-controller-
-
-# Labels to add to all resources and selectors.
-#commonLabels:
-#  someName: someValue
-
-bases:
-- ../crd
-- ../rbac
-- ../manager
-# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
-# crd/kustomization.yaml
-#- ../webhook
-# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
-#- ../certmanager
-# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
-#- ../prometheus
-
-patchesStrategicMerge:
-# Protect the /metrics endpoint by putting it behind auth.
-# If you want your controller-manager to expose the /metrics
-# endpoint w/o any authn/z, please comment the following line.
-- manager_auth_proxy_patch.yaml
-
-# Mount the controller config file for loading manager configurations
-# through a ComponentConfig type
-#- manager_config_patch.yaml
-
-# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
-# crd/kustomization.yaml
-#- manager_webhook_patch.yaml
-
-# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'.
-# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks.
-# 'CERTMANAGER' needs to be enabled to use ca injection
-#- webhookcainjection_patch.yaml
-
-# the following config is for teaching kustomize how to do var substitution
-vars:
-# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
-#- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR
-#  objref:
-#    kind: Certificate
-#    group: cert-manager.io
-#    version: v1
-#    name: serving-cert # this name should match the one in certificate.yaml
-#  fieldref:
-#    fieldpath: metadata.namespace
-#- name: CERTIFICATE_NAME
-#  objref:
-#    kind: Certificate
-#    group: cert-manager.io
-#    version: v1
-#    name: serving-cert # this name should match the one in certificate.yaml
-#- name: SERVICE_NAMESPACE # namespace of the service
-#  objref:
-#    kind: Service
-#    version: v1
-#    name: webhook-service
-#  fieldref:
-#    fieldpath: metadata.namespace
-#- name: SERVICE_NAME
-#  objref:
-#    kind: Service
-#    version: v1
-#    name: webhook-service
diff --git a/core/ns-controller/config/default/manager_auth_proxy_patch.yaml b/core/ns-controller/config/default/manager_auth_proxy_patch.yaml
deleted file mode 100644
index cec149a..0000000
--- a/core/ns-controller/config/default/manager_auth_proxy_patch.yaml
+++ /dev/null
@@ -1,39 +0,0 @@
-# This patch inject a sidecar container which is a HTTP proxy for the
-# controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews.
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: controller-manager
-  namespace: system
-spec:
-  template:
-    spec:
-      containers:
-      - name: kube-rbac-proxy
-        securityContext:
-          allowPrivilegeEscalation: false
-          capabilities:
-            drop:
-              - "ALL"
-        image: gcr.io/kubebuilder/kube-rbac-proxy:v0.13.0
-        args:
-        - "--secure-listen-address=0.0.0.0:8443"
-        - "--upstream=http://127.0.0.1:8080/"
-        - "--logtostderr=true"
-        - "--v=0"
-        ports:
-        - containerPort: 8443
-          protocol: TCP
-          name: https
-        resources:
-          limits:
-            cpu: 500m
-            memory: 128Mi
-          requests:
-            cpu: 5m
-            memory: 64Mi
-      - name: manager
-        args:
-        - "--health-probe-bind-address=:8081"
-        - "--metrics-bind-address=127.0.0.1:8080"
-        - "--leader-elect"
diff --git a/core/ns-controller/config/default/manager_config_patch.yaml b/core/ns-controller/config/default/manager_config_patch.yaml
deleted file mode 100644
index 6c40015..0000000
--- a/core/ns-controller/config/default/manager_config_patch.yaml
+++ /dev/null
@@ -1,20 +0,0 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: controller-manager
-  namespace: system
-spec:
-  template:
-    spec:
-      containers:
-      - name: manager
-        args:
-        - "--config=controller_manager_config.yaml"
-        volumeMounts:
-        - name: manager-config
-          mountPath: /controller_manager_config.yaml
-          subPath: controller_manager_config.yaml
-      volumes:
-      - name: manager-config
-        configMap:
-          name: manager-config
diff --git a/core/ns-controller/config/manager/controller_manager_config.yaml b/core/ns-controller/config/manager/controller_manager_config.yaml
deleted file mode 100644
index 875dc1b..0000000
--- a/core/ns-controller/config/manager/controller_manager_config.yaml
+++ /dev/null
@@ -1,21 +0,0 @@
-apiVersion: controller-runtime.sigs.k8s.io/v1alpha1
-kind: ControllerManagerConfig
-health:
-  healthProbeBindAddress: :8081
-metrics:
-  bindAddress: 127.0.0.1:8080
-webhook:
-  port: 9443
-leaderElection:
-  leaderElect: true
-  resourceName: c1db6143.dodo.cloud
-# leaderElectionReleaseOnCancel defines if the leader should step down volume
-# when the Manager ends. This requires the binary to immediately end when the
-# Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
-# speeds up voluntary leader transitions as the new leader don't have to wait
-# LeaseDuration time first.
-# In the default scaffold provided, the program ends immediately after
-# the manager stops, so would be fine to enable this option. However,
-# if you are doing or is intended to do any operation such as perform cleanups
-# after the manager stops then its usage might be unsafe.
-# leaderElectionReleaseOnCancel: true
diff --git a/core/ns-controller/config/manager/kustomization.yaml b/core/ns-controller/config/manager/kustomization.yaml
deleted file mode 100644
index 356255a..0000000
--- a/core/ns-controller/config/manager/kustomization.yaml
+++ /dev/null
@@ -1,16 +0,0 @@
-resources:
-- manager.yaml
-
-generatorOptions:
-  disableNameSuffixHash: true
-
-configMapGenerator:
-- files:
-  - controller_manager_config.yaml
-  name: manager-config
-apiVersion: kustomize.config.k8s.io/v1beta1
-kind: Kustomization
-images:
-- name: controller
-  newName: giolekva/dns-ns-controller
-  newTag: latest
diff --git a/core/ns-controller/config/manager/manager.yaml b/core/ns-controller/config/manager/manager.yaml
deleted file mode 100644
index 878ad48..0000000
--- a/core/ns-controller/config/manager/manager.yaml
+++ /dev/null
@@ -1,70 +0,0 @@
-apiVersion: v1
-kind: Namespace
-metadata:
-  labels:
-    control-plane: controller-manager
-  name: system
----
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: controller-manager
-  namespace: system
-  labels:
-    control-plane: controller-manager
-spec:
-  selector:
-    matchLabels:
-      control-plane: controller-manager
-  replicas: 1
-  template:
-    metadata:
-      annotations:
-        kubectl.kubernetes.io/default-container: manager
-      labels:
-        control-plane: controller-manager
-    spec:
-      securityContext:
-        runAsNonRoot: true
-        # TODO(user): For common cases that do not require escalating privileges
-        # it is recommended to ensure that all your Pods/Containers are restrictive.
-        # More info: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted
-        # Please uncomment the following code if your project does NOT have to work on old Kubernetes
-        # versions < 1.19 or on vendors versions which do NOT support this field by default (i.e. Openshift < 4.11 ).
-        # seccompProfile:
-        #   type: RuntimeDefault
-      containers:
-      - command:
-        - /manager
-        args:
-        - --leader-elect
-        image: controller:latest
-        name: manager
-        securityContext:
-          allowPrivilegeEscalation: false
-          capabilities:
-            drop:
-              - "ALL"
-        livenessProbe:
-          httpGet:
-            path: /healthz
-            port: 8081
-          initialDelaySeconds: 15
-          periodSeconds: 20
-        readinessProbe:
-          httpGet:
-            path: /readyz
-            port: 8081
-          initialDelaySeconds: 5
-          periodSeconds: 10
-        # TODO(user): Configure the resources accordingly based on the project requirements.
-        # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
-        resources:
-          limits:
-            cpu: 500m
-            memory: 128Mi
-          requests:
-            cpu: 10m
-            memory: 64Mi
-      serviceAccountName: controller-manager
-      terminationGracePeriodSeconds: 10
diff --git a/core/ns-controller/config/prometheus/kustomization.yaml b/core/ns-controller/config/prometheus/kustomization.yaml
deleted file mode 100644
index ed13716..0000000
--- a/core/ns-controller/config/prometheus/kustomization.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-resources:
-- monitor.yaml
diff --git a/core/ns-controller/config/prometheus/monitor.yaml b/core/ns-controller/config/prometheus/monitor.yaml
deleted file mode 100644
index d19136a..0000000
--- a/core/ns-controller/config/prometheus/monitor.yaml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-# Prometheus Monitor Service (Metrics)
-apiVersion: monitoring.coreos.com/v1
-kind: ServiceMonitor
-metadata:
-  labels:
-    control-plane: controller-manager
-  name: controller-manager-metrics-monitor
-  namespace: system
-spec:
-  endpoints:
-    - path: /metrics
-      port: https
-      scheme: https
-      bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
-      tlsConfig:
-        insecureSkipVerify: true
-  selector:
-    matchLabels:
-      control-plane: controller-manager
diff --git a/core/ns-controller/config/rbac/auth_proxy_client_clusterrole.yaml b/core/ns-controller/config/rbac/auth_proxy_client_clusterrole.yaml
deleted file mode 100644
index 51a75db..0000000
--- a/core/ns-controller/config/rbac/auth_proxy_client_clusterrole.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  name: metrics-reader
-rules:
-- nonResourceURLs:
-  - "/metrics"
-  verbs:
-  - get
diff --git a/core/ns-controller/config/rbac/auth_proxy_role.yaml b/core/ns-controller/config/rbac/auth_proxy_role.yaml
deleted file mode 100644
index 80e1857..0000000
--- a/core/ns-controller/config/rbac/auth_proxy_role.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  name: proxy-role
-rules:
-- apiGroups:
-  - authentication.k8s.io
-  resources:
-  - tokenreviews
-  verbs:
-  - create
-- apiGroups:
-  - authorization.k8s.io
-  resources:
-  - subjectaccessreviews
-  verbs:
-  - create
diff --git a/core/ns-controller/config/rbac/auth_proxy_role_binding.yaml b/core/ns-controller/config/rbac/auth_proxy_role_binding.yaml
deleted file mode 100644
index ec7acc0..0000000
--- a/core/ns-controller/config/rbac/auth_proxy_role_binding.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
-  name: proxy-rolebinding
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: ClusterRole
-  name: proxy-role
-subjects:
-- kind: ServiceAccount
-  name: controller-manager
-  namespace: system
diff --git a/core/ns-controller/config/rbac/auth_proxy_service.yaml b/core/ns-controller/config/rbac/auth_proxy_service.yaml
deleted file mode 100644
index 71f1797..0000000
--- a/core/ns-controller/config/rbac/auth_proxy_service.yaml
+++ /dev/null
@@ -1,15 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    control-plane: controller-manager
-  name: controller-manager-metrics-service
-  namespace: system
-spec:
-  ports:
-  - name: https
-    port: 8443
-    protocol: TCP
-    targetPort: https
-  selector:
-    control-plane: controller-manager
diff --git a/core/ns-controller/config/rbac/dnszone_editor_role.yaml b/core/ns-controller/config/rbac/dnszone_editor_role.yaml
deleted file mode 100644
index be63ac1..0000000
--- a/core/ns-controller/config/rbac/dnszone_editor_role.yaml
+++ /dev/null
@@ -1,24 +0,0 @@
-# permissions for end users to edit dnszones.
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  name: dnszone-editor-role
-rules:
-- apiGroups:
-  - dodo.cloud.dodo.cloud
-  resources:
-  - dnszones
-  verbs:
-  - create
-  - delete
-  - get
-  - list
-  - patch
-  - update
-  - watch
-- apiGroups:
-  - dodo.cloud.dodo.cloud
-  resources:
-  - dnszones/status
-  verbs:
-  - get
diff --git a/core/ns-controller/config/rbac/dnszone_viewer_role.yaml b/core/ns-controller/config/rbac/dnszone_viewer_role.yaml
deleted file mode 100644
index dfc5014..0000000
--- a/core/ns-controller/config/rbac/dnszone_viewer_role.yaml
+++ /dev/null
@@ -1,20 +0,0 @@
-# permissions for end users to view dnszones.
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  name: dnszone-viewer-role
-rules:
-- apiGroups:
-  - dodo.cloud.dodo.cloud
-  resources:
-  - dnszones
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - dodo.cloud.dodo.cloud
-  resources:
-  - dnszones/status
-  verbs:
-  - get
diff --git a/core/ns-controller/config/rbac/kustomization.yaml b/core/ns-controller/config/rbac/kustomization.yaml
deleted file mode 100644
index 731832a..0000000
--- a/core/ns-controller/config/rbac/kustomization.yaml
+++ /dev/null
@@ -1,18 +0,0 @@
-resources:
-# All RBAC will be applied under this service account in
-# the deployment namespace. You may comment out this resource
-# if your manager will use a service account that exists at
-# runtime. Be sure to update RoleBinding and ClusterRoleBinding
-# subjects if changing service account names.
-- service_account.yaml
-- role.yaml
-- role_binding.yaml
-- leader_election_role.yaml
-- leader_election_role_binding.yaml
-# Comment the following 4 lines if you want to disable
-# the auth proxy (https://github.com/brancz/kube-rbac-proxy)
-# which protects your /metrics endpoint.
-- auth_proxy_service.yaml
-- auth_proxy_role.yaml
-- auth_proxy_role_binding.yaml
-- auth_proxy_client_clusterrole.yaml
diff --git a/core/ns-controller/config/rbac/leader_election_role.yaml b/core/ns-controller/config/rbac/leader_election_role.yaml
deleted file mode 100644
index 4190ec8..0000000
--- a/core/ns-controller/config/rbac/leader_election_role.yaml
+++ /dev/null
@@ -1,37 +0,0 @@
-# permissions to do leader election.
-apiVersion: rbac.authorization.k8s.io/v1
-kind: Role
-metadata:
-  name: leader-election-role
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - configmaps
-  verbs:
-  - get
-  - list
-  - watch
-  - create
-  - update
-  - patch
-  - delete
-- apiGroups:
-  - coordination.k8s.io
-  resources:
-  - leases
-  verbs:
-  - get
-  - list
-  - watch
-  - create
-  - update
-  - patch
-  - delete
-- apiGroups:
-  - ""
-  resources:
-  - events
-  verbs:
-  - create
-  - patch
diff --git a/core/ns-controller/config/rbac/leader_election_role_binding.yaml b/core/ns-controller/config/rbac/leader_election_role_binding.yaml
deleted file mode 100644
index 1d1321e..0000000
--- a/core/ns-controller/config/rbac/leader_election_role_binding.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-apiVersion: rbac.authorization.k8s.io/v1
-kind: RoleBinding
-metadata:
-  name: leader-election-rolebinding
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: Role
-  name: leader-election-role
-subjects:
-- kind: ServiceAccount
-  name: controller-manager
-  namespace: system
diff --git a/core/ns-controller/config/rbac/role.yaml b/core/ns-controller/config/rbac/role.yaml
deleted file mode 100644
index f5694fd..0000000
--- a/core/ns-controller/config/rbac/role.yaml
+++ /dev/null
@@ -1,45 +0,0 @@
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  creationTimestamp: null
-  name: manager-role
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - secrets
-  verbs:
-  - create
-  - delete
-  - get
-  - list
-  - patch
-  - update
-  - watch
-- apiGroups:
-  - dodo.cloud.dodo.cloud
-  resources:
-  - dnszones
-  verbs:
-  - create
-  - delete
-  - get
-  - list
-  - patch
-  - update
-  - watch
-- apiGroups:
-  - dodo.cloud.dodo.cloud
-  resources:
-  - dnszones/finalizers
-  verbs:
-  - update
-- apiGroups:
-  - dodo.cloud.dodo.cloud
-  resources:
-  - dnszones/status
-  verbs:
-  - get
-  - patch
-  - update
diff --git a/core/ns-controller/config/rbac/role_binding.yaml b/core/ns-controller/config/rbac/role_binding.yaml
deleted file mode 100644
index 2070ede..0000000
--- a/core/ns-controller/config/rbac/role_binding.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
-  name: manager-rolebinding
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: ClusterRole
-  name: manager-role
-subjects:
-- kind: ServiceAccount
-  name: controller-manager
-  namespace: system
diff --git a/core/ns-controller/config/rbac/service_account.yaml b/core/ns-controller/config/rbac/service_account.yaml
deleted file mode 100644
index 7cd6025..0000000
--- a/core/ns-controller/config/rbac/service_account.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-apiVersion: v1
-kind: ServiceAccount
-metadata:
-  name: controller-manager
-  namespace: system
diff --git a/core/ns-controller/config/samples/dodo.cloud_v1_dnszone.yaml b/core/ns-controller/config/samples/dodo.cloud_v1_dnszone.yaml
deleted file mode 100644
index b5f7d7e..0000000
--- a/core/ns-controller/config/samples/dodo.cloud_v1_dnszone.yaml
+++ /dev/null
@@ -1,16 +0,0 @@
-apiVersion: dodo.cloud.dodo.cloud/v1
-kind: DNSZone
-metadata:
-  name: dnszone-sample
-  namespace: dns-ns-controller
-spec:
-  zone: t5.lekva.me
-  privateIP: 10.1.0.1
-  publicIPs:
-  - 135.181.48.180
-  - 65.108.39.172
-  - 65.108.39.171
-  nameservers:
-  - 135.181.48.180
-  - 65.108.39.172
-  - 65.108.39.171
diff --git a/core/ns-controller/controllers/dnszone_controller.go b/core/ns-controller/controllers/dnszone_controller.go
deleted file mode 100644
index 53db4a1..0000000
--- a/core/ns-controller/controllers/dnszone_controller.go
+++ /dev/null
@@ -1,146 +0,0 @@
-/*
-Copyright 2023.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package controllers
-
-import (
-	"context"
-	"fmt"
-	"strings"
-	"time"
-
-	corev1 "k8s.io/api/core/v1"
-	apierrors "k8s.io/apimachinery/pkg/api/errors"
-	"k8s.io/apimachinery/pkg/runtime"
-	ctrl "sigs.k8s.io/controller-runtime"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-	"sigs.k8s.io/controller-runtime/pkg/log"
-
-	dodocloudv1 "github.com/giolekva/pcloud/core/ns-controller/api/v1"
-)
-
-// DNSZoneReconciler reconciles a DNSZone object
-type DNSZoneReconciler struct {
-	client.Client
-	Scheme *runtime.Scheme
-	Store  ZoneStoreFactory
-}
-
-type DNSSecKey struct {
-	Basename string `json:"basename,omitempty"`
-	Key      []byte `json:"key,omitempty"`
-	Private  []byte `json:"private,omitempty"`
-	DS       []byte `json:"ds,omitempty"`
-}
-
-//+kubebuilder:rbac:groups=dodo.cloud.dodo.cloud,resources=dnszones,verbs=get;list;watch;create;update;patch;delete
-//+kubebuilder:rbac:groups=dodo.cloud.dodo.cloud,resources=dnszones/status,verbs=get;update;patch
-//+kubebuilder:rbac:groups=dodo.cloud.dodo.cloud,resources=dnszones/finalizers,verbs=update
-//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
-
-// Reconcile is part of the main kubernetes reconciliation loop which aims to
-// move the current state of the cluster closer to the desired state.
-// TODO(user): Modify the Reconcile function to compare the state specified by
-// the DNSZone object against the actual cluster state, and then
-// perform operations to make the cluster state reflect the state specified by
-// the user.
-//
-// For more details, check Reconcile and its Result here:
-// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.12.2/pkg/reconcile
-func (r *DNSZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
-	r.Store.Debug()
-	defer func() {
-		r.Store.Debug()
-	}()
-	logger := log.FromContext(ctx)
-	logger.Info(req.String())
-
-	resource := &dodocloudv1.DNSZone{}
-	if err := r.Get(context.Background(), client.ObjectKey{
-		Namespace: req.Namespace,
-		Name:      req.Name,
-	}, resource); err != nil {
-		if apierrors.IsGone(err) {
-			fmt.Printf("GONE %s %s\n", req.Name, req.Namespace)
-		} else {
-			return ctrl.Result{RequeueAfter: time.Minute}, err
-		}
-	}
-	if resource.Status.Ready {
-		return ctrl.Result{}, nil
-	}
-	zoneConfig := ZoneConfig{
-		Zone:        resource.Spec.Zone,
-		PublicIPs:   resource.Spec.PublicIPs,
-		PrivateIP:   resource.Spec.PrivateIP,
-		Nameservers: resource.Spec.Nameservers,
-	}
-	if resource.Spec.DNSSec.Enabled {
-		var secret corev1.Secret
-		if err := r.Get(context.Background(), client.ObjectKey{
-			Namespace: resource.Namespace, // NOTE(gio): configurable on resource level?
-			Name:      resource.Spec.DNSSec.SecretName,
-		}, &secret); err != nil {
-			return ctrl.Result{RequeueAfter: time.Minute}, err
-		}
-		basename, ok := secret.Data["basename"]
-		if !ok {
-			return ctrl.Result{RequeueAfter: time.Minute}, fmt.Errorf("basename not found")
-		}
-		key, ok := secret.Data["key"]
-		if !ok {
-			return ctrl.Result{RequeueAfter: time.Minute}, fmt.Errorf("key not found")
-		}
-		private, ok := secret.Data["private"]
-		if !ok {
-			return ctrl.Result{RequeueAfter: time.Minute}, fmt.Errorf("private not found")
-		}
-		ds, ok := secret.Data["ds"]
-		if !ok {
-			return ctrl.Result{RequeueAfter: time.Minute}, fmt.Errorf("ds not found")
-		}
-		zoneConfig.DNSSec = &DNSSecKey{
-			Basename: string(basename),
-			Key:      key,
-			Private:  private,
-			DS:       ds,
-		}
-	}
-	zs, err := r.Store.Create(zoneConfig)
-	if err != nil {
-		return ctrl.Result{RequeueAfter: time.Minute}, err
-	}
-	if err := zs.CreateConfigFile(); err != nil {
-		return ctrl.Result{RequeueAfter: time.Minute}, err
-	}
-	resource.Status.Ready = true
-	if zoneConfig.DNSSec != nil {
-		rrs := []string{string(zoneConfig.DNSSec.DS)}
-		rrs = append(rrs, GenerateNSRecords(zoneConfig)...)
-		resource.Status.RecordsToPublish = strings.Join(rrs, "\n")
-	}
-	if err := r.Status().Update(context.Background(), resource); err != nil {
-		return ctrl.Result{RequeueAfter: time.Minute}, err
-	}
-	return ctrl.Result{}, nil
-}
-
-// SetupWithManager sets up the controller with the Manager.
-func (r *DNSZoneReconciler) SetupWithManager(mgr ctrl.Manager) error {
-	return ctrl.NewControllerManagedBy(mgr).
-		For(&dodocloudv1.DNSZone{}).
-		Complete(r)
-}
diff --git a/core/ns-controller/controllers/store.go b/core/ns-controller/controllers/store.go
deleted file mode 100644
index fd2d158..0000000
--- a/core/ns-controller/controllers/store.go
+++ /dev/null
@@ -1,304 +0,0 @@
-package controllers
-
-import (
-	"encoding/json"
-	"fmt"
-	"io"
-	"io/fs"
-	"os"
-	"strings"
-	"text/template"
-
-	"github.com/Masterminds/sprig/v3"
-	"github.com/go-git/go-billy/v5"
-	"github.com/go-git/go-billy/v5/util"
-)
-
-const dodoConfigFilename = "dodo.json"
-const zoneConfigFilename = "coredns.conf"
-const rootConfigFilename = "coredns.conf"
-const importAllConfigFiles = "import */" + zoneConfigFilename
-
-type ZoneStore interface {
-	ConfigPath() string
-	CreateConfigFile() error
-	AddDNSSec(key DNSSecKey) error
-	AddTextRecord(entry, txt string) error
-	DeleteTextRecord(entry, txt string) error
-}
-
-type ZoneConfig struct {
-	Zone        string     `json:"zone,omitempty"`
-	PublicIPs   []string   `json:"publicIPs,omitempty"`
-	PrivateIP   string     `json:"privateIP,omitempty"`
-	Nameservers []string   `json:"nameservers,omitempty"`
-	DNSSec      *DNSSecKey `json:"dnsSec,omitempty"`
-}
-
-func GenerateNSRecords(z ZoneConfig) []string {
-	subdomain := strings.Split(z.Zone, ",")[0]
-	ret := make([]string, 0)
-	for i, ip := range z.Nameservers {
-		ret = append(ret, fmt.Sprintf("ns%d.%s 10800 IN A %s", i+1, z.Zone, ip))
-		ret = append(ret, fmt.Sprintf("%s. 10800 IN NS ns%d.%s.", subdomain, i+1, z.Zone))
-	}
-	return ret
-}
-
-type ZoneStoreFactory interface {
-	ConfigPath() string
-	Create(zone ZoneConfig) (ZoneStore, error)
-	Get(zone string) (ZoneStore, error)
-	Debug()
-	Purge()
-}
-
-type fsZoneStoreFactory struct {
-	fs    billy.Filesystem
-	zones map[string]ZoneStore
-}
-
-func NewFSZoneStoreFactory(fs billy.Filesystem) (ZoneStoreFactory, error) {
-	if err := util.WriteFile(fs, rootConfigFilename, []byte(importAllConfigFiles), os.ModePerm); err != nil {
-		return nil, err
-	}
-	f, err := fs.ReadDir(".")
-	if err != nil {
-		return nil, err
-	}
-	zf := fsZoneStoreFactory{fs: fs, zones: make(map[string]ZoneStore)}
-	for _, i := range f {
-		if i.IsDir() {
-			var zone ZoneConfig
-			r, err := fs.Open(fs.Join(i.Name(), dodoConfigFilename))
-			if err != nil {
-				continue // TODO(gio): clean up the dir to enforce config file
-			}
-			defer r.Close()
-			if err := json.NewDecoder(r).Decode(&zone); err != nil {
-				return nil, err
-			}
-			zfs, err := fs.Chroot(zone.Zone)
-			if err != nil {
-				return nil, err
-			}
-			z, err := NewFSZoneStore(zone, zfs)
-			zf.zones[zone.Zone] = z
-		}
-	}
-	return &zf, nil
-}
-
-func (f *fsZoneStoreFactory) ConfigPath() string {
-	return f.fs.Join(f.fs.Root(), rootConfigFilename)
-}
-
-func (f *fsZoneStoreFactory) Purge() {
-	items, _ := f.fs.ReadDir(".")
-	for _, i := range items {
-		f.fs.Remove(i.Name())
-	}
-}
-
-func (f *fsZoneStoreFactory) Debug() {
-	fmt.Println("------------")
-	util.Walk(f.fs, ".", func(path string, info fs.FileInfo, err error) error {
-		fmt.Println(path)
-		if !info.IsDir() {
-			r, err := f.fs.Open(path)
-			if err != nil {
-				return err
-			}
-			defer r.Close()
-			_, err = io.Copy(os.Stdout, r)
-			return err
-		}
-		return nil
-	})
-	fmt.Println("++++++++++++++")
-}
-
-func (f *fsZoneStoreFactory) Get(zone string) (ZoneStore, error) {
-	if z, ok := f.zones[zone]; ok {
-		return z, nil
-	}
-	return nil, fmt.Errorf("%s zone not found", zone)
-}
-
-func (f *fsZoneStoreFactory) Create(zone ZoneConfig) (ZoneStore, error) {
-	if z, ok := f.zones[zone.Zone]; ok {
-		return z, nil
-	}
-	if err := f.fs.MkdirAll(zone.Zone, fs.ModePerm); err != nil {
-		return nil, err
-	}
-	zfs, err := f.fs.Chroot(zone.Zone)
-	if err != nil {
-		return nil, err
-	}
-	z, err := NewFSZoneStore(zone, zfs)
-	if err != nil {
-		defer func() {
-			if err := f.fs.Remove(zone.Zone); err != nil {
-				fmt.Printf("Failed to remove zone directory: %s\n", err.Error())
-			}
-		}()
-	}
-	f.zones[zone.Zone] = z
-	return z, nil
-}
-
-type fsZoneStore struct {
-	zone ZoneConfig
-	fs   billy.Filesystem
-}
-
-func NewFSZoneStore(zone ZoneConfig, fs billy.Filesystem) (ZoneStore, error) {
-	return &fsZoneStore{zone, fs}, nil
-}
-
-func (s *fsZoneStore) CreateConfigFile() error {
-	{
-		w, err := s.fs.Create(dodoConfigFilename)
-		if err != nil {
-			return err
-		}
-		defer w.Close()
-		if err := json.NewEncoder(w).Encode(s.zone); err != nil {
-			return err
-		}
-	}
-	zone := s.zone
-	fs := s.fs
-	if zone.DNSSec != nil {
-		sec := zone.DNSSec
-		if err := util.WriteFile(fs, sec.Basename+".key", sec.Key, 0644); err != nil {
-			return err
-		}
-		if err := util.WriteFile(fs, sec.Basename+".private", sec.Private, 0600); err != nil {
-			return err
-		}
-	}
-	conf, err := fs.Create(zoneConfigFilename)
-	if err != nil {
-		return err
-	}
-	defer conf.Close()
-	configTmpl, err := template.New("config").Funcs(sprig.TxtFuncMap()).Parse(`
-{{ .zone.Zone }}:53 {
-	file {{ .rootDir }}/zone.db {
-      reload 1s
-    }
-	errors
-    {{ if .zone.DNSSec }}
-	dnssec {
-		key file {{ .rootDir}}/{{ .zone.DNSSec.Basename }}
-	}
-    {{ end }}
-	log
-	health {
-		lameduck 5s
-	}
-	ready
-	cache 30
-	loop
-	reload
-	loadbalance
-}`)
-	if err != nil {
-		return err
-	}
-	if err := configTmpl.Execute(conf, map[string]any{
-		"zone":    zone,
-		"rootDir": fs.Root(),
-	}); err != nil {
-		return err
-	}
-	recordsTmpl, err := template.New("records").Funcs(sprig.TxtFuncMap()).Parse(`
-{{ $zone := .zone }}
-{{ $zone }}.   IN SOA ns1.{{ $zone }}. hostmaster.{{ $zone }}. {{ .nowUnix }} 7200 3600 1209600 3600
-{{ range $i, $ns := .nameservers }}
-ns{{ add1 $i }}.{{ $zone }}. 10800 IN A {{ $ns }}
-{{ end }}
-{{ range .publicIngressIPs }}
-{{ $zone }}. 10800 IN A {{ . }}
-*.{{ $zone }}. 10800 IN A {{ . }}
-*.*.{{ $zone }}. 10800 IN A {{ . }}
-{{ end }}
-*.p.{{ $zone }}. 10800 IN A {{ .privateIngressIP }}
-`)
-	records, err := fs.Create("zone.db")
-	if err != nil {
-		return err
-	}
-	defer records.Close()
-	if err := recordsTmpl.Execute(records, map[string]any{
-		"zone":             zone.Zone,
-		"publicIngressIPs": zone.PublicIPs,
-		"privateIngressIP": zone.PrivateIP,
-		"nameservers":      zone.Nameservers,
-		"nowUnix":          NowUnix(),
-	}); err != nil {
-		return err
-	}
-	return nil
-}
-
-func (s *fsZoneStore) ConfigPath() string {
-	return s.fs.Join(s.fs.Root(), zoneConfigFilename)
-}
-
-func (s *fsZoneStore) AddDNSSec(key DNSSecKey) error {
-	return nil
-}
-
-func (s *fsZoneStore) AddTextRecord(entry, txt string) error {
-	s.fs.Remove("txt")
-	r, err := s.fs.Open("zone.db")
-	if err != nil {
-		return err
-	}
-	defer r.Close()
-	z, err := NewZoneFile(r)
-	if err != nil {
-		return err
-	}
-	var fqdn = fmt.Sprintf("%s.%s.", entry, s.zone.Zone)
-	z.CreateOrReplaceTxtRecord(fqdn, txt)
-	for _, ip := range s.zone.PublicIPs {
-		z.CreateARecord(fqdn, ip)
-	}
-	w, err := s.fs.Create("zone.db")
-	if err != nil {
-		return err
-	}
-	defer w.Close()
-	if err := z.Write(w); err != nil {
-		return err
-	}
-	return nil
-}
-
-func (s *fsZoneStore) DeleteTextRecord(entry, txt string) error {
-	r, err := s.fs.Open("zone.db")
-	if err != nil {
-		return err
-	}
-	defer r.Close()
-	z, err := NewZoneFile(r)
-	if err != nil {
-		return err
-	}
-	fqdn := fmt.Sprintf("%s.%s.", entry, s.zone.Zone)
-	z.DeleteTxtRecord(fqdn, txt)
-	z.DeleteRecordsFor(fqdn)
-	w, err := s.fs.Create("zone.db")
-	if err != nil {
-		return err
-	}
-	defer w.Close()
-	if err := z.Write(w); err != nil {
-		return err
-	}
-	return nil
-}
diff --git a/core/ns-controller/controllers/zone_test.go b/core/ns-controller/controllers/zone_test.go
deleted file mode 100644
index e3a1da2..0000000
--- a/core/ns-controller/controllers/zone_test.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package controllers
-
-import (
-	"strings"
-	"testing"
-
-	"os"
-)
-
-const sample = `
-example.com.   IN SOA ns1.example.com. hostmaster.example.com. 2015082541 7200 3600 1209600 3600
-ns1.example.com. 10800 IN A 10.1.0.1
-ns2.example.com. 10800 IN A 10.1.0.2
-@.example.com. 10800 IN A 10.1.0.1
-@.example.com. 10800 IN A 10.1.0.2
-*.example.com. 10800 IN CNAME example.com.
-p.example.com. 10800 IN CNAME example.com.
-*.p.example.com. 10800 IN A 10.0.0.1
-`
-
-func TestRead(t *testing.T) {
-	z, err := NewZoneFile(strings.NewReader(sample))
-	if err != nil {
-		t.Fatal(err)
-	}
-	z.CreateOrReplaceTxtRecord("foo.example.com.", "bar")
-	z.DeleteTxtRecord("foo.example.com.", "bar")
-	if err := z.Write(os.Stdout); err != nil {
-		t.Fatal(err)
-	}
-}
diff --git a/core/ns-controller/go.mod b/core/ns-controller/go.mod
deleted file mode 100644
index 38c5b01..0000000
--- a/core/ns-controller/go.mod
+++ /dev/null
@@ -1,85 +0,0 @@
-module github.com/giolekva/pcloud/core/ns-controller
-
-go 1.18
-
-require (
-	github.com/Masterminds/sprig/v3 v3.2.3
-	github.com/go-git/go-billy/v5 v5.5.0
-	github.com/miekg/dns v1.1.55
-	k8s.io/api v0.28.1
-	k8s.io/apimachinery v0.28.1
-	k8s.io/client-go v0.28.1
-	sigs.k8s.io/controller-runtime v0.16.1
-)
-
-require (
-	github.com/Masterminds/goutils v1.1.1 // indirect
-	github.com/Masterminds/semver/v3 v3.2.0 // indirect
-	github.com/beorn7/perks v1.0.1 // indirect
-	github.com/cespare/xxhash/v2 v2.2.0 // indirect
-	github.com/cyphar/filepath-securejoin v0.2.4 // indirect
-	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
-	github.com/emicklei/go-restful/v3 v3.11.0 // indirect
-	github.com/evanphx/json-patch/v5 v5.6.0 // indirect
-	github.com/fsnotify/fsnotify v1.6.0 // indirect
-	github.com/go-logr/logr v1.2.4 // indirect
-	github.com/go-logr/zapr v1.2.4 // indirect
-	github.com/go-openapi/jsonpointer v0.19.6 // indirect
-	github.com/go-openapi/jsonreference v0.20.2 // indirect
-	github.com/go-openapi/swag v0.22.3 // indirect
-	github.com/gogo/protobuf v1.3.2 // indirect
-	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
-	github.com/golang/protobuf v1.5.3 // indirect
-	github.com/google/gnostic-models v0.6.8 // indirect
-	github.com/google/go-cmp v0.5.9 // indirect
-	github.com/google/gofuzz v1.2.0 // indirect
-	github.com/google/uuid v1.3.1 // indirect
-	github.com/huandu/xstrings v1.3.3 // indirect
-	github.com/imdario/mergo v0.3.12 // indirect
-	github.com/josharian/intern v1.0.0 // indirect
-	github.com/json-iterator/go v1.1.12 // indirect
-	github.com/mailru/easyjson v0.7.7 // indirect
-	github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
-	github.com/mitchellh/copystructure v1.0.0 // indirect
-	github.com/mitchellh/reflectwalk v1.0.0 // indirect
-	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
-	github.com/modern-go/reflect2 v1.0.2 // indirect
-	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
-	github.com/onsi/ginkgo/v2 v2.12.0 // indirect
-	github.com/pkg/errors v0.9.1 // indirect
-	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
-	github.com/prometheus/client_golang v1.16.0 // indirect
-	github.com/prometheus/client_model v0.4.0 // indirect
-	github.com/prometheus/common v0.44.0 // indirect
-	github.com/prometheus/procfs v0.10.1 // indirect
-	github.com/shopspring/decimal v1.2.0 // indirect
-	github.com/spf13/cast v1.3.1 // indirect
-	github.com/spf13/pflag v1.0.5 // indirect
-	github.com/stretchr/testify v1.8.4 // indirect
-	go.uber.org/multierr v1.11.0 // indirect
-	go.uber.org/zap v1.25.0 // indirect
-	golang.org/x/crypto v0.14.0 // indirect
-	golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
-	golang.org/x/mod v0.12.0 // indirect
-	golang.org/x/net v0.17.0 // indirect
-	golang.org/x/oauth2 v0.12.0 // indirect
-	golang.org/x/sys v0.13.0 // indirect
-	golang.org/x/term v0.13.0 // indirect
-	golang.org/x/text v0.13.0 // indirect
-	golang.org/x/time v0.3.0 // indirect
-	golang.org/x/tools v0.13.0 // indirect
-	gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
-	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/protobuf v1.31.0 // indirect
-	gopkg.in/inf.v0 v0.9.1 // indirect
-	gopkg.in/yaml.v2 v2.4.0 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
-	k8s.io/apiextensions-apiserver v0.28.1 // indirect
-	k8s.io/component-base v0.28.1 // indirect
-	k8s.io/klog/v2 v2.100.1 // indirect
-	k8s.io/kube-openapi v0.0.0-20230905202853-d090da108d2f // indirect
-	k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
-	sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
-	sigs.k8s.io/structured-merge-diff/v4 v4.3.0 // indirect
-	sigs.k8s.io/yaml v1.3.0 // indirect
-)
diff --git a/core/ns-controller/go.sum b/core/ns-controller/go.sum
deleted file mode 100644
index 0d1ab91..0000000
--- a/core/ns-controller/go.sum
+++ /dev/null
@@ -1,269 +0,0 @@
-github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
-github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
-github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
-github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
-github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
-github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
-github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
-github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
-github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
-github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
-github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
-github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
-github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
-github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
-github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
-github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
-github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
-github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
-github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
-github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
-github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
-github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/zapr v1.2.4 h1:QHVo+6stLbfJmYGkQ7uGHUCu5hnAFAj6mDe6Ea0SeOo=
-github.com/go-logr/zapr v1.2.4/go.mod h1:FyHWQIzQORZ0QVE1BtVHv3cKtNLuXsbNLtpuhNapBOA=
-github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
-github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
-github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
-github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
-github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
-github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
-github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
-github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
-github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
-github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
-github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec=
-github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
-github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
-github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
-github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
-github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
-github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
-github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
-github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
-github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
-github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
-github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
-github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
-github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
-github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
-github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
-github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
-github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
-github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
-github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
-github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/onsi/ginkgo/v2 v2.12.0 h1:UIVDowFPwpg6yMUpPjGkYvf06K3RAiJXUhCxEwQVHRI=
-github.com/onsi/ginkgo/v2 v2.12.0/go.mod h1:ZNEzXISYlqpb8S36iN71ifqLi3vVD1rVJGvWRCJOUpQ=
-github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
-github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
-github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
-github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
-github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
-github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
-github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg=
-github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
-github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
-github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
-github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
-github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
-github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
-go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
-go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
-go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
-go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
-go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
-go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c=
-go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
-golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
-golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
-golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
-golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
-golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
-golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
-golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
-golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
-golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
-golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
-golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
-golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
-golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
-gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
-google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
-google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
-google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
-gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-k8s.io/api v0.28.1 h1:i+0O8k2NPBCPYaMB+uCkseEbawEt/eFaiRqUx8aB108=
-k8s.io/api v0.28.1/go.mod h1:uBYwID+66wiL28Kn2tBjBYQdEU0Xk0z5qF8bIBqk/Dg=
-k8s.io/apiextensions-apiserver v0.28.1 h1:l2ThkBRjrWpw4f24uq0Da2HaEgqJZ7pcgiEUTKSmQZw=
-k8s.io/apiextensions-apiserver v0.28.1/go.mod h1:sVvrI+P4vxh2YBBcm8n2ThjNyzU4BQGilCQ/JAY5kGs=
-k8s.io/apimachinery v0.28.1 h1:EJD40og3GizBSV3mkIoXQBsws32okPOy+MkRyzh6nPY=
-k8s.io/apimachinery v0.28.1/go.mod h1:X0xh/chESs2hP9koe+SdIAcXWcQ+RM5hy0ZynB+yEvw=
-k8s.io/client-go v0.28.1 h1:pRhMzB8HyLfVwpngWKE8hDcXRqifh1ga2Z/PU9SXVK8=
-k8s.io/client-go v0.28.1/go.mod h1:pEZA3FqOsVkCc07pFVzK076R+P/eXqsgx5zuuRWukNE=
-k8s.io/component-base v0.28.1 h1:LA4AujMlK2mr0tZbQDZkjWbdhTV5bRyEyAFe0TJxlWg=
-k8s.io/component-base v0.28.1/go.mod h1:jI11OyhbX21Qtbav7JkhehyBsIRfnO8oEgoAR12ArIU=
-k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg=
-k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
-k8s.io/kube-openapi v0.0.0-20230905202853-d090da108d2f h1:eeEUOoGYWhOz7EyXqhlR2zHKNw2mNJ9vzJmub6YN6kk=
-k8s.io/kube-openapi v0.0.0-20230905202853-d090da108d2f/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA=
-k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
-k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
-sigs.k8s.io/controller-runtime v0.16.1 h1:+15lzrmHsE0s2kNl0Dl8cTchI5Cs8qofo5PGcPrV9z0=
-sigs.k8s.io/controller-runtime v0.16.1/go.mod h1:vpMu3LpI5sYWtujJOa2uPK61nB5rbwlN7BAB8aSLvGU=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
-sigs.k8s.io/structured-merge-diff/v4 v4.3.0 h1:UZbZAZfX0wV2zr7YZorDz6GXROfDFj6LvqCRm4VUVKk=
-sigs.k8s.io/structured-merge-diff/v4 v4.3.0/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
-sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
-sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
diff --git a/core/ns-controller/hack/boilerplate.go.txt b/core/ns-controller/hack/boilerplate.go.txt
deleted file mode 100644
index 65b8622..0000000
--- a/core/ns-controller/hack/boilerplate.go.txt
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
-Copyright 2023.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
\ No newline at end of file
diff --git a/core/ns-controller/main.go b/core/ns-controller/main.go
deleted file mode 100644
index 8987127..0000000
--- a/core/ns-controller/main.go
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
-Copyright 2023.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package main
-
-import (
-	"flag"
-	"os"
-
-	// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
-	// to ensure that exec-entrypoint and run can make use of them.
-	_ "k8s.io/client-go/plugin/pkg/client/auth"
-
-	"k8s.io/apimachinery/pkg/runtime"
-	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
-	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
-	ctrl "sigs.k8s.io/controller-runtime"
-	"sigs.k8s.io/controller-runtime/pkg/healthz"
-	"sigs.k8s.io/controller-runtime/pkg/log/zap"
-	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
-
-	dodocloudv1 "github.com/giolekva/pcloud/core/ns-controller/api/v1"
-	"github.com/giolekva/pcloud/core/ns-controller/controllers"
-	//+kubebuilder:scaffold:imports
-
-	"github.com/go-git/go-billy/v5/osfs"
-)
-
-var (
-	scheme   = runtime.NewScheme()
-	setupLog = ctrl.Log.WithName("setup")
-)
-
-func init() {
-	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
-
-	utilruntime.Must(dodocloudv1.AddToScheme(scheme))
-	//+kubebuilder:scaffold:scheme
-}
-
-func main() {
-	var metricsAddr string
-	var enableLeaderElection bool
-	var probeAddr string
-	var configDir string
-	var apiPort int
-	flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
-	flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
-	flag.BoolVar(&enableLeaderElection, "leader-elect", false,
-		"Enable leader election for controller manager. "+
-			"Enabling this will ensure there is only one active controller manager.")
-	flag.StringVar(&configDir, "config-dir", "/etc/pcloud/dns-zone-configs", "Path to the DNS configurations directory")
-	flag.IntVar(&apiPort, "api-port", 8082, "Port to listen for API requests")
-	opts := zap.Options{
-		Development: true,
-	}
-	opts.BindFlags(flag.CommandLine)
-	flag.Parse()
-
-	ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
-
-	mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
-		Scheme: scheme,
-		Metrics: server.Options{
-			BindAddress: metricsAddr,
-		},
-		// Port:                   9443,
-		HealthProbeBindAddress: probeAddr,
-		LeaderElection:         enableLeaderElection,
-		LeaderElectionID:       "c1db6143.dodo.cloud",
-		// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
-		// when the Manager ends. This requires the binary to immediately end when the
-		// Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
-		// speeds up voluntary leader transitions as the new leader don't have to wait
-		// LeaseDuration time first.
-		//
-		// In the default scaffold provided, the program ends immediately after
-		// the manager stops, so would be fine to enable this option. However,
-		// if you are doing or is intended to do any operation such as perform cleanups
-		// after the manager stops then its usage might be unsafe.
-		// LeaderElectionReleaseOnCancel: true,
-	})
-	if err != nil {
-		setupLog.Error(err, "unable to start manager")
-		os.Exit(1)
-	}
-
-	if err != nil {
-		setupLog.Error(err, "unable to chroot into configs directory")
-		os.Exit(1)
-	}
-	store, err := controllers.NewFSZoneStoreFactory(osfs.New(configDir))
-	if err != nil {
-		setupLog.Error(err, "unable to create zone store")
-		os.Exit(1)
-	}
-	s := NewServer(apiPort, store)
-	go s.Start()
-	if err = (&controllers.DNSZoneReconciler{
-		Client: mgr.GetClient(),
-		Scheme: mgr.GetScheme(),
-		Store:  store,
-	}).SetupWithManager(mgr); err != nil {
-		setupLog.Error(err, "unable to create controller", "controller", "DNSZone")
-		os.Exit(1)
-	}
-	//+kubebuilder:scaffold:builder
-
-	if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
-		setupLog.Error(err, "unable to set up health check")
-		os.Exit(1)
-	}
-	if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
-		setupLog.Error(err, "unable to set up ready check")
-		os.Exit(1)
-	}
-
-	setupLog.Info("starting manager")
-	if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
-		setupLog.Error(err, "problem running manager")
-		os.Exit(1)
-	}
-}
diff --git a/core/ns-controller/server.go b/core/ns-controller/server.go
deleted file mode 100644
index 41dd955..0000000
--- a/core/ns-controller/server.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"net/http"
-
-	"github.com/giolekva/pcloud/core/ns-controller/controllers"
-)
-
-type Server struct {
-	s     *http.Server
-	m     *http.ServeMux
-	store controllers.ZoneStoreFactory
-}
-
-func NewServer(port int, store controllers.ZoneStoreFactory) *Server {
-	m := http.NewServeMux()
-	s := &Server{
-		s: &http.Server{
-			Addr:    fmt.Sprintf(":%d", port),
-			Handler: m,
-		},
-		m:     m,
-		store: store,
-	}
-	m.HandleFunc("/create-txt-record", s.createTxtRecord)
-	m.HandleFunc("/delete-txt-record", s.deleteTxtRecord)
-	m.HandleFunc("/admin/purge", s.purge)
-	return s
-}
-
-func (s *Server) Start() error {
-	return s.s.ListenAndServe()
-}
-
-type createTextRecordReq struct {
-	Domain string `json:"domain,omitempty"`
-	Entry  string `json:"entry,omitempty"`
-	Text   string `json:"text,omitempty"`
-}
-
-func (s *Server) purge(w http.ResponseWriter, r *http.Request) {
-	s.store.Purge()
-}
-
-func (s *Server) createTxtRecord(w http.ResponseWriter, r *http.Request) {
-	var req createTextRecordReq
-	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		http.Error(w, err.Error(), http.StatusBadRequest)
-		return
-	}
-	zone, err := s.store.Get(req.Domain)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	if err := zone.AddTextRecord(req.Entry, req.Text); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	s.store.Debug()
-}
-
-func (s *Server) deleteTxtRecord(w http.ResponseWriter, r *http.Request) {
-	var req createTextRecordReq
-	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		http.Error(w, err.Error(), http.StatusBadRequest)
-		return
-	}
-	zone, err := s.store.Get(req.Domain)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	if err := zone.DeleteTextRecord(req.Entry, req.Text); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	s.store.Debug()
-}
diff --git a/core/port-allocator/main.go b/core/port-allocator/main.go
index a33f299..5b557de 100644
--- a/core/port-allocator/main.go
+++ b/core/port-allocator/main.go
@@ -28,7 +28,7 @@
 }
 
 type repoClient struct {
-	repo installer.RepoIO
+	repo soft.RepoIO
 	path string
 }
 
@@ -192,7 +192,7 @@
 }
 
 // TODO(gio): deduplicate
-func createRepoClient(addr string, keyPath string) (installer.RepoIO, error) {
+func createRepoClient(addr string, keyPath string) (soft.RepoIO, error) {
 	sshKey, err := os.ReadFile(keyPath)
 	if err != nil {
 		return nil, err
@@ -209,7 +209,7 @@
 	if err != nil {
 		return nil, err
 	}
-	return installer.NewRepoIO(repo, signer), nil
+	return soft.NewRepoIO(repo, signer), nil
 }
 
 func main() {
diff --git a/core/tcp-udp-transport/controllers/servicetransport_controller.go b/core/tcp-udp-transport/controllers/servicetransport_controller.go
index 12fb3a6..c6b44bc 100644
--- a/core/tcp-udp-transport/controllers/servicetransport_controller.go
+++ b/core/tcp-udp-transport/controllers/servicetransport_controller.go
@@ -115,7 +115,7 @@
 	if err != nil {
 		return ctrl.Result{RequeueAfter: time.Minute}, err
 	}
-	repoIO := installer.NewRepoIO(repo, r.Signer)
+	repoIO := soft.NewRepoIO(repo, r.Signer)
 	data, err := repoIO.ReadYaml(repoPath)
 	if err != nil {
 		return ctrl.Result{RequeueAfter: time.Minute}, err