AppManager: cache helm charts and container images to local registry

Caching container images is disabled until we figure out how to run
container registry behind TLS.

Change-Id: I0253f2a862e5adddff18a82b102f67258151c070
diff --git a/charts/appmanager/templates/install.yaml b/charts/appmanager/templates/install.yaml
index f84e7ba..96d6b08 100644
--- a/charts/appmanager/templates/install.yaml
+++ b/charts/appmanager/templates/install.yaml
@@ -10,6 +10,12 @@
   verbs:
   - create
 - apiGroups:
+  - "batch"
+  resources:
+  - jobs
+  verbs:
+  - create
+- apiGroups:
   - "helm.toolkit.fluxcd.io"
   resources:
   - helmreleases
diff --git a/charts/dodo-app/templates/install.yaml b/charts/dodo-app/templates/install.yaml
index f19ea1e..4746869 100644
--- a/charts/dodo-app/templates/install.yaml
+++ b/charts/dodo-app/templates/install.yaml
@@ -1,3 +1,28 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  name: job-creator
+rules:
+- apiGroups:
+  - "batch"
+  resources:
+  - jobs
+  verbs:
+  - create
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: job-creator
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: job-creator
+subjects:
+- kind: ServiceAccount
+  name: default
+  namespace: {{ .Release.Namespace }}
+---
 apiVersion: v1
 kind: Secret
 metadata:
@@ -10,7 +35,6 @@
 kind: Service
 metadata:
   name: dodo-app
-  namespace: {{ .Release.Namespace }}
 spec:
   type: ClusterIP
   selector:
@@ -25,7 +49,6 @@
 kind: Deployment
 metadata:
   name: dodo-app
-  namespace: {{ .Release.Namespace }}
 spec:
   selector:
     matchLabels:
diff --git a/charts/soft-serve/foo.yml b/charts/soft-serve/foo.yml
deleted file mode 100644
index 1b22e81..0000000
--- a/charts/soft-serve/foo.yml
+++ /dev/null
@@ -1,8 +0,0 @@
-        # - name: SOFT_SERVE_PORT
-        #   value: "{{ .Values.port }}"
-        # - name: SOFT_SERVE_INITIAL_ADMIN_KEY
-        #   value: "{{ .Values.adminKey }}"
-        # - name: SOFT_SERVE_KEY_PATH
-        #   value: /.ssh/key
-        # - name: SOFT_SERVE_REPO_PATH
-        #   value: /var/lib/soft-serve/repos
diff --git a/core/installer/Makefile b/core/installer/Makefile
index ce9c184..ca0fa6b 100644
--- a/core/installer/Makefile
+++ b/core/installer/Makefile
@@ -31,14 +31,11 @@
 create_env:
 	./pcloud --kubeconfig=../../priv/kubeconfig create-env --admin-priv-key=/Users/lekva/.ssh/id_rsa --name=lekva --ip=192.168.0.211 --admin-username=gio
 
-rpuppy:
-	./pcloud --kubeconfig=../../priv/kubeconfig install --ssh-key=/Users/lekva/.ssh/id_rsa --app=rpuppy --repo-addr=ssh://localhost:2222/lekva
-
 appmanager:
-	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner appmanager --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://localhost:2222/config --port=9090 # --app-repo-addr=http://localhost:8080
+	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner appmanager --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://10.43.196.174/config --port=9090 # --app-repo-addr=http://localhost:8080
 
 dodo-app:
-	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner dodo-app --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://localhost:2222/test
+	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner dodo-app --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://10.43.196.174/test
 
 welc:
 	./pcloud --kubeconfig=../../priv/kubeconfig welcome --ssh-key=/Users/lekva/.ssh/id_rsa --repo-addr=ssh://192.168.0.210/config --port=9090
@@ -47,10 +44,10 @@
 	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner envmanager --ssh-key=/Users/lekva/.ssh/id_rsa --repo-addr=192.168.100.210:22 --repo-name=config --port=9090
 
 rewrite:
-	./pcloud rewrite --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://localhost:2222/config
+	./pcloud rewrite --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://10.43.196.174/config
 
 launcher:
-	./pcloud launcher --port=9090 --logout-url=http://localhost:8080 --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://localhost:2222/config
+	./pcloud launcher --port=9090 --logout-url=http://localhost:8080 --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://10.43.196.174/config
 
 ## installer image
 build_arm64: export CGO_ENABLED=0
diff --git a/core/installer/app.go b/core/installer/app.go
index 2a16c1e..86447fe 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -15,343 +15,32 @@
 	"cuelang.org/go/cue/cuecontext"
 	"cuelang.org/go/cue/load"
 	cueyaml "cuelang.org/go/encoding/yaml"
+	helmv2 "github.com/fluxcd/helm-controller/api/v2"
 )
 
-//go:embed pcloud_app.cue
-var DodoAppCue []byte
+//go:embed app_configs/dodo_app.cue
+var dodoAppCue []byte
 
-// TODO(gio): import
-const cueEnvAppGlobal = `
-import (
-    "net"
-)
+//go:embed app_configs/app_base.cue
+var cueBaseConfig []byte
 
-#Global: {
-	id: string | *""
-	pcloudEnvName: string | *""
-	domain: string | *""
-    privateDomain: string | *""
-    contactEmail: string | *""
-    adminPublicKey: string | *""
-    publicIP: [...string] | *[]
-    nameserverIP: [...string] | *[]
-	namespacePrefix: string | *""
-	network: #EnvNetwork
-}
+//go:embed app_configs/app_global_env.cue
+var cueEnvAppGlobal []byte
 
-#EnvNetwork: {
-	dns: net.IPv4
-	dnsInClusterIP: net.IPv4
-	ingress: net.IPv4
-	headscale: net.IPv4
-	servicesFrom: net.IPv4
-	servicesTo: net.IPv4
-}
-
-// TODO(gio): remove
-ingressPrivate: "\(global.id)-ingress-private"
-ingressPublic: "\(global.pcloudEnvName)-ingress-public"
-issuerPrivate: "\(global.id)-private"
-issuerPublic: "\(global.id)-public"
-
-#Ingress: {
-	auth: #Auth
-	network: #Network
-	subdomain: string
-	service: close({
-		name: string
-		port: close({ name: string }) | close({ number: int & > 0 })
-	})
-
-	_domain: "\(subdomain).\(network.domain)"
-    _authProxyHTTPPortName: "http"
-
-	out: {
-		images: {
-			authProxy: #Image & {
-				repository: "giolekva"
-				name: "auth-proxy"
-				tag: "latest"
-				pullPolicy: "Always"
-			}
-		}
-		charts: {
-			ingress: #Chart & {
-				chart: "charts/ingress"
-				sourceRef: {
-					kind: "GitRepository"
-					name: "pcloud"
-					namespace: global.id
-				}
-			}
-			authProxy: #Chart & {
-				chart: "charts/auth-proxy"
-				sourceRef: {
-					kind: "GitRepository"
-					name: "pcloud"
-					namespace: global.id
-				}
-			}
-		}
-		helm: {
-			if auth.enabled {
-				"auth-proxy": {
-					chart: charts.authProxy
-					values: {
-						image: {
-							repository: images.authProxy.fullName
-							tag: images.authProxy.tag
-							pullPolicy: images.authProxy.pullPolicy
-						}
-						upstream: "\(service.name).\(release.namespace).svc.cluster.local"
-						whoAmIAddr: "https://accounts.\(global.domain)/sessions/whoami"
-						loginAddr: "https://accounts-ui.\(global.domain)/login"
-						membershipAddr: "http://memberships-api.\(global.id)-core-auth-memberships.svc.cluster.local/api/user"
-						groups: auth.groups
-						portName: _authProxyHTTPPortName
-					}
-				}
-			}
-			ingress: {
-				chart: charts.ingress
-				_service: service
-				values: {
-					domain: _domain
-					ingressClassName: network.ingressClass
-					certificateIssuer: network.certificateIssuer
-					service: {
-						if auth.enabled {
-							name: "auth-proxy"
-                            port: name: _authProxyHTTPPortName
-						}
-						if !auth.enabled {
-							name: _service.name
-							if _service.port.name != _|_ {
-								port: name: _service.port.name
-							}
-							if _service.port.number != _|_ {
-								port: number: _service.port.number
-							}
-						}
-					}
-				}
-			}
-		}
-	}
-}
-
-ingress: {}
-
-_ingressValidate: {
-	for key, value in ingress {
-		"\(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 = `
-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"
-
-#Release: {
-	appInstanceId: string
-	namespace: string
-	repoAddr: string
-	appDir: string
-}
-
-#Network: {
-	name: string
-	ingressClass: string
-	certificateIssuer: string | *""
-	domain: string
-	allocatePortAddr: string
-}
-
-#Auth: {
-  enabled: bool | *false // TODO(gio): enabled by default?
-  groups: string | *"" // TODO(gio): []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
-}
-
-#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 {
-		"\(key)": #Image & value
-	}
-    for _, value in _ingressValidate {
-        for name, image in value.out.images {
-            "\(name)": #Image & image
-        }
-    }
-}
-
-charts: {
-	for key, value in charts {
-		"\(key)": #Chart & value
-	}
-    for _, value in _ingressValidate {
-        for name, chart in value.out.charts {
-            "\(name)": #Chart & chart
-        }
-    }
-}
-
-#ResourceReference: {
-    name: string
-    namespace: string
-}
-
-#Helm: {
-	name: string
-	dependsOn: [...#ResourceReference] | *[]
-	...
-}
-
-_helmValidate: {
-	for key, value in helm {
-		"\(key)": #Helm & value & {
-			name: key
-		}
-	}
-	for key, value in _ingressValidate {
-		for ing, ingValue in value.out.helm {
-            // TODO(gio): support multiple ingresses
-			// "\(key)-\(ing)": #Helm & ingValue & {
-			"\(ing)": #Helm & ingValue & {
-				// name: "\(key)-\(ing)"
-				name: ing
-			}
-		}
-	}
-}
-
-resources: {}
-
-#HelmRelease: {
-	_name: string
-	_chart: #Chart
-	_values: _
-	_dependencies: [...#ResourceReference] | *[]
-
-	apiVersion: "helm.toolkit.fluxcd.io/v2beta1"
-	kind: "HelmRelease"
-	metadata: {
-		name: _name
-   		namespace: release.namespace
-	}
-	spec: {
-		interval: "1m0s"
-		dependsOn: _dependencies
-		chart: {
-			spec: _chart
-		}
-		values: _values
-	}
-}
-
-output: {
-	for name, r in _helmValidate {
-		"\(name)": #HelmRelease & {
-			_name: name
-			_chart: r.chart
-			_values: r.values
-			_dependencies: r.dependsOn
-		}
-	}
-}
-
-#SSHKey: {
-	public: string
-	private: string
-}
-
-#HelpDocument: {
-    title: string
-    contents: string
-    children: [...#HelpDocument]
-}
-
-help: [...#HelpDocument] | *[]
-
-url: string | *""
-
-networks: {}
-`
+//go:embed app_configs/app_global_infra.cue
+var cueInfraAppGlobal []byte
 
 type rendered struct {
-	Name      string
-	Readme    string
-	Resources CueAppData
-	Ports     []PortForward
-	Data      CueAppData
-	URL       string
-	Help      []HelpDocument
-	Icon      string
+	Name            string
+	Readme          string
+	Resources       CueAppData
+	HelmCharts      HelmCharts
+	ContainerImages map[string]ContainerImage
+	Ports           []PortForward
+	Data            CueAppData
+	URL             string
+	Help            []HelpDocument
+	Icon            string
 }
 
 type HelpDocument struct {
@@ -360,6 +49,27 @@
 	Children []HelpDocument
 }
 
+type ContainerImage struct {
+	Registry   string `json:"registry"`
+	Repository string `json:"repository"`
+	Name       string `json:"name"`
+	Tag        string `json:"tag"`
+}
+
+type helmChartRef struct {
+	Kind string `json:"kind"`
+}
+
+type HelmCharts struct {
+	Git map[string]HelmChartGitRepo
+}
+
+type HelmChartGitRepo struct {
+	Address string `json:"address"`
+	Branch  string `json:"branch"`
+	Path    string `json:"path"`
+}
+
 type EnvAppRendered struct {
 	rendered
 	Config AppInstanceConfig
@@ -404,7 +114,7 @@
 
 type InfraApp interface {
 	App
-	Render(release Release, infra InfraConfig, values map[string]any) (InfraAppRendered, error)
+	Render(release Release, infra InfraConfig, values map[string]any, charts map[string]helmv2.HelmChartTemplateSpec) (InfraAppRendered, error)
 }
 
 type EnvNetwork struct {
@@ -459,7 +169,6 @@
 	}, nil
 }
 
-// TODO(gio): rename to EnvConfig
 type EnvConfig struct {
 	Id              string     `json:"id,omitempty"`
 	InfraName       string     `json:"pcloudEnvName,omitempty"`
@@ -475,7 +184,7 @@
 
 type EnvApp interface {
 	App
-	Render(release Release, env EnvConfig, values map[string]any) (EnvAppRendered, error)
+	Render(release Release, env EnvConfig, values map[string]any, charts map[string]helmv2.HelmChartTemplateSpec) (EnvAppRendered, error)
 }
 
 type cueApp struct {
@@ -583,8 +292,12 @@
 	ret := rendered{
 		Name:      a.Slug(),
 		Resources: make(CueAppData),
-		Ports:     make([]PortForward, 0),
-		Data:      a.data,
+		HelmCharts: HelmCharts{
+			Git: make(map[string]HelmChartGitRepo),
+		},
+		ContainerImages: make(map[string]ContainerImage),
+		Ports:           make([]PortForward, 0),
+		Data:            a.data,
 	}
 	var buf bytes.Buffer
 	if err := json.NewEncoder(&buf).Encode(values); err != nil {
@@ -613,6 +326,40 @@
 		return rendered{}, err
 	}
 	{
+		charts := res.LookupPath(cue.ParsePath("charts"))
+		i, err := charts.Fields()
+		if err != nil {
+			return rendered{}, err
+		}
+		for i.Next() {
+			var chartRef helmChartRef
+			if err := i.Value().Decode(&chartRef); err != nil {
+				return rendered{}, err
+			}
+			if chartRef.Kind == "GitRepository" {
+				var chart HelmChartGitRepo
+				if err := i.Value().Decode(&chart); err != nil {
+					return rendered{}, err
+				}
+				ret.HelmCharts.Git[cleanName(i.Selector().String())] = chart
+			}
+		}
+	}
+	{
+		images := res.LookupPath(cue.ParsePath("images"))
+		i, err := images.Fields()
+		if err != nil {
+			return rendered{}, err
+		}
+		for i.Next() {
+			var img ContainerImage
+			if err := i.Value().Decode(&img); err != nil {
+				return rendered{}, err
+			}
+			ret.ContainerImages[cleanName(i.Selector().String())] = img
+		}
+	}
+	{
 		output := res.LookupPath(cue.ParsePath("output"))
 		i, err := output.Fields()
 		if err != nil {
@@ -677,7 +424,7 @@
 	return NewCueEnvApp(CueAppData{
 		"app.cue":        appCfg,
 		"base.cue":       []byte(cueBaseConfig),
-		"pcloud_app.cue": DodoAppCue,
+		"pcloud_app.cue": dodoAppCue,
 		"env_app.cue":    []byte(cueEnvAppGlobal),
 	})
 }
@@ -686,17 +433,21 @@
 	return AppTypeEnv
 }
 
-func (a cueEnvApp) Render(release Release, env EnvConfig, values map[string]any) (EnvAppRendered, error) {
+func (a cueEnvApp) Render(release Release, env EnvConfig, values map[string]any, charts map[string]helmv2.HelmChartTemplateSpec) (EnvAppRendered, error) {
 	networks := CreateNetworks(env)
 	derived, err := deriveValues(values, a.Schema(), networks)
 	if err != nil {
 		return EnvAppRendered{}, nil
 	}
+	if charts == nil {
+		charts = make(map[string]helmv2.HelmChartTemplateSpec)
+	}
 	ret, err := a.cueApp.render(map[string]any{
-		"global":   env,
-		"release":  release,
-		"input":    derived,
-		"networks": networkMap(networks),
+		"global":      env,
+		"release":     release,
+		"input":       derived,
+		"localCharts": charts,
+		"networks":    networkMap(networks),
 	})
 	if err != nil {
 		return EnvAppRendered{}, err
@@ -732,11 +483,15 @@
 	return AppTypeInfra
 }
 
-func (a cueInfraApp) Render(release Release, infra InfraConfig, values map[string]any) (InfraAppRendered, error) {
+func (a cueInfraApp) Render(release Release, infra InfraConfig, values map[string]any, charts map[string]helmv2.HelmChartTemplateSpec) (InfraAppRendered, error) {
+	if charts == nil {
+		charts = make(map[string]helmv2.HelmChartTemplateSpec)
+	}
 	ret, err := a.cueApp.render(map[string]any{
-		"global":  infra,
-		"release": release,
-		"input":   values,
+		"global":      infra,
+		"release":     release,
+		"input":       values,
+		"localCharts": charts,
 	})
 	if err != nil {
 		return InfraAppRendered{}, err
diff --git a/core/installer/app_configs/app_base.cue b/core/installer/app_configs/app_base.cue
new file mode 100644
index 0000000..d6332e3
--- /dev/null
+++ b/core/installer/app_configs/app_base.cue
@@ -0,0 +1,206 @@
+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 | *release.imageRegistry
+	repository: string
+	name: string
+	tag: string
+	pullPolicy: string | *"IfNotPresent"
+	imageName: "\(repository)/\(name)"
+	fullName: "\(registry)/\(imageName)"
+	fullNameWithTag: "\(fullName):\(tag)"
+}
+
+#Chart: #GitRepositoryRef | #HelmRepositoryRef
+
+#GitRepositoryRef: {
+    name: string
+	kind: "GitRepository"
+    address: string
+	branch: string
+    path: string
+}
+
+#HelmRepositoryRef: {
+    name: string
+	kind: "HelmRepository"
+    repository: string
+	name: string
+	tag: string
+}
+
+#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
+	imageRegistry: string | *"docker.io"
+}
+
+#PortForward: {
+	allocator: string
+	protocol: "TCP" | "UDP" | *"TCP"
+	sourcePort: int
+	targetService: string
+	targetPort: int
+}
+
+portForward: [...#PortForward] | *[]
+
+global: #Global
+release: #Release
+
+images: {}
+
+images: {
+	for key, value in images {
+		"\(key)": #Image & value
+	}
+    for _, value in _ingressValidate {
+        for name, image in value.out.images {
+            "\(name)": #Image & image
+        }
+    }
+}
+
+charts: {}
+
+charts: {
+	for key, value in charts {
+		"\(key)": #Chart & value & {
+            name: key
+        }
+	}
+    for _, value in _ingressValidate {
+        for name, chart in value.out.charts {
+            "\(name)": #Chart & chart & {
+                name: name
+            }
+        }
+    }
+}
+
+localCharts: {
+	for key, _ in charts {
+		"\(key)": {
+        }
+    }
+}
+
+#ResourceReference: {
+    name: string
+    namespace: string
+}
+
+#Helm: {
+	name: string
+	dependsOn: [...#ResourceReference] | *[]
+	...
+}
+
+_helmValidate: {
+	for key, value in helm {
+		"\(key)": #Helm & value & {
+			name: key
+		}
+	}
+	for key, value in _ingressValidate {
+		for ing, ingValue in value.out.helm {
+            // TODO(gio): support multiple ingresses
+			// "\(key)-\(ing)": #Helm & ingValue & {
+			"\(ing)": #Helm & ingValue & {
+				// name: "\(key)-\(ing)"
+				name: ing
+			}
+		}
+	}
+}
+
+#HelmRelease: {
+	_name: string
+	_chart: _
+	_values: _
+	_dependencies: [...#ResourceReference] | *[]
+
+	apiVersion: "helm.toolkit.fluxcd.io/v2beta1"
+	kind: "HelmRelease"
+	metadata: {
+		name: _name
+   		namespace: release.namespace
+	}
+	spec: {
+		interval: "1m0s"
+		dependsOn: _dependencies
+		chart: spec: _chart
+		values: _values
+	}
+}
+
+output: {
+	for name, r in _helmValidate {
+		"\(name)": #HelmRelease & {
+			_name: name
+            _chart: localCharts[r.chart.name]
+			_values: r.values
+			_dependencies: r.dependsOn
+		}
+	}
+}
+
+#SSHKey: {
+	public: string
+	private: string
+}
+
+#HelpDocument: {
+    title: string
+    contents: string
+    children: [...#HelpDocument]
+}
+
+help: [...#HelpDocument] | *[]
+
+url: string | *""
diff --git a/core/installer/app_configs/app_global_env.cue b/core/installer/app_configs/app_global_env.cue
new file mode 100644
index 0000000..1ae2f4d
--- /dev/null
+++ b/core/installer/app_configs/app_global_env.cue
@@ -0,0 +1,131 @@
+#Global: {
+	id: string | *""
+	pcloudEnvName: string | *""
+	domain: string | *""
+    privateDomain: string | *""
+    contactEmail: string | *""
+    adminPublicKey: string | *""
+    publicIP: [...string] | *[]
+    nameserverIP: [...string] | *[]
+	namespacePrefix: string | *""
+	network: #EnvNetwork
+}
+
+networks: {
+	public: #Network & {
+		name: "Public"
+		ingressClass: "\(global.pcloudEnvName)-ingress-public"
+		certificateIssuer: "\(global.id)-public"
+		domain: global.domain
+		allocatePortAddr: "http://port-allocator.\(global.pcloudEnvName)-ingress-public.svc.cluster.local/api/allocate"
+	}
+	private: #Network & {
+		name: "Private"
+		ingressClass: "\(global.id)-ingress-private"
+		domain: global.privateDomain
+		allocatePortAddr: "http://port-allocator.\(global.id)-ingress-private.svc.cluster.local/api/allocate"
+	}
+}
+
+// TODO(gio): remove
+ingressPrivate: "\(global.id)-ingress-private"
+ingressPublic: "\(global.pcloudEnvName)-ingress-public"
+issuerPrivate: "\(global.id)-private"
+issuerPublic: "\(global.id)-public"
+
+#Ingress: {
+	auth: #Auth
+	network: #Network
+	subdomain: string
+	service: close({
+		name: string
+		port: close({ name: string }) | close({ number: int & > 0 })
+	})
+
+	_domain: "\(subdomain).\(network.domain)"
+    _authProxyHTTPPortName: "http"
+
+	out: {
+		images: {
+			authProxy: #Image & {
+				repository: "giolekva"
+				name: "auth-proxy"
+				tag: "latest"
+				pullPolicy: "Always"
+			}
+		}
+		charts: {
+			ingress: #Chart & {
+				kind: "GitRepository"
+				address: "https://github.com/giolekva/pcloud.git"
+				branch: "main"
+				path: "charts/ingress"
+			}
+			authProxy: #Chart & {
+				kind: "GitRepository"
+				address: "https://github.com/giolekva/pcloud.git"
+				branch: "main"
+				path: "charts/auth-proxy"
+			}
+		}
+		charts: {
+			for key, value in charts {
+				"\(key)": #Chart & value & {
+					name: key
+				}
+			}
+		}
+		helm: {
+			if auth.enabled {
+				"auth-proxy": {
+					chart: charts.authProxy
+					values: {
+						image: {
+							repository: images.authProxy.fullName
+							tag: images.authProxy.tag
+							pullPolicy: images.authProxy.pullPolicy
+						}
+						upstream: "\(service.name).\(release.namespace).svc.cluster.local"
+						whoAmIAddr: "https://accounts.\(global.domain)/sessions/whoami"
+						loginAddr: "https://accounts-ui.\(global.domain)/login"
+						membershipAddr: "http://memberships-api.\(global.id)-core-auth-memberships.svc.cluster.local/api/user"
+						groups: auth.groups
+						portName: _authProxyHTTPPortName
+					}
+				}
+			}
+			ingress: {
+				chart: charts.ingress
+				_service: service
+				values: {
+					domain: _domain
+					ingressClassName: network.ingressClass
+					certificateIssuer: network.certificateIssuer
+					service: {
+						if auth.enabled {
+							name: "auth-proxy"
+                            port: name: _authProxyHTTPPortName
+						}
+						if !auth.enabled {
+							name: _service.name
+							if _service.port.name != _|_ {
+								port: name: _service.port.name
+							}
+							if _service.port.number != _|_ {
+								port: number: _service.port.number
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+}
+
+ingress: {}
+
+_ingressValidate: {
+	for key, value in ingress {
+		"\(key)": #Ingress & value
+	}
+}
diff --git a/core/installer/app_configs/app_global_infra.cue b/core/installer/app_configs/app_global_infra.cue
new file mode 100644
index 0000000..578a30b
--- /dev/null
+++ b/core/installer/app_configs/app_global_infra.cue
@@ -0,0 +1,13 @@
+#Global: {
+	pcloudEnvName: string | *""
+    publicIP: [...string] | *[]
+	namespacePrefix: string | *""
+    infraAdminPublicKey: string | *""
+}
+
+// TODO(gio): remove
+ingressPublic: "\(global.pcloudEnvName)-ingress-public"
+
+ingress: {}
+_ingressValidate: {}
+
diff --git a/core/installer/pcloud_app.cue b/core/installer/app_configs/dodo_app.cue
similarity index 91%
rename from core/installer/pcloud_app.cue
rename to core/installer/app_configs/dodo_app.cue
index d453747..409b5d4 100644
--- a/core/installer/pcloud_app.cue
+++ b/core/installer/app_configs/dodo_app.cue
@@ -73,12 +73,10 @@
 
 charts: {
 	app: {
-		chart: "charts/app-runner"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/app-runner"
 	}
 }
 
diff --git a/core/installer/testapp.cue b/core/installer/app_configs/testapp.cue
similarity index 100%
rename from core/installer/testapp.cue
rename to core/installer/app_configs/testapp.cue
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index ae18ff8..9ee9671 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -9,10 +9,12 @@
 	"net/http"
 	"path"
 	"path/filepath"
+	"strings"
 
 	"github.com/giolekva/pcloud/core/installer/io"
 	"github.com/giolekva/pcloud/core/installer/soft"
 
+	helmv2 "github.com/fluxcd/helm-controller/api/v2"
 	"sigs.k8s.io/yaml"
 )
 
@@ -23,14 +25,24 @@
 
 type AppManager struct {
 	repoIO     soft.RepoIO
-	nsCreator  NamespaceCreator
+	nsc        NamespaceCreator
+	jc         JobCreator
+	hf         HelmFetcher
 	appDirRoot string
 }
 
-func NewAppManager(repoIO soft.RepoIO, nsCreator NamespaceCreator, appDirRoot string) (*AppManager, error) {
+func NewAppManager(
+	repoIO soft.RepoIO,
+	nsc NamespaceCreator,
+	jc JobCreator,
+	hf HelmFetcher,
+	appDirRoot string,
+) (*AppManager, error) {
 	return &AppManager{
 		repoIO,
-		nsCreator,
+		nsc,
+		jc,
+		hf,
 		appDirRoot,
 	}, nil
 }
@@ -108,14 +120,32 @@
 	return nil, ErrorNotFound
 }
 
-func (m *AppManager) AppConfig(name string) (AppInstanceConfig, error) {
-	var cfg AppInstanceConfig
-	if err := soft.ReadJson(m.repoIO, filepath.Join(m.appDirRoot, name, "config.json"), &cfg); err != nil {
-		return AppInstanceConfig{}, err
+func GetCueAppData(fs soft.RepoFS, dir string) (CueAppData, error) {
+	files, err := fs.ListDir(dir)
+	if err != nil {
+		return nil, err
+	}
+	cfg := CueAppData{}
+	for _, f := range files {
+		if !f.IsDir() && strings.HasSuffix(f.Name(), ".cue") {
+			contents, err := soft.ReadFile(fs, filepath.Join(dir, f.Name()))
+			if err != nil {
+				return nil, err
+			}
+			cfg[f.Name()] = contents
+		}
 	}
 	return cfg, nil
 }
 
+func (m *AppManager) GetInstanceApp(id string) (EnvApp, error) {
+	cfg, err := GetCueAppData(m.repoIO, filepath.Join(m.appDirRoot, id))
+	if err != nil {
+		return nil, err
+	}
+	return NewCueEnvApp(cfg)
+}
+
 type allocatePortReq struct {
 	Protocol      string `json:"protocol"`
 	SourcePort    int    `json:"sourcePort"`
@@ -186,8 +216,20 @@
 	ports []PortForward,
 	resources CueAppData,
 	data CueAppData,
-	opts ...soft.DoOption,
+	opts ...InstallOption,
 ) (ReleaseResources, error) {
+	var o installOptions
+	for _, i := range opts {
+		i(&o)
+	}
+	dopts := []soft.DoOption{}
+	if o.Branch != "" {
+		dopts = append(dopts, soft.WithForce())
+		dopts = append(dopts, soft.WithCommitToBranch(o.Branch))
+	}
+	if o.NoPublish {
+		dopts = append(dopts, soft.WithNoCommit())
+	}
 	return ReleaseResources{}, repo.Do(func(r soft.RepoFS) (string, error) {
 		if err := r.RemoveDir(appDir); err != nil {
 			return "", err
@@ -238,11 +280,18 @@
 			}
 		}
 		return fmt.Sprintf("install: %s", name), nil
-	}, opts...)
+	}, dopts...)
 }
 
 // TODO(gio): commit instanceId -> appDir mapping as well
-func (m *AppManager) Install(app EnvApp, instanceId string, appDir string, namespace string, values map[string]any, opts ...InstallOption) (ReleaseResources, error) {
+func (m *AppManager) Install(
+	app EnvApp,
+	instanceId string,
+	appDir string,
+	namespace string,
+	values map[string]any,
+	opts ...InstallOption,
+) (ReleaseResources, error) {
 	o := &installOptions{}
 	for _, i := range opts {
 		i(o)
@@ -251,7 +300,7 @@
 	if err := m.repoIO.Pull(); err != nil {
 		return ReleaseResources{}, err
 	}
-	if err := m.nsCreator.Create(namespace); err != nil {
+	if err := m.nsc.Create(namespace); err != nil {
 		return ReleaseResources{}, err
 	}
 	var env EnvConfig
@@ -264,22 +313,47 @@
 			return ReleaseResources{}, err
 		}
 	}
+	var lg LocalChartGenerator
+	if o.LG != nil {
+		lg = o.LG
+	} else {
+		lg = GitRepositoryLocalChartGenerator{env.Id, env.Id}
+	}
 	release := Release{
 		AppInstanceId: instanceId,
 		Namespace:     namespace,
 		RepoAddr:      m.repoIO.FullAddress(),
 		AppDir:        appDir,
 	}
-	rendered, err := app.Render(release, env, values)
+	rendered, err := app.Render(release, env, values, nil)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	dopts := []soft.DoOption{}
-	if o.Branch != "" {
-		dopts = append(dopts, soft.WithForce())
-		dopts = append(dopts, soft.WithCommitToBranch(o.Branch))
+	imageRegistry := fmt.Sprintf("zot.%s", env.PrivateDomain)
+	if o.FetchContainerImages {
+		if err := pullContainerImages(instanceId, rendered.ContainerImages, imageRegistry, namespace, m.jc); err != nil {
+			return ReleaseResources{}, err
+		}
 	}
-	if _, err := installApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, dopts...); err != nil {
+	var localCharts map[string]helmv2.HelmChartTemplateSpec
+	if err := m.repoIO.Do(func(rfs soft.RepoFS) (string, error) {
+		charts, err := pullHelmCharts(m.hf, rendered.HelmCharts, rfs, "/helm-charts")
+		if err != nil {
+			return "", err
+		}
+		localCharts = generateLocalCharts(lg, charts)
+		return "pull helm charts", nil
+	}); err != nil {
+		return ReleaseResources{}, err
+	}
+	if o.FetchContainerImages {
+		release.ImageRegistry = imageRegistry
+	}
+	rendered, err = app.Render(release, env, values, localCharts)
+	if err != nil {
+		return ReleaseResources{}, err
+	}
+	if _, err := installApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...); err != nil {
 		return ReleaseResources{}, err
 	}
 	// TODO(gio): add ingress-nginx to release resources
@@ -316,7 +390,12 @@
 	return ret
 }
 
-func (m *AppManager) Update(app EnvApp, instanceId string, values map[string]any, opts ...soft.DoOption) (ReleaseResources, error) {
+// TODO(gio): take app configuration from the repo
+func (m *AppManager) Update(
+	instanceId string,
+	values map[string]any,
+	opts ...InstallOption,
+) (ReleaseResources, error) {
 	if err := m.repoIO.Pull(); err != nil {
 		return ReleaseResources{}, err
 	}
@@ -325,18 +404,20 @@
 		return ReleaseResources{}, err
 	}
 	instanceDir := filepath.Join(m.appDirRoot, instanceId)
+	app, err := m.GetInstanceApp(instanceId)
+	if err != nil {
+		return ReleaseResources{}, err
+	}
 	instanceConfigPath := filepath.Join(instanceDir, "config.json")
 	config, err := m.appConfig(instanceConfigPath)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	release := Release{
-		AppInstanceId: instanceId,
-		Namespace:     config.Release.Namespace,
-		RepoAddr:      m.repoIO.FullAddress(),
-		AppDir:        instanceDir,
+	localCharts, err := extractLocalCharts(m.repoIO, filepath.Join(instanceDir, "rendered.json"))
+	if err != nil {
+		return ReleaseResources{}, err
 	}
-	rendered, err := app.Render(release, env, values)
+	rendered, err := app.Render(config.Release, env, values, localCharts)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -379,16 +460,12 @@
 	}
 }
 
-// InfraAppmanager
-
-type InfraAppManager struct {
-	repoIO    soft.RepoIO
-	nsCreator NamespaceCreator
-}
-
 type installOptions struct {
-	Env    *EnvConfig
-	Branch string
+	NoPublish            bool
+	Env                  *EnvConfig
+	Branch               string
+	LG                   LocalChartGenerator
+	FetchContainerImages bool
 }
 
 type InstallOption func(*installOptions)
@@ -405,10 +482,44 @@
 	}
 }
 
-func NewInfraAppManager(repoIO soft.RepoIO, nsCreator NamespaceCreator) (*InfraAppManager, error) {
+func WithLocalChartGenerator(lg LocalChartGenerator) InstallOption {
+	return func(o *installOptions) {
+		o.LG = lg
+	}
+}
+
+func WithFetchContainerImages() InstallOption {
+	return func(o *installOptions) {
+		o.FetchContainerImages = true
+	}
+}
+
+func WithNoPublish() InstallOption {
+	return func(o *installOptions) {
+		o.NoPublish = true
+	}
+}
+
+// InfraAppmanager
+
+type InfraAppManager struct {
+	repoIO soft.RepoIO
+	nsc    NamespaceCreator
+	hf     HelmFetcher
+	lg     LocalChartGenerator
+}
+
+func NewInfraAppManager(
+	repoIO soft.RepoIO,
+	nsc NamespaceCreator,
+	hf HelmFetcher,
+	lg LocalChartGenerator,
+) (*InfraAppManager, error) {
 	return &InfraAppManager{
 		repoIO,
-		nsCreator,
+		nsc,
+		hf,
+		lg,
 	}, nil
 }
 
@@ -453,7 +564,7 @@
 	if err := m.repoIO.Pull(); err != nil {
 		return ReleaseResources{}, err
 	}
-	if err := m.nsCreator.Create(namespace); err != nil {
+	if err := m.nsc.Create(namespace); err != nil {
 		return ReleaseResources{}, err
 	}
 	infra, err := m.Config()
@@ -465,14 +576,34 @@
 		RepoAddr:  m.repoIO.FullAddress(),
 		AppDir:    appDir,
 	}
-	rendered, err := app.Render(release, infra, values)
+	rendered, err := app.Render(release, infra, values, nil)
+	if err != nil {
+		return ReleaseResources{}, err
+	}
+	var localCharts map[string]helmv2.HelmChartTemplateSpec
+	if err := m.repoIO.Do(func(rfs soft.RepoFS) (string, error) {
+		charts, err := pullHelmCharts(m.hf, rendered.HelmCharts, rfs, "/helm-charts")
+		if err != nil {
+			return "", err
+		}
+		localCharts = generateLocalCharts(m.lg, charts)
+		return "pull helm charts", nil
+	}); err != nil {
+		return ReleaseResources{}, err
+	}
+	rendered, err = app.Render(release, infra, values, localCharts)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
 	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) (ReleaseResources, error) {
+// TODO(gio): take app configuration from the repo
+func (m *InfraAppManager) Update(
+	instanceId string,
+	values map[string]any,
+	opts ...InstallOption,
+) (ReleaseResources, error) {
 	if err := m.repoIO.Pull(); err != nil {
 		return ReleaseResources{}, err
 	}
@@ -481,20 +612,81 @@
 		return ReleaseResources{}, err
 	}
 	instanceDir := filepath.Join("/infrastructure", instanceId)
+	appCfg, err := GetCueAppData(m.repoIO, instanceDir)
+	if err != nil {
+		return ReleaseResources{}, err
+	}
+	app, err := NewCueInfraApp(appCfg)
+	if err != nil {
+		return ReleaseResources{}, err
+	}
 	instanceConfigPath := filepath.Join(instanceDir, "config.json")
 	config, err := m.appConfig(instanceConfigPath)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	release := Release{
-		AppInstanceId: instanceId,
-		Namespace:     config.Release.Namespace,
-		RepoAddr:      m.repoIO.FullAddress(),
-		AppDir:        instanceDir,
+	localCharts, err := extractLocalCharts(m.repoIO, filepath.Join(instanceDir, "rendered.json"))
+	if err != nil {
+		return ReleaseResources{}, err
 	}
-	rendered, err := app.Render(release, env, values)
+	rendered, err := app.Render(config.Release, env, values, localCharts)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
 	return installApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
 }
+
+func pullHelmCharts(hf HelmFetcher, charts HelmCharts, rfs soft.RepoFS, root string) (map[string]string, error) {
+	ret := make(map[string]string)
+	for name, chart := range charts.Git {
+		chartRoot := filepath.Join(root, name)
+		ret[name] = chartRoot
+		if err := hf.Pull(chart, rfs, chartRoot); err != nil {
+			return nil, err
+		}
+	}
+	return ret, nil
+}
+
+func generateLocalCharts(g LocalChartGenerator, charts map[string]string) map[string]helmv2.HelmChartTemplateSpec {
+	ret := make(map[string]helmv2.HelmChartTemplateSpec)
+	for name, path := range charts {
+		ret[name] = g.Generate(path)
+	}
+	return ret
+}
+
+func pullContainerImages(appName string, imgs map[string]ContainerImage, registry, namespace string, jc JobCreator) error {
+	for _, img := range imgs {
+		name := fmt.Sprintf("copy-image-%s-%s-%s-%s", appName, img.Repository, img.Name, img.Tag)
+		if err := jc.Create(name, namespace, "giolekva/skopeo:latest", []string{
+			"skopeo",
+			"--insecure-policy",
+			"copy",
+			"--dest-tls-verify=false", // TODO(gio): enable
+			"--multi-arch=all",
+			fmt.Sprintf("docker://%s/%s/%s:%s", img.Registry, img.Repository, img.Name, img.Tag),
+			fmt.Sprintf("docker://%s/%s/%s:%s", registry, img.Repository, img.Name, img.Tag),
+		}); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+type renderedInstance struct {
+	LocalCharts map[string]helmv2.HelmChartTemplateSpec `json:"localCharts"`
+}
+
+func extractLocalCharts(fs soft.RepoFS, path string) (map[string]helmv2.HelmChartTemplateSpec, error) {
+	r, err := fs.Reader(path)
+	if err != nil {
+		return nil, err
+	}
+	defer r.Close()
+	var cfg renderedInstance
+	if err := json.NewDecoder(r).Decode(&cfg); err != nil {
+		return nil, err
+	}
+	return cfg.LocalCharts, nil
+}
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index a646425..74dfbdc 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -2,8 +2,13 @@
 
 import (
 	_ "embed"
+	"fmt"
 	"net"
 	"testing"
+
+	"github.com/giolekva/pcloud/core/installer/soft"
+
+	"github.com/go-git/go-billy/v5/memfs"
 )
 
 var env = EnvConfig{
@@ -46,7 +51,7 @@
 				"groups":  "a,b",
 			},
 		}
-		rendered, err := a.Render(release, env, values)
+		rendered, err := a.Render(release, env, values, nil)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -76,7 +81,7 @@
 				"enabled": false,
 			},
 		}
-		rendered, err := a.Render(release, env, values)
+		rendered, err := a.Render(release, env, values, nil)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -101,7 +106,7 @@
 	values := map[string]any{
 		"authGroups": "foo,bar",
 	}
-	rendered, err := a.Render(release, env, values)
+	rendered, err := a.Render(release, env, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -131,7 +136,7 @@
 		},
 		"sshPort": 22,
 	}
-	rendered, err := a.Render(release, env, values)
+	rendered, err := a.Render(release, env, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -156,7 +161,7 @@
 		"subdomain": "jenkins",
 		"network":   "Private",
 	}
-	rendered, err := a.Render(release, env, values)
+	rendered, err := a.Render(release, env, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -186,7 +191,7 @@
 	values := map[string]any{
 		"sshPrivateKey": "private",
 	}
-	rendered, err := a.Render(release, infra, values)
+	rendered, err := a.Render(release, infra, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -215,7 +220,7 @@
 		},
 		"sshPrivateKey": "private",
 	}
-	rendered, err := a.Render(release, env, values)
+	rendered, err := a.Render(release, env, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -248,18 +253,40 @@
 			"groups":  "a,b",
 		},
 	}
-	rendered, err := app.Render(release, env, values)
+	rendered, err := app.Render(release, env, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
 	for _, r := range rendered.Resources {
 		t.Log(string(r))
 	}
+	for _, r := range rendered.HelmCharts.Git {
+		t.Log(fmt.Sprintf("%+v\n", r))
+	}
 	for _, r := range rendered.Data {
 		t.Log(string(r))
 	}
 }
 
+func TestPullGitHelmCharts(t *testing.T) {
+	charts := HelmCharts{
+		Git: map[string]HelmChartGitRepo{
+			"rpuppy": HelmChartGitRepo{
+				Address: "https://code.v1.dodo.cloud/pcloud",
+				Branch:  "main",
+				Path:    "charts/rpuppy",
+			},
+		},
+	}
+	fs := soft.NewBillyRepoFS(memfs.New())
+	hf := NewGitHelmFetcher()
+	pulled, err := pullHelmCharts(hf, charts, fs, "/helm-charts")
+	if err != nil {
+		t.Fatal(err)
+	}
+	fmt.Println(pulled)
+}
+
 func TestDNSGateway(t *testing.T) {
 	contents, err := valuesTmpls.ReadFile("values-tmpl/dns-gateway.cue")
 	if err != nil {
@@ -288,7 +315,7 @@
 	values := map[string]any{
 		"servers": []EnvDNS{EnvDNS{"v1.dodo.cloud", "10.0.1.2"}},
 	}
-	rendered, err := app.Render(release, infra, values)
+	rendered, err := app.Render(release, infra, values, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -300,7 +327,7 @@
 	}
 }
 
-//go:embed testapp.cue
+//go:embed app_configs/testapp.cue
 var testAppCue []byte
 
 type appInput struct {
diff --git a/core/installer/bootstrapper.go b/core/installer/bootstrapper.go
index c581f42..4b72556 100644
--- a/core/installer/bootstrapper.go
+++ b/core/installer/bootstrapper.go
@@ -90,7 +90,9 @@
 		fmt.Println("Failed to get config repo")
 		return err
 	}
-	mgr, err := NewInfraAppManager(repoIO, b.ns)
+	hf := NewGitHelmFetcher()
+	lg := NewInfraLocalChartGenerator()
+	mgr, err := NewInfraAppManager(repoIO, b.ns, hf, lg)
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/charts.go b/core/installer/charts.go
new file mode 100644
index 0000000..5f98d2e
--- /dev/null
+++ b/core/installer/charts.go
@@ -0,0 +1,36 @@
+package installer
+
+import (
+	"strings"
+
+	helmv2 "github.com/fluxcd/helm-controller/api/v2"
+)
+
+type LocalChartGenerator interface {
+	Generate(path string) helmv2.HelmChartTemplateSpec
+}
+
+type GitRepositoryLocalChartGenerator struct {
+	Name      string
+	Namespace string
+}
+
+func (g GitRepositoryLocalChartGenerator) Generate(path string) helmv2.HelmChartTemplateSpec {
+	p, _ := strings.CutPrefix(path, "/")
+	return helmv2.HelmChartTemplateSpec{
+		Chart: p,
+		SourceRef: helmv2.CrossNamespaceObjectReference{
+			Kind:      "GitRepository",
+			Name:      g.Name,
+			Namespace: g.Namespace,
+		},
+	}
+}
+
+type InfraLocalChartGenerator struct {
+	GitRepositoryLocalChartGenerator
+}
+
+func NewInfraLocalChartGenerator() InfraLocalChartGenerator {
+	return InfraLocalChartGenerator{GitRepositoryLocalChartGenerator{"dodo-flux", "dodo-flux"}}
+}
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index 8cffeba..33f122d 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -76,11 +76,16 @@
 	if err != nil {
 		return err
 	}
-	kube, err := newNSCreator()
+	nsc, err := newNSCreator()
 	if err != nil {
 		return err
 	}
-	m, err := installer.NewAppManager(repoIO, kube, "/apps")
+	jc, err := newJobCreator()
+	if err != nil {
+		return err
+	}
+	hf := installer.NewGitHelmFetcher()
+	m, err := installer.NewAppManager(repoIO, nsc, jc, hf, "/apps")
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
index d9f9a69..c45cd35 100644
--- a/core/installer/cmd/dodo_app.go
+++ b/core/installer/cmd/dodo_app.go
@@ -85,6 +85,10 @@
 	if err != nil {
 		return err
 	}
+	jc, err := newJobCreator()
+	if err != nil {
+		return err
+	}
 	if err := softClient.AddRepository("app"); err == nil {
 		repo, err := softClient.GetRepo("app")
 		if err != nil {
@@ -93,7 +97,7 @@
 		if err := initRepo(repo); err != nil {
 			return err
 		}
-		if err := welcome.UpdateDodoApp(softClient, dodoAppFlags.namespace, string(sshKey), &env); err != nil {
+		if err := welcome.UpdateDodoApp(softClient, dodoAppFlags.namespace, string(sshKey), jc, &env); err != nil {
 			return err
 		}
 		if err := softClient.AddWebhook("app", fmt.Sprintf("http://%s/update", dodoAppFlags.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
@@ -102,7 +106,7 @@
 	} else if !errors.Is(err, soft.ErrorAlreadyExists) {
 		return err
 	}
-	s := welcome.NewDodoAppServer(dodoAppFlags.port, string(sshKey), softClient, dodoAppFlags.namespace, env)
+	s := welcome.NewDodoAppServer(dodoAppFlags.port, string(sshKey), softClient, dodoAppFlags.namespace, jc, env)
 	return s.Start()
 }
 
diff --git a/core/installer/cmd/env_manager.go b/core/installer/cmd/env_manager.go
index aa33de8..c0ea8e8 100644
--- a/core/installer/cmd/env_manager.go
+++ b/core/installer/cmd/env_manager.go
@@ -72,6 +72,11 @@
 	if err != nil {
 		return err
 	}
+	jc, err := newJobCreator()
+	if err != nil {
+		return err
+	}
+	hf := installer.NewGitHelmFetcher()
 	dnsFetcher, err := newZoneFetcher()
 	if err != nil {
 		return err
@@ -83,6 +88,8 @@
 		repoIO,
 		repoClient,
 		nsCreator,
+		jc,
+		hf,
 		dnsFetcher,
 		installer.NewFixedLengthRandomNameGenerator(4),
 		httpClient,
diff --git a/core/installer/cmd/kube.go b/core/installer/cmd/kube.go
index 4c6ab59..f31ad8f 100644
--- a/core/installer/cmd/kube.go
+++ b/core/installer/cmd/kube.go
@@ -15,3 +15,11 @@
 func newHelmReleaseMonitor() (installer.HelmReleaseMonitor, error) {
 	return installer.NewHelmReleaseMonitor(rootFlags.kubeConfig)
 }
+
+func newJobCreator() (installer.JobCreator, error) {
+	clientset, err := installer.NewKubeConfig(rootFlags.kubeConfig)
+	if err != nil {
+		return nil, err
+	}
+	return installer.NewJobCreator(clientset.BatchV1()), nil
+}
diff --git a/core/installer/cmd/launcher.go b/core/installer/cmd/launcher.go
index 6a8ced5..0af1d1e 100644
--- a/core/installer/cmd/launcher.go
+++ b/core/installer/cmd/launcher.go
@@ -74,7 +74,7 @@
 	if err != nil {
 		return err
 	}
-	appManager, err := installer.NewAppManager(repoIO, nil, "/apps")
+	appManager, err := installer.NewAppManager(repoIO, nil, nil, nil, "/apps")
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/cmd/rewrite.go b/core/installer/cmd/rewrite.go
index c4bc7a8..3ebb390 100644
--- a/core/installer/cmd/rewrite.go
+++ b/core/installer/cmd/rewrite.go
@@ -61,7 +61,8 @@
 	}
 	log.Println("Creating repository")
 	r := installer.NewInMemoryAppRepository(installer.CreateAllApps())
-	mgr, err := installer.NewAppManager(repoIO, nil, "/apps")
+	hf := installer.NewGitHelmFetcher()
+	mgr, err := installer.NewAppManager(repoIO, nil, nil, hf, "/apps")
 	if err != nil {
 		return err
 	}
@@ -84,7 +85,14 @@
 			return err
 		}
 		v := inst.InputToValues(app.Schema())
-		if _, err := mgr.Update(app, inst.Id, v, soft.WithNoCommit()); err != nil {
+		if _, err := mgr.Install(
+			app,
+			inst.Id,
+			inst.Release.AppDir,
+			inst.Release.Namespace,
+			v,
+			installer.WithNoPublish(),
+		); err != nil {
 			return err
 		}
 	}
diff --git a/core/installer/cmd/welcome.go b/core/installer/cmd/welcome.go
index 64820e6..db1ce4a 100644
--- a/core/installer/cmd/welcome.go
+++ b/core/installer/cmd/welcome.go
@@ -3,6 +3,7 @@
 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"
@@ -91,6 +92,7 @@
 		welcomeFlags.port,
 		repoIO,
 		nsCreator,
+		installer.NewGitHelmFetcher(),
 		welcomeFlags.createAccountAddr,
 		welcomeFlags.loginAddr,
 		welcomeFlags.membershipsInitAddr,
diff --git a/core/installer/copy-image.yaml b/core/installer/copy-image.yaml
new file mode 100644
index 0000000..a5c27ef
--- /dev/null
+++ b/core/installer/copy-image.yaml
@@ -0,0 +1,26 @@
+apiVersion: batch/v1
+kind: Job
+metadata:
+  name: copy-image-{{ .Name }}
+  namespace: {{ .Namespace }}
+  # name: copy-image # -{{ .Name }}
+  # namespace: default # {{ .Namespace }}
+spec:
+  template:
+    spec:
+      containers:
+      - name: copy
+        image: giolekva/skopeo:amd64
+        imagePullPolicy: Always
+        command:
+        - skopeo
+        - --insecure-policy
+        - copy
+        - --dest-tls-verify=false # TODO(gio): enable
+        - --multi-arch=all
+        - {{ .From }}
+        - {{ .To }}
+        # - docker://docker.io/giolekva/skopeo:latest # {{ .From }}
+        # - docker://zot.p.v1.dodo.cloud/giolekva/skopeo:test # {{ .To }}
+      restartPolicy: Never
+  backoffLimit: 4
diff --git a/core/installer/derived.go b/core/installer/derived.go
index c3483cf..f623392 100644
--- a/core/installer/derived.go
+++ b/core/installer/derived.go
@@ -10,6 +10,7 @@
 	Namespace     string `json:"namespace"`
 	RepoAddr      string `json:"repoAddr"`
 	AppDir        string `json:"appDir"`
+	ImageRegistry string `json:"imageRegistry,omitempty"`
 }
 
 type Network struct {
diff --git a/core/installer/go.mod b/core/installer/go.mod
index a17907f..73dd5bb 100644
--- a/core/installer/go.mod
+++ b/core/installer/go.mod
@@ -2,7 +2,9 @@
 
 replace github.com/giolekva/pcloud/installer => /Users/lekva/dev/src/pcloud/core/installer
 
-go 1.21
+go 1.22.0
+
+toolchain go1.22.3
 
 // toolchain go1.21.5
 
@@ -11,6 +13,7 @@
 	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/fluxcd/helm-controller/api v1.0.1
 	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
@@ -22,9 +25,9 @@
 	golang.org/x/crypto v0.22.0
 	golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8
 	helm.sh/helm/v3 v3.14.3
-	k8s.io/api v0.29.3
-	k8s.io/apimachinery v0.29.3
-	k8s.io/client-go v0.29.3
+	k8s.io/api v0.30.0
+	k8s.io/apimachinery v0.30.0
+	k8s.io/client-go v0.30.0
 	sigs.k8s.io/yaml v1.4.0
 )
 
@@ -65,6 +68,8 @@
 	github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
 	github.com/fatih/color v1.16.0 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
+	github.com/fluxcd/pkg/apis/kustomize v1.5.0 // indirect
+	github.com/fluxcd/pkg/apis/meta v1.5.0 // indirect
 	github.com/go-errors/errors v1.5.1 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/go-gorp/gorp/v3 v3.1.0 // indirect
@@ -116,8 +121,6 @@
 	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
@@ -150,7 +153,7 @@
 	go.opentelemetry.io/otel/trace v1.24.0 // indirect
 	go.starlark.net v0.0.0-20240329153429-e6e8e7ce1b7a // indirect
 	golang.org/x/mod v0.16.0 // indirect
-	golang.org/x/net v0.23.0 // indirect
+	golang.org/x/net v0.24.0 // indirect
 	golang.org/x/oauth2 v0.18.0 // indirect
 	golang.org/x/sync v0.6.0 // indirect
 	golang.org/x/sys v0.19.0 // indirect
@@ -167,15 +170,16 @@
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
-	k8s.io/apiextensions-apiserver v0.29.3 // indirect
-	k8s.io/apiserver v0.29.3 // indirect
+	k8s.io/apiextensions-apiserver v0.30.0 // indirect
+	k8s.io/apiserver v0.30.0 // indirect
 	k8s.io/cli-runtime v0.29.3 // indirect
-	k8s.io/component-base v0.29.3 // indirect
+	k8s.io/component-base v0.30.0 // indirect
 	k8s.io/klog/v2 v2.120.1 // indirect
 	k8s.io/kube-openapi v0.0.0-20240403164606-bc84c2ddaf99 // indirect
 	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.18.1 // 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 21eb60a..e0033a2 100644
--- a/core/installer/go.sum
+++ b/core/installer/go.sum
@@ -122,6 +122,12 @@
 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/fluxcd/helm-controller/api v1.0.1 h1:Gn9qEVuif6D5+gHmVwTEZkR4+nmLOcOhKx4Sw2gL2EA=
+github.com/fluxcd/helm-controller/api v1.0.1/go.mod h1:/6AD5a2qjo/ttxVM8GR33syLZwqigta60DCLdy8GrME=
+github.com/fluxcd/pkg/apis/kustomize v1.5.0 h1:ah4sfqccnio+/5Edz/tVz6LetFhiBoDzXAElj6fFCzU=
+github.com/fluxcd/pkg/apis/kustomize v1.5.0/go.mod h1:nEzhnhHafhWOUUV8VMFLojUOH+HHDEsL75y54mt/c30=
+github.com/fluxcd/pkg/apis/meta v1.5.0 h1:/G82d2Az5D9op3F+wJUpD8jw/eTV0suM6P7+cSURoUM=
+github.com/fluxcd/pkg/apis/meta v1.5.0/go.mod h1:Y3u7JomuuKtr5fvP1Iji2/50FdRe5GcBug2jawNVkdM=
 github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI=
 github.com/foxcpp/go-mockdns v1.0.0/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtVlwxvzXTQ4=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -310,10 +316,10 @@
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
-github.com/onsi/ginkgo/v2 v2.14.0 h1:vSmGj2Z5YPb9JwCWT6z6ihcUvDhuXLc3sJiqd3jMKAY=
-github.com/onsi/ginkgo/v2 v2.14.0/go.mod h1:JkUdW7JkN0V6rFvsHcJ478egV3XH9NxpD27Hal/PhZw=
-github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=
-github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
+github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8=
+github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs=
+github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk=
+github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -456,8 +462,8 @@
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
-golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
-golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
+golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
+golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
 golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
 golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -556,20 +562,20 @@
 gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
 helm.sh/helm/v3 v3.14.3 h1:HmvRJlwyyt9HjgmAuxHbHv3PhMz9ir/XNWHyXfmnOP4=
 helm.sh/helm/v3 v3.14.3/go.mod h1:v6myVbyseSBJTzhmeE39UcPLNv6cQK6qss3dvgAySaE=
-k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw=
-k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80=
-k8s.io/apiextensions-apiserver v0.29.3 h1:9HF+EtZaVpFjStakF4yVufnXGPRppWFEQ87qnO91YeI=
-k8s.io/apiextensions-apiserver v0.29.3/go.mod h1:po0XiY5scnpJfFizNGo6puNU6Fq6D70UJY2Cb2KwAVc=
-k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU=
-k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU=
-k8s.io/apiserver v0.29.3 h1:xR7ELlJ/BZSr2n4CnD3lfA4gzFivh0wwfNfz9L0WZcE=
-k8s.io/apiserver v0.29.3/go.mod h1:hrvXlwfRulbMbBgmWRQlFru2b/JySDpmzvQwwk4GUOs=
+k8s.io/api v0.30.0 h1:siWhRq7cNjy2iHssOB9SCGNCl2spiF1dO3dABqZ8niA=
+k8s.io/api v0.30.0/go.mod h1:OPlaYhoHs8EQ1ql0R/TsUgaRPhpKNxIMrKQfWUp8QSE=
+k8s.io/apiextensions-apiserver v0.30.0 h1:jcZFKMqnICJfRxTgnC4E+Hpcq8UEhT8B2lhBcQ+6uAs=
+k8s.io/apiextensions-apiserver v0.30.0/go.mod h1:N9ogQFGcrbWqAY9p2mUAL5mGxsLqwgtUce127VtRX5Y=
+k8s.io/apimachinery v0.30.0 h1:qxVPsyDM5XS96NIh9Oj6LavoVFYff/Pon9cZeDIkHHA=
+k8s.io/apimachinery v0.30.0/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc=
+k8s.io/apiserver v0.30.0 h1:QCec+U72tMQ+9tR6A0sMBB5Vh6ImCEkoKkTDRABWq6M=
+k8s.io/apiserver v0.30.0/go.mod h1:smOIBq8t0MbKZi7O7SyIpjPsiKJ8qa+llcFCluKyqiY=
 k8s.io/cli-runtime v0.29.3 h1:r68rephmmytoywkw2MyJ+CxjpasJDQY7AGc3XY2iv1k=
 k8s.io/cli-runtime v0.29.3/go.mod h1:aqVUsk86/RhaGJwDhHXH0jcdqBrgdF3bZWk4Z9D4mkM=
-k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg=
-k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0=
-k8s.io/component-base v0.29.3 h1:Oq9/nddUxlnrCuuR2K/jp6aflVvc0uDvxMzAWxnGzAo=
-k8s.io/component-base v0.29.3/go.mod h1:Yuj33XXjuOk2BAaHsIGHhCKZQAgYKhqIxIjIr2UXYio=
+k8s.io/client-go v0.30.0 h1:sB1AGGlhY/o7KCyCEQ0bPWzYDL0pwOZO4vAtTSh/gJQ=
+k8s.io/client-go v0.30.0/go.mod h1:g7li5O5256qe6TYdAMyX/otJqMhIiGgTapdLchhmOaY=
+k8s.io/component-base v0.30.0 h1:cj6bp38g0ainlfYtaOQuRELh5KSYjhKxM+io7AUIk4o=
+k8s.io/component-base v0.30.0/go.mod h1:V9x/0ePFNaKeKYA3bOvIbrNoluTSG+fSJKjLdjOoeXQ=
 k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw=
 k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
 k8s.io/kube-openapi v0.0.0-20240403164606-bc84c2ddaf99 h1:w6nThEmGo9zcL+xH1Tu6pjxJ3K1jXFW+V0u4peqN8ks=
@@ -580,6 +586,8 @@
 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.18.1 h1:RpWbigmuiylbxOCLy0tGnq1cU1qWPwNIQzoJk+QeJx4=
+sigs.k8s.io/controller-runtime v0.18.1/go.mod h1:tuAt1+wbVsXIT8lPtk5RURxqAnq7xkpv2Mhttslg7Hw=
 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/helm.go b/core/installer/helm.go
index 1f805b9..2c13b50 100644
--- a/core/installer/helm.go
+++ b/core/installer/helm.go
@@ -2,7 +2,17 @@
 
 import (
 	"fmt"
+	"io"
+	"io/fs"
+	"path/filepath"
 
+	"github.com/giolekva/pcloud/core/installer/soft"
+
+	"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/plumbing"
+	"github.com/go-git/go-git/v5/storage/memory"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/kube"
 )
@@ -30,3 +40,49 @@
 	}
 	return config, nil
 }
+
+type HelmFetcher interface {
+	Pull(chart HelmChartGitRepo, rfs soft.RepoFS, root string) error
+}
+
+type gitHelmFetcher struct{}
+
+func NewGitHelmFetcher() *gitHelmFetcher {
+	return &gitHelmFetcher{}
+}
+
+func (f *gitHelmFetcher) Pull(chart HelmChartGitRepo, rfs soft.RepoFS, root string) error {
+	ref := fmt.Sprintf("refs/heads/%s", chart.Branch)
+	r, err := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
+		URL:           chart.Address,
+		ReferenceName: plumbing.ReferenceName(ref),
+		SingleBranch:  true,
+		Depth:         1,
+	})
+	if err != nil {
+		return err
+	}
+	wt, err := r.Worktree()
+	if err != nil {
+		return err
+	}
+	wtFS, err := wt.Filesystem.Chroot(chart.Path)
+	if err != nil {
+		return err
+	}
+	return util.Walk(wtFS, "/", func(path string, info fs.FileInfo, err error) error {
+		if info.IsDir() {
+			return nil
+		}
+		inp, err := wtFS.Open(path)
+		if err != nil {
+			return err
+		}
+		out, err := rfs.Writer(filepath.Join(root, path))
+		if err != nil {
+			return err
+		}
+		_, err = io.Copy(out, inp)
+		return err
+	})
+}
diff --git a/core/installer/job.go b/core/installer/job.go
new file mode 100644
index 0000000..265510c
--- /dev/null
+++ b/core/installer/job.go
@@ -0,0 +1,63 @@
+package installer
+
+import (
+	"context"
+
+	batchv1 "k8s.io/api/batch/v1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes/typed/batch/v1"
+)
+
+type JobCreator interface {
+	Create(name, namespace string, image string, cmd []string) error
+}
+
+type noOpJobCreator struct{}
+
+func (c noOpJobCreator) Create(name, namespace string, image string, cmd []string) error {
+	return nil
+}
+
+func NewNoOpJobCreator() noOpJobCreator {
+	return noOpJobCreator{}
+}
+
+type realJobCreator struct {
+	v1.BatchV1Interface
+}
+
+func NewJobCreator(batch v1.BatchV1Interface) *realJobCreator {
+	return &realJobCreator{batch}
+}
+
+var onFailure corev1.RestartPolicy = "OnFailure"
+
+func (c *realJobCreator) Create(name, namespace string, image string, cmd []string) error {
+	_, err := c.Jobs(namespace).Create(context.Background(), &batchv1.Job{
+		TypeMeta: metav1.TypeMeta{
+			Kind:       "Job",
+			APIVersion: "batch/v1",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Spec: batchv1.JobSpec{
+			Template: corev1.PodTemplateSpec{
+				Spec: corev1.PodSpec{
+					Containers: []corev1.Container{
+						corev1.Container{
+							Name:            "job",
+							Image:           image,
+							ImagePullPolicy: "Always",
+							Command:         cmd,
+						},
+					},
+					RestartPolicy: onFailure,
+				},
+			},
+		},
+	}, metav1.CreateOptions{})
+	return err
+}
diff --git a/core/installer/soft/repoio.go b/core/installer/soft/repoio.go
index b916d24..b50bcaa 100644
--- a/core/installer/soft/repoio.go
+++ b/core/installer/soft/repoio.go
@@ -30,6 +30,7 @@
 	Writer(path string) (io.WriteCloser, error)
 	CreateDir(path string) error
 	RemoveDir(path string) error
+	ListDir(path string) ([]os.FileInfo, error)
 }
 
 type DoFn func(r RepoFS) (string, error)
@@ -120,6 +121,10 @@
 	return nil
 }
 
+func (r *repoFS) ListDir(path string) ([]os.FileInfo, error) {
+	return r.fs.ReadDir(path)
+}
+
 type repoIO struct {
 	*repoFS
 	repo   *Repository
diff --git a/core/installer/tasks/dns.go b/core/installer/tasks/dns.go
index 0424cfe..7db2c3a 100644
--- a/core/installer/tasks/dns.go
+++ b/core/installer/tasks/dns.go
@@ -60,10 +60,6 @@
 			}
 		}
 		{
-			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
@@ -84,7 +80,7 @@
 				env.Domain,
 				env.Network.DNSInClusterIP.String(),
 			})
-			if _, err := st.infraAppManager.Update(app, "dns-gateway", map[string]any{
+			if _, err := st.infraAppManager.Update("dns-gateway", map[string]any{
 				"servers": servers,
 			}); err != nil {
 				return err
diff --git a/core/installer/tasks/env.go b/core/installer/tasks/env.go
index a38e5b1..20528a3 100644
--- a/core/installer/tasks/env.go
+++ b/core/installer/tasks/env.go
@@ -15,6 +15,8 @@
 type state struct {
 	infoListener    EnvInfoListener
 	nsCreator       installer.NamespaceCreator
+	jc              installer.JobCreator
+	hf              installer.HelmFetcher
 	dnsFetcher      installer.ZoneStatusFetcher
 	httpClient      http.Client
 	dnsClient       dns.Client
@@ -34,6 +36,8 @@
 func NewCreateEnvTask(
 	env installer.EnvConfig,
 	nsCreator installer.NamespaceCreator,
+	jc installer.JobCreator,
+	hf installer.HelmFetcher,
 	dnsFetcher installer.ZoneStatusFetcher,
 	httpClient http.Client,
 	dnsClient dns.Client,
@@ -45,6 +49,8 @@
 	st := state{
 		infoListener:    infoListener,
 		nsCreator:       nsCreator,
+		jc:              jc,
+		hf:              hf,
 		dnsFetcher:      dnsFetcher,
 		httpClient:      httpClient,
 		dnsClient:       dnsClient,
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index e71de44..6f02fa3 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -18,7 +18,7 @@
 		if err != nil {
 			return err
 		}
-		appManager, err := installer.NewAppManager(r, st.nsCreator, "/apps")
+		appManager, err := installer.NewAppManager(r, st.nsCreator, st.jc, st.hf, "/apps")
 		if err != nil {
 			return err
 		}
@@ -57,31 +57,10 @@
 			if err := soft.WriteYaml(r, "config.yaml", env); err != nil {
 				return "", err
 			}
-			out, err := r.Writer("pcloud-charts.yaml")
-			if err != nil {
-				return "", err
-			}
-			defer out.Close()
-			_, err = fmt.Fprintf(out, `
-apiVersion: source.toolkit.fluxcd.io/v1
-kind: GitRepository
-metadata:
-  name: pcloud
-  namespace: %s
-spec:
-  interval: 1m0s
-  url: https://github.com/giolekva/pcloud
-  ref:
-    branch: main
-`, env.Id)
-			if err != nil {
-				return "", err
-			}
 			rootKust, err := soft.ReadKustomization(r, "kustomization.yaml")
 			if err != nil {
 				return "", err
 			}
-			rootKust.AddResources("pcloud-charts.yaml")
 			if err := soft.WriteYaml(r, "kustomization.yaml", rootKust); err != nil {
 				return "", err
 			}
diff --git a/core/installer/values-tmpl/appmanager.cue b/core/installer/values-tmpl/appmanager.cue
index 6e98d5e..7ce72e2 100644
--- a/core/installer/values-tmpl/appmanager.cue
+++ b/core/installer/values-tmpl/appmanager.cue
@@ -44,12 +44,10 @@
 
 charts: {
 	appmanager: {
-		chart: "charts/appmanager"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/appmanager"
 	}
 }
 
diff --git a/core/installer/values-tmpl/cert-manager.cue b/core/installer/values-tmpl/cert-manager.cue
index 9f9b5d1..4b6154a 100644
--- a/core/installer/values-tmpl/cert-manager.cue
+++ b/core/installer/values-tmpl/cert-manager.cue
@@ -35,20 +35,16 @@
 
 charts: {
 	certManager: {
-		chart: "charts/cert-manager"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/cert-manager"
 	}
 	dnsChallengeSolver: {
-		chart: "charts/cert-manager-webhook-pcloud"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/cert-manager-webhook-pcloud"
 	}
 }
 
diff --git a/core/installer/values-tmpl/certificate-issuer-private.cue b/core/installer/values-tmpl/certificate-issuer-private.cue
index ee50b49..eef76d3 100644
--- a/core/installer/values-tmpl/certificate-issuer-private.cue
+++ b/core/installer/values-tmpl/certificate-issuer-private.cue
@@ -7,12 +7,10 @@
 
 charts: {
 	"certificate-issuer-private": {
-		chart: "charts/certificate-issuer-private"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		path: "charts/certificate-issuer-private"
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
 	}
 }
 
diff --git a/core/installer/values-tmpl/certificate-issuer-public.cue b/core/installer/values-tmpl/certificate-issuer-public.cue
index 7a5d3ba..35242bf 100644
--- a/core/installer/values-tmpl/certificate-issuer-public.cue
+++ b/core/installer/values-tmpl/certificate-issuer-public.cue
@@ -7,12 +7,10 @@
 
 charts: {
 	"certificate-issuer-public": {
-		chart: "charts/certificate-issuer-public"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/certificate-issuer-public"
 	}
 }
 
diff --git a/core/installer/values-tmpl/config-repo.cue b/core/installer/values-tmpl/config-repo.cue
index 8d6a52f..cff139e 100644
--- a/core/installer/values-tmpl/config-repo.cue
+++ b/core/installer/values-tmpl/config-repo.cue
@@ -18,12 +18,10 @@
 
 charts: {
 	softserve: {
-		chart: "charts/soft-serve"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/soft-serve"
 	}
 }
 
diff --git a/core/installer/values-tmpl/core-auth.cue b/core/installer/values-tmpl/core-auth.cue
index eb19493..e2f05c4 100644
--- a/core/installer/values-tmpl/core-auth.cue
+++ b/core/installer/values-tmpl/core-auth.cue
@@ -64,20 +64,16 @@
 
 charts: {
 	auth: {
-		chart: "charts/auth"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/auth"
 	}
 	postgres: {
-		chart: "charts/postgresql"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/postgresql"
 	}
 }
 
diff --git a/core/installer/values-tmpl/csi-driver-smb.cue b/core/installer/values-tmpl/csi-driver-smb.cue
index 6d93bbf..9dec7c2 100644
--- a/core/installer/values-tmpl/csi-driver-smb.cue
+++ b/core/installer/values-tmpl/csi-driver-smb.cue
@@ -30,12 +30,10 @@
 
 charts: {
 	csiDriverSMB: {
-		chart: "charts/csi-driver-smb"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/csi-driver-smb"
 	}
 }
 
diff --git a/core/installer/values-tmpl/dns-gateway.cue b/core/installer/values-tmpl/dns-gateway.cue
index 31b729c..739a4a9 100644
--- a/core/installer/values-tmpl/dns-gateway.cue
+++ b/core/installer/values-tmpl/dns-gateway.cue
@@ -21,12 +21,10 @@
 
 charts: {
 	coredns: {
-		chart: "charts/coredns"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/coredns"
 	}
 }
 
diff --git a/core/installer/values-tmpl/dodo-app.cue b/core/installer/values-tmpl/dodo-app.cue
index 5acc6db..db8989d 100644
--- a/core/installer/values-tmpl/dodo-app.cue
+++ b/core/installer/values-tmpl/dodo-app.cue
@@ -40,20 +40,16 @@
 
 charts: {
 	softserve: {
-		chart: "charts/soft-serve"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/soft-serve"
 	}
 	dodoApp: {
-		chart: "charts/dodo-app"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/dodo-app"
 	}
 }
 
diff --git a/core/installer/values-tmpl/env-dns.cue b/core/installer/values-tmpl/env-dns.cue
index 5c95a54..b4a80a9 100644
--- a/core/installer/values-tmpl/env-dns.cue
+++ b/core/installer/values-tmpl/env-dns.cue
@@ -27,44 +27,34 @@
 
 charts: {
 	coredns: {
-		chart: "charts/coredns"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/coredns"
 	}
 	api: {
-		chart: "charts/dns-api"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/dns-api"
 	}
 	volume: {
-		chart: "charts/volumes"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/volumes"
 	}
 	service: {
-		chart: "charts/service"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/service"
 	}
 	ipAddressPool: {
-		chart: "charts/metallb-ipaddresspool"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/metallb-ipaddresspool"
 	}
 }
 
diff --git a/core/installer/values-tmpl/env-manager.cue b/core/installer/values-tmpl/env-manager.cue
index b93d918..43c22e8 100644
--- a/core/installer/values-tmpl/env-manager.cue
+++ b/core/installer/values-tmpl/env-manager.cue
@@ -23,12 +23,10 @@
 
 charts: {
 	envManager: {
-		chart: "charts/env-manager"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/env-manager"
 	}
 }
 
diff --git a/core/installer/values-tmpl/fluxcd-reconciler.cue b/core/installer/values-tmpl/fluxcd-reconciler.cue
index ebb6c4d..a4c2693 100644
--- a/core/installer/values-tmpl/fluxcd-reconciler.cue
+++ b/core/installer/values-tmpl/fluxcd-reconciler.cue
@@ -14,12 +14,10 @@
 
 charts: {
 	fluxcdReconciler: {
-		chart: "charts/fluxcd-reconciler"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/fluxcd-reconciler"
 	}
 }
 
diff --git a/core/installer/values-tmpl/gerrit.cue b/core/installer/values-tmpl/gerrit.cue
index 3cffb3b..529d0f3 100644
--- a/core/installer/values-tmpl/gerrit.cue
+++ b/core/installer/values-tmpl/gerrit.cue
@@ -51,44 +51,34 @@
 
 charts: {
 	ingress: {
-		chart: "charts/ingress"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/ingress"
 	}
 	volume: {
-		chart: "charts/volumes"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/volumes"
 	}
 	gerrit: {
-		chart: "charts/gerrit"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/gerrit"
 	}
 	oauth2Client: {
-		chart: "charts/oauth2-client"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/oauth2-client"
 	}
 	resourceRenderer: {
-		chart: "charts/resource-renderer"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/resource-renderer"
 	}
 }
 
diff --git a/core/installer/values-tmpl/headscale-controller.cue b/core/installer/values-tmpl/headscale-controller.cue
index 86570ed..0abf6e4 100644
--- a/core/installer/values-tmpl/headscale-controller.cue
+++ b/core/installer/values-tmpl/headscale-controller.cue
@@ -21,12 +21,10 @@
 
 charts: {
 	headscaleController: {
-		chart: "charts/headscale-controller"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/headscale-controller"
 	}
 }
 
diff --git a/core/installer/values-tmpl/headscale-user.cue b/core/installer/values-tmpl/headscale-user.cue
index 893ca28..6ba12cb 100644
--- a/core/installer/values-tmpl/headscale-user.cue
+++ b/core/installer/values-tmpl/headscale-user.cue
@@ -8,16 +8,12 @@
 name: "headscale-user"
 namespace: "app-headscale"
 
-images: {}
-
 charts: {
 	headscaleUser: {
-		chart: "charts/headscale-user"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/headscale-user"
 	}
 }
 
diff --git a/core/installer/values-tmpl/headscale.cue b/core/installer/values-tmpl/headscale.cue
index e0b8703..ec06383 100644
--- a/core/installer/values-tmpl/headscale.cue
+++ b/core/installer/values-tmpl/headscale.cue
@@ -24,20 +24,16 @@
 
 charts: {
 	oauth2Client: {
-		chart: "charts/oauth2-client"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/oauth2-client"
 	}
 	headscale: {
-		chart: "charts/headscale"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/headscale"
 	}
 }
 
diff --git a/core/installer/values-tmpl/hydra-maester.cue b/core/installer/values-tmpl/hydra-maester.cue
index 4f76f84..a48536b 100644
--- a/core/installer/values-tmpl/hydra-maester.cue
+++ b/core/installer/values-tmpl/hydra-maester.cue
@@ -14,12 +14,10 @@
 
 charts: {
 	hydraMaester: {
-		chart: "charts/hydra-maester"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/hydra-maester"
 	}
 }
 
diff --git a/core/installer/values-tmpl/ingress-public.cue b/core/installer/values-tmpl/ingress-public.cue
index 6823a7b..f0827e5 100644
--- a/core/installer/values-tmpl/ingress-public.cue
+++ b/core/installer/values-tmpl/ingress-public.cue
@@ -27,20 +27,16 @@
 
 charts: {
 	ingressNginx: {
-		chart: "charts/ingress-nginx"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/ingress-nginx"
 	}
 	portAllocator: {
-		chart: "charts/port-allocator"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/port-allocator"
 	}
 }
 
diff --git a/core/installer/values-tmpl/jellyfin.cue b/core/installer/values-tmpl/jellyfin.cue
index 2d5d45c..dd50adc 100644
--- a/core/installer/values-tmpl/jellyfin.cue
+++ b/core/installer/values-tmpl/jellyfin.cue
@@ -24,12 +24,10 @@
 
 charts: {
 	jellyfin: {
-		chart: "charts/jellyfin"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/jellyfin"
 	}
 }
 
diff --git a/core/installer/values-tmpl/jenkins.cue b/core/installer/values-tmpl/jenkins.cue
index 673e544..5326c7a 100644
--- a/core/installer/values-tmpl/jenkins.cue
+++ b/core/installer/values-tmpl/jenkins.cue
@@ -37,28 +37,22 @@
 
 charts: {
     jenkins: {
-        chart: "charts/jenkins"
-        sourceRef: {
-            kind: "GitRepository"
-            name: "pcloud"
-            namespace: global.id
-        }
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/jenkins"
     }
 	volume: {
-		chart: "charts/volumes"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/volumes"
 	}
 	oauth2Client: {
-		chart: "charts/oauth2-client"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/oauth2-client"
 	}
 }
 
diff --git a/core/installer/values-tmpl/launcher.cue b/core/installer/values-tmpl/launcher.cue
index 8aabbd5..12e2246 100644
--- a/core/installer/values-tmpl/launcher.cue
+++ b/core/installer/values-tmpl/launcher.cue
@@ -41,12 +41,10 @@
 
 charts: {
     launcher: {
-        chart: "charts/launcher"
-        sourceRef: {
-            kind: "GitRepository"
-            name: "pcloud"
-            namespace: global.id
-        }
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/launcher"
     }
 }
 
diff --git a/core/installer/values-tmpl/matrix.cue b/core/installer/values-tmpl/matrix.cue
index 4b4ca06..37103b7 100644
--- a/core/installer/values-tmpl/matrix.cue
+++ b/core/installer/values-tmpl/matrix.cue
@@ -28,28 +28,22 @@
 
 charts: {
 	oauth2Client: {
-		chart: "charts/oauth2-client"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/oauth2-client"
 	}
 	matrix: {
-		chart: "charts/matrix"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/matrix"
 	}
 	postgres: {
-		chart: "charts/postgresql"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/postgresql"
 	}
 }
 
diff --git a/core/installer/values-tmpl/memberships.cue b/core/installer/values-tmpl/memberships.cue
index c0ffaeb..9bf9b57 100644
--- a/core/installer/values-tmpl/memberships.cue
+++ b/core/installer/values-tmpl/memberships.cue
@@ -40,12 +40,10 @@
 
 charts: {
     memberships: {
-        chart: "charts/memberships"
-        sourceRef: {
-            kind: "GitRepository"
-            name: "pcloud"
-            namespace: global.id
-        }
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/memberships"
     }
 }
 
diff --git a/core/installer/values-tmpl/metallb-ipaddresspool.cue b/core/installer/values-tmpl/metallb-ipaddresspool.cue
index 603b4b3..d990a88 100644
--- a/core/installer/values-tmpl/metallb-ipaddresspool.cue
+++ b/core/installer/values-tmpl/metallb-ipaddresspool.cue
@@ -13,12 +13,10 @@
 
 charts: {
 	metallbIPAddressPool: {
-		chart: "charts/metallb-ipaddresspool"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName // TODO(gio): id ?
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/metallb-ipaddresspool"
 	}
 }
 
diff --git a/core/installer/values-tmpl/open-project.cue b/core/installer/values-tmpl/open-project.cue
index 76f604b..8c2da74 100644
--- a/core/installer/values-tmpl/open-project.cue
+++ b/core/installer/values-tmpl/open-project.cue
@@ -42,28 +42,22 @@
 
 charts: {
 	openProject: {
-		chart: "charts/openproject"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/openproject"
 	}
 	volume: {
-		chart: "charts/volumes"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		path: "charts/volumes"
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
 	}
 	postgres: {
-		chart: "charts/postgresql"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/postgresql"
 	}
 }
 
diff --git a/core/installer/values-tmpl/penpot.cue b/core/installer/values-tmpl/penpot.cue
index 4a8e66a..961cd30 100644
--- a/core/installer/values-tmpl/penpot.cue
+++ b/core/installer/values-tmpl/penpot.cue
@@ -41,28 +41,22 @@
 
 charts: {
 	postgres: {
-		chart: "charts/postgresql"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/postgresql"
 	}
 	oauth2Client: {
-		chart: "charts/oauth2-client"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/oauth2-client"
 	}
 	penpot: {
-		chart: "charts/penpot"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/penpot"
 	}
 }
 
diff --git a/core/installer/values-tmpl/pihole.cue b/core/installer/values-tmpl/pihole.cue
index d1b9097..c5f740d 100644
--- a/core/installer/values-tmpl/pihole.cue
+++ b/core/installer/values-tmpl/pihole.cue
@@ -38,12 +38,10 @@
 
 charts: {
 	pihole: {
-		chart: "charts/pihole"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/pihole"
 	}
 }
 
diff --git a/core/installer/values-tmpl/private-network.cue b/core/installer/values-tmpl/private-network.cue
index 65bfeaf..fe78f32 100644
--- a/core/installer/values-tmpl/private-network.cue
+++ b/core/installer/values-tmpl/private-network.cue
@@ -38,28 +38,22 @@
 
 charts: {
 	"ingress-nginx": {
-		chart: "charts/ingress-nginx"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/ingress-nginx"
 	}
 	"tailscale-proxy": {
-		chart: "charts/tailscale-proxy"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/tailscale-proxy"
 	}
 	portAllocator: {
-		chart: "charts/port-allocator"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/port-allocator"
 	}
 }
 
diff --git a/core/installer/values-tmpl/qbittorrent.cue b/core/installer/values-tmpl/qbittorrent.cue
index 179a718..c4d7ca2 100644
--- a/core/installer/values-tmpl/qbittorrent.cue
+++ b/core/installer/values-tmpl/qbittorrent.cue
@@ -24,12 +24,10 @@
 
 charts: {
 	qbittorrent: {
-		chart: "charts/qbittorrent"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/qbittorrent"
 	}
 }
 
diff --git a/core/installer/values-tmpl/resource-renderer-controller.cue b/core/installer/values-tmpl/resource-renderer-controller.cue
index e596ba3..6c1bcd2 100644
--- a/core/installer/values-tmpl/resource-renderer-controller.cue
+++ b/core/installer/values-tmpl/resource-renderer-controller.cue
@@ -21,12 +21,10 @@
 
 charts: {
 	resourceRenderer: {
-		chart: "charts/resource-renderer-controller"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.pcloudEnvName
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/resource-renderer-controller"
 	}
 }
 
diff --git a/core/installer/values-tmpl/rpuppy.cue b/core/installer/values-tmpl/rpuppy.cue
index 05f40c7..0e40fe9 100644
--- a/core/installer/values-tmpl/rpuppy.cue
+++ b/core/installer/values-tmpl/rpuppy.cue
@@ -37,12 +37,10 @@
 
 charts: {
 	rpuppy: {
-		chart: "charts/rpuppy"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/rpuppy"
 	}
 }
 
diff --git a/core/installer/values-tmpl/soft-serve.cue b/core/installer/values-tmpl/soft-serve.cue
index 1bae24f..e2e351b 100644
--- a/core/installer/values-tmpl/soft-serve.cue
+++ b/core/installer/values-tmpl/soft-serve.cue
@@ -25,12 +25,10 @@
 
 charts: {
 	softserve: {
-		chart: "charts/soft-serve"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/soft-serve"
 	}
 }
 
diff --git a/core/installer/values-tmpl/url-shortener.cue b/core/installer/values-tmpl/url-shortener.cue
index 84a0edf..06ac7e1 100644
--- a/core/installer/values-tmpl/url-shortener.cue
+++ b/core/installer/values-tmpl/url-shortener.cue
@@ -38,12 +38,10 @@
 
 charts: {
     urlShortener: {
-        chart: "charts/url-shortener"
-        sourceRef: {
-            kind: "GitRepository"
-            name: "pcloud"
-            namespace: global.id
-        }
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/url-shortener"
     }
 }
 
diff --git a/core/installer/values-tmpl/vaultwarden.cue b/core/installer/values-tmpl/vaultwarden.cue
index fd2962c..7d11904 100644
--- a/core/installer/values-tmpl/vaultwarden.cue
+++ b/core/installer/values-tmpl/vaultwarden.cue
@@ -23,12 +23,10 @@
 
 charts: {
 	vaultwarden: {
-		chart: "charts/vaultwarden"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/vaultwarden"
 	}
 }
 
diff --git a/core/installer/values-tmpl/welcome.cue b/core/installer/values-tmpl/welcome.cue
index 30c6980..2abd8b2 100644
--- a/core/installer/values-tmpl/welcome.cue
+++ b/core/installer/values-tmpl/welcome.cue
@@ -21,12 +21,10 @@
 
 charts: {
 	welcome: {
-		chart: "charts/welcome"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/welcome"
 	}
 }
 
diff --git a/core/installer/values-tmpl/zot.cue b/core/installer/values-tmpl/zot.cue
index ac0674e..f83c671 100644
--- a/core/installer/values-tmpl/zot.cue
+++ b/core/installer/values-tmpl/zot.cue
@@ -41,20 +41,16 @@
 
 charts: {
 	zot: {
-		chart: "charts/zot"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/zot"
 	}
 	volume: {
-		chart: "charts/volumes"
-		sourceRef: {
-			kind: "GitRepository"
-			name: "pcloud"
-			namespace: global.id
-		}
+		kind: "GitRepository"
+		address: "https://github.com/giolekva/pcloud.git"
+		branch: "main"
+		path: "charts/volumes"
 	}
 }
 
@@ -100,7 +96,7 @@
 					}
 				})
 			}
-			persistnce: true
+			persistence: true
 			pvc: {
 				create: false
 				name: volumes.zot.name
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
index 5da26ef..a4430b8 100644
--- a/core/installer/welcome/appmanager.go
+++ b/core/installer/welcome/appmanager.go
@@ -247,11 +247,6 @@
 		http.Error(w, "empty slug", http.StatusBadRequest)
 		return
 	}
-	appConfig, err := s.m.AppConfig(slug)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
 	contents, err := ioutil.ReadAll(r.Body)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -262,16 +257,11 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	a, err := installer.FindEnvApp(s.r, appConfig.AppId)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
 	if _, ok := s.tasks[slug]; ok {
 		http.Error(w, "Update already in progress", http.StatusBadRequest)
 		return
 	}
-	rr, err := s.m.Update(a, slug, values)
+	rr, err := s.m.Update(slug, values)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -452,7 +442,7 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	a, err := installer.FindEnvApp(s.r, instance.AppId)
+	a, err := s.m.GetInstanceApp(instance.Id)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 5eb2f58..251a379 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -18,6 +18,7 @@
 	client    soft.Client
 	namespace string
 	env       installer.EnvConfig
+	jc        installer.JobCreator
 	workers   map[string]struct{}
 }
 
@@ -26,6 +27,7 @@
 	sshKey string,
 	client soft.Client,
 	namespace string,
+	jc installer.JobCreator,
 	env installer.EnvConfig,
 ) *DodoAppServer {
 	return &DodoAppServer{
@@ -34,6 +36,7 @@
 		client,
 		namespace,
 		env,
+		jc,
 		map[string]struct{}{},
 	}
 }
@@ -64,7 +67,7 @@
 	}
 	go func() {
 		time.Sleep(20 * time.Second)
-		if err := UpdateDodoApp(s.client, s.namespace, s.sshKey, &s.env); err != nil {
+		if err := UpdateDodoApp(s.client, s.namespace, s.sshKey, s.jc, &s.env); err != nil {
 			fmt.Println(err)
 		}
 	}()
@@ -90,16 +93,17 @@
 	fmt.Printf("registered worker: %s\n", req.Address)
 }
 
-func UpdateDodoApp(client soft.Client, namespace string, sshKey string, env *installer.EnvConfig) error {
+func UpdateDodoApp(client soft.Client, namespace string, sshKey string, jc installer.JobCreator, env *installer.EnvConfig) error {
 	repo, err := client.GetRepo("app")
 	if err != nil {
 		return err
 	}
-	nsCreator := installer.NewNoOpNamespaceCreator()
+	nsc := installer.NewNoOpNamespaceCreator()
 	if err != nil {
 		return err
 	}
-	m, err := installer.NewAppManager(repo, nsCreator, "/.dodo")
+	hf := installer.NewGitHelmFetcher()
+	m, err := installer.NewAppManager(repo, nsc, jc, hf, "/.dodo")
 	if err != nil {
 		return err
 	}
@@ -112,10 +116,11 @@
 	if err != nil {
 		return err
 	}
+	lg := installer.GitRepositoryLocalChartGenerator{"app", namespace}
 	if _, err := m.Install(app, "app", "/.dodo/app", namespace, map[string]any{
 		"repoAddr":      repo.FullAddress(),
 		"sshPrivateKey": sshKey,
-	}, installer.WithConfig(env), installer.WithBranch("dodo")); err != nil {
+	}, installer.WithConfig(env), installer.WithBranch("dodo"), installer.WithLocalChartGenerator(lg)); err != nil {
 		return err
 	}
 	return nil
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index 5d2206f..949cbe0 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -84,6 +84,8 @@
 	repo          soft.RepoIO
 	repoClient    soft.ClientGetter
 	nsCreator     installer.NamespaceCreator
+	jc            installer.JobCreator
+	hf            installer.HelmFetcher
 	dnsFetcher    installer.ZoneStatusFetcher
 	nameGenerator installer.NameGenerator
 	httpClient    phttp.Client
@@ -100,6 +102,8 @@
 	repo soft.RepoIO,
 	repoClient soft.ClientGetter,
 	nsCreator installer.NamespaceCreator,
+	jc installer.JobCreator,
+	hf installer.HelmFetcher,
 	dnsFetcher installer.ZoneStatusFetcher,
 	nameGenerator installer.NameGenerator,
 	httpClient phttp.Client,
@@ -112,6 +116,8 @@
 		repo,
 		repoClient,
 		nsCreator,
+		jc,
+		hf,
 		dnsFetcher,
 		nameGenerator,
 		httpClient,
@@ -333,7 +339,9 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	mgr, err := installer.NewInfraAppManager(s.repo, s.nsCreator)
+	hf := installer.NewGitHelmFetcher()
+	lg := installer.NewInfraLocalChartGenerator()
+	mgr, err := installer.NewInfraAppManager(s.repo, s.nsCreator, hf, lg)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -403,6 +411,8 @@
 	t, dns := tasks.NewCreateEnvTask(
 		env,
 		s.nsCreator,
+		s.jc,
+		s.hf,
 		s.dnsFetcher,
 		s.httpClient,
 		s.dnsClient,
diff --git a/core/installer/welcome/env_test.go b/core/installer/welcome/env_test.go
index 0803e64..35e968d 100644
--- a/core/installer/welcome/env_test.go
+++ b/core/installer/welcome/env_test.go
@@ -34,6 +34,24 @@
 	return nil
 }
 
+type fakeJobCreator struct {
+	t *testing.T
+}
+
+func (f fakeJobCreator) Create(name, namespace string, image string, cmd []string) error {
+	f.t.Logf("Create job: %s/%s %s \"%s\"", namespace, name, image, strings.Join(cmd, " "))
+	return nil
+}
+
+type fakeHelmFetcher struct {
+	t *testing.T
+}
+
+func (f fakeHelmFetcher) Pull(chart installer.HelmChartGitRepo, rfs soft.RepoFS, root string) error {
+	f.t.Logf("Helm pull: %+v", chart)
+	return nil
+}
+
 type fakeZoneStatusFetcher struct {
 	t *testing.T
 }
@@ -213,8 +231,11 @@
 	infraFS := memfs.New()
 	envFS := memfs.New()
 	nsCreator := fakeNSCreator{t}
+	jc := fakeJobCreator{t}
+	hf := fakeHelmFetcher{t}
+	lg := installer.GitRepositoryLocalChartGenerator{"foo", "bar"}
 	infraRepo := mockRepoIO{soft.NewBillyRepoFS(infraFS), "foo.bar", t, &sync.Mutex{}}
-	infraMgr, err := installer.NewInfraAppManager(infraRepo, nsCreator)
+	infraMgr, err := installer.NewInfraAppManager(infraRepo, nsCreator, hf, lg)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -254,6 +275,8 @@
 		infraRepo,
 		cg,
 		nsCreator,
+		jc,
+		hf,
 		fakeZoneStatusFetcher{t},
 		fixedNameGenerator{},
 		httpClient,
diff --git a/core/installer/welcome/welcome.go b/core/installer/welcome/welcome.go
index 64f4cf1..7688d50 100644
--- a/core/installer/welcome/welcome.go
+++ b/core/installer/welcome/welcome.go
@@ -30,6 +30,7 @@
 	port                int
 	repo                soft.RepoIO
 	nsCreator           installer.NamespaceCreator
+	hf                  installer.HelmFetcher
 	createAccountAddr   string
 	loginAddr           string
 	membershipsInitAddr string
@@ -39,6 +40,7 @@
 	port int,
 	repo soft.RepoIO,
 	nsCreator installer.NamespaceCreator,
+	hf installer.HelmFetcher,
 	createAccountAddr string,
 	loginAddr string,
 	membershipsInitAddr string,
@@ -47,6 +49,7 @@
 		port,
 		repo,
 		nsCreator,
+		hf,
 		createAccountAddr,
 		loginAddr,
 		membershipsInitAddr,
@@ -205,8 +208,9 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
+	// TODO(gio): remove this once auto user sync is implemented
 	{
-		appManager, err := installer.NewAppManager(s.repo, s.nsCreator, "/apps")
+		appManager, err := installer.NewAppManager(s.repo, s.nsCreator, nil, s.hf, "/apps")
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return