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/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