appmanager: replace svelte implementation with go based one
diff --git a/core/installer/app.go b/core/installer/app.go
index 4ed1549..f3d465b 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -2,8 +2,11 @@
 
 import (
 	"embed"
+	"encoding/json"
 	"fmt"
+	htemplate "html/template"
 	"log"
+	"strings"
 	"text/template"
 
 	"github.com/Masterminds/sprig/v3"
@@ -24,9 +27,17 @@
 	Readme     *template.Template
 }
 
+func (a App) ConfigSchema() map[string]any {
+	ret := make(map[string]any)
+	if err := json.NewDecoder(strings.NewReader(a.Schema)).Decode(&ret); err != nil {
+		panic(err) // TODO(giolekva): prevalidate
+	}
+	return ret
+}
+
 type StoreApp struct {
 	App
-	Icon             string
+	Icon             htemplate.HTML
 	ShortDescription string
 }
 
@@ -81,6 +92,7 @@
 		CreateMetallbConfigEnv(valuesTmpls, tmpls),
 		CreateEnvManager(valuesTmpls, tmpls),
 		CreateWelcome(valuesTmpls, tmpls),
+		CreateAppManager(valuesTmpls, tmpls),
 		CreateIngressPublic(valuesTmpls, tmpls),
 		CreateCertManager(valuesTmpls, tmpls),
 		CreateCertManagerWebhookGandi(valuesTmpls, tmpls),
@@ -192,7 +204,7 @@
 			string(schema),
 			tmpls.Lookup("vaultwarden.md"),
 		},
-		Icon:             "arcticons:bitwarden",
+		Icon:             `<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 48 48"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M35.38 25.63V9.37H24v28.87a34.93 34.93 0 0 0 5.41-3.48q6-4.66 6-9.14Zm4.87-19.5v19.5A11.58 11.58 0 0 1 39.4 30a16.22 16.22 0 0 1-2.11 3.81a23.52 23.52 0 0 1-3 3.24a34.87 34.87 0 0 1-3.22 2.62c-1 .69-2 1.35-3.07 2s-1.82 1-2.27 1.26l-1.08.51a1.53 1.53 0 0 1-1.32 0l-1.08-.51c-.45-.22-1.21-.64-2.27-1.26s-2.09-1.27-3.07-2A34.87 34.87 0 0 1 13.7 37a23.52 23.52 0 0 1-3-3.24A16.22 16.22 0 0 1 8.6 30a11.58 11.58 0 0 1-.85-4.32V6.13A1.64 1.64 0 0 1 9.38 4.5h29.24a1.64 1.64 0 0 1 1.63 1.63Z"/></svg>`,
 		ShortDescription: "Open source implementation of Bitwarden password manager. Can be used with official client applications.",
 	}
 }
@@ -213,7 +225,7 @@
 			string(schema),
 			nil,
 		},
-		"simple-icons:matrix",
+		`<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24"><path fill="currentColor" d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033a3.312 3.312 0 0 1 1.117-1.024c.433-.245.936-.365 1.5-.365c.54 0 1.033.107 1.481.314c.448.208.785.582 1.02 1.108c.254-.374.6-.706 1.034-.992c.434-.287.95-.43 1.546-.43c.453 0 .872.056 1.26.167c.388.11.716.286.993.53c.276.245.489.559.646.951c.152.392.23.863.23 1.417v5.728h-2.349V11.52c0-.286-.01-.559-.032-.812a1.755 1.755 0 0 0-.18-.66a1.106 1.106 0 0 0-.438-.448c-.194-.11-.457-.166-.785-.166c-.332 0-.6.064-.803.189a1.38 1.38 0 0 0-.48.499a1.946 1.946 0 0 0-.231.696a5.56 5.56 0 0 0-.06.785v4.768h-2.35v-4.8c0-.254-.004-.503-.018-.752a2.074 2.074 0 0 0-.143-.688a1.052 1.052 0 0 0-.415-.503c-.194-.125-.476-.19-.854-.19c-.111 0-.259.024-.439.074c-.18.051-.36.143-.53.282a1.637 1.637 0 0 0-.439.595c-.12.259-.18.6-.18 1.02v4.966H5.46V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"/></svg>`,
 		"An open network for secure, decentralised communication",
 	}
 }
@@ -233,7 +245,8 @@
 			string(schema),
 			tmpls.Lookup("pihole.md"),
 		},
-		"simple-icons:pihole",
+		// "simple-icons:pihole",
+		`<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24"><path fill="currentColor" d="M4.344 0c.238 4.792 3.256 7.056 6.252 7.376c.165-1.692-4.319-5.6-4.319-5.6c-.008-.011.009-.025.019-.014c0 0 4.648 4.01 5.423 5.645c2.762-.15 5.196-1.947 5-4.912c0 0-4.12-.613-5 4.618C11.48 2.753 8.993 0 4.344 0zM12 7.682v.002a3.68 3.68 0 0 0-2.591 1.077L4.94 13.227a3.683 3.683 0 0 0-.86 1.356a3.31 3.31 0 0 0-.237 1.255A3.681 3.681 0 0 0 4.92 18.45l4.464 4.466a3.69 3.69 0 0 0 2.251 1.06l.002.001c.093.01.187.015.28.017l-.1-.008c.06.003.117.009.177.009l-.077-.001L12 24l-.004-.005a3.68 3.68 0 0 0 2.61-1.077l4.469-4.465a3.683 3.683 0 0 0 1.006-1.888l.012-.063a3.682 3.682 0 0 0 .057-.541l.003-.061c0-.017.003-.05.004-.06h-.002a3.683 3.683 0 0 0-1.077-2.607l-4.466-4.468a3.694 3.694 0 0 0-1.564-.927l-.07-.02a3.43 3.43 0 0 0-.946-.133L12 7.682zm3.165 3.357c.023 1.748-1.33 3.078-1.33 4.806c.164 2.227 1.733 3.207 3.266 3.146c-.035.003-.068.007-.104.009c-1.847.135-3.209-1.326-5.002-1.326c-2.23.164-3.21 1.736-3.147 3.27l-.008-.104c-.133-1.847 1.328-3.21 1.328-5.002c-.173-2.32-1.867-3.284-3.46-3.132c.1-.011.203-.021.31-.027c1.847-.133 3.209 1.328 5.002 1.328c2.082-.155 3.074-1.536 3.145-2.968zM4.344 0c.238 4.792 3.256 7.056 6.252 7.376c.165-1.692-4.319-5.6-4.319-5.6c-.008-.011.009-.025.019-.014c0 0 4.648 4.01 5.423 5.645c2.762-.15 5.196-1.947 5-4.912c0 0-4.12-.613-5 4.618C11.48 2.753 8.993 0 4.344 0zM12 7.682v.002a3.68 3.68 0 0 0-2.591 1.077L4.94 13.227a3.683 3.683 0 0 0-.86 1.356a3.31 3.31 0 0 0-.237 1.255A3.681 3.681 0 0 0 4.92 18.45l4.464 4.466a3.69 3.69 0 0 0 2.251 1.06l.002.001c.093.01.187.015.28.017l-.1-.008c.06.003.117.009.177.009l-.077-.001L12 24l-.004-.005a3.68 3.68 0 0 0 2.61-1.077l4.469-4.465a3.683 3.683 0 0 0 1.006-1.888l.012-.063a3.682 3.682 0 0 0 .057-.541l.003-.061c0-.017.003-.05.004-.06h-.002a3.683 3.683 0 0 0-1.077-2.607l-4.466-4.468a3.694 3.694 0 0 0-1.564-.927l-.07-.02a3.43 3.43 0 0 0-.946-.133L12 7.682zm3.165 3.357c.023 1.748-1.33 3.078-1.33 4.806c.164 2.227 1.733 3.207 3.266 3.146c-.035.003-.068.007-.104.009c-1.847.135-3.209-1.326-5.002-1.326c-2.23.164-3.21 1.736-3.147 3.27l-.008-.104c-.133-1.847 1.328-3.21 1.328-5.002c-.173-2.32-1.867-3.284-3.46-3.132c.1-.011.203-.021.31-.027c1.847-.133 3.209 1.328 5.002 1.328c2.082-.155 3.074-1.536 3.145-2.968z"/></svg>`,
 		"Pi-hole is a Linux network-level advertisement and Internet tracker blocking application which acts as a DNS sinkhole and optionally a DHCP server, intended for use on a private network.",
 	}
 }
@@ -253,7 +266,7 @@
 			string(schema),
 			nil,
 		},
-		"arcticons:huawei-email",
+		`<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 48 48"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M9.5 13c13.687 13.574 14.825 13.09 29 0"/><rect width="37" height="31" x="5.5" y="8.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" rx="2"/></svg>`,
 		"SMPT/IMAP server to communicate via email.",
 	}
 }
@@ -273,7 +286,7 @@
 			string(schema),
 			tmpls.Lookup("qbittorrent.md"),
 		},
-		"arcticons:qbittorrent-remote",
+		`<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 48 48"><circle cx="24" cy="24" r="21.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M26.651 22.364a5.034 5.034 0 0 1 5.035-5.035h0a5.034 5.034 0 0 1 5.034 5.035v3.272a5.034 5.034 0 0 1-5.034 5.035h0a5.034 5.034 0 0 1-5.035-5.035m0 5.035V10.533m-5.302 15.103a5.034 5.034 0 0 1-5.035 5.035h0a5.034 5.034 0 0 1-5.034-5.035v-3.272a5.034 5.034 0 0 1 5.034-5.035h0a5.034 5.034 0 0 1 5.035 5.035m0-5.035v20.138"/></svg>`,
 		"qBittorrent is a cross-platform free and open-source BitTorrent client written in native C++. It relies on Boost, Qt 6 toolkit and the libtorrent-rasterbar library, with an optional search engine written in Python.",
 	}
 }
@@ -293,7 +306,7 @@
 			string(schema),
 			nil,
 		},
-		"arcticons:jellyfin",
+		`<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 48 48"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M24 20c-1.62 0-6.85 9.48-6.06 11.08s11.33 1.59 12.12 0S25.63 20 24 20Z"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M24 5.5c-4.89 0-20.66 28.58-18.25 33.4s34.13 4.77 36.51 0S28.9 5.5 24 5.5Zm12 29.21c-1.56 3.13-22.35 3.17-23.93 0S20.8 12.83 24 12.83s13.52 18.76 12 21.88Z"/></svg>`,
 		"Jellyfin is a free and open-source media server and suite of multimedia applications designed to organize, manage, and share digital media files to networked devices.",
 	}
 }
@@ -313,7 +326,7 @@
 			string(schema),
 			tmpls.Lookup("rpuppy.md"),
 		},
-		"ph:dog-thin",
+		`<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 256 256"><path fill="currentColor" d="M100 140a8 8 0 1 1-8-8a8 8 0 0 1 8 8Zm64 8a8 8 0 1 0-8-8a8 8 0 0 0 8 8Zm64.94-9.11a12.12 12.12 0 0 1-5 1.11a11.83 11.83 0 0 1-9.35-4.62l-2.59-3.29V184a36 36 0 0 1-36 36H80a36 36 0 0 1-36-36v-51.91l-2.53 3.27A11.88 11.88 0 0 1 32.1 140a12.08 12.08 0 0 1-5-1.11a11.82 11.82 0 0 1-6.84-13.14l16.42-88a12 12 0 0 1 14.7-9.43h.16L104.58 44h46.84l53.08-15.6h.16a12 12 0 0 1 14.7 9.43l16.42 88a11.81 11.81 0 0 1-6.84 13.06ZM97.25 50.18L49.34 36.1a4.18 4.18 0 0 0-.92-.1a4 4 0 0 0-3.92 3.26l-16.42 88a4 4 0 0 0 7.08 3.22ZM204 121.75L150 52h-44l-54 69.75V184a28 28 0 0 0 28 28h44v-18.34l-14.83-14.83a4 4 0 0 1 5.66-5.66L128 186.34l13.17-13.17a4 4 0 0 1 5.66 5.66L132 193.66V212h44a28 28 0 0 0 28-28Zm23.92 5.48l-16.42-88a4 4 0 0 0-4.84-3.16l-47.91 14.11l62.11 80.28a4 4 0 0 0 7.06-3.23Z"/></svg>`,
 		"Delights users with randomly generate puppy pictures. Can be configured to be reachable only from private network or publicly.",
 	}
 }
@@ -398,6 +411,22 @@
 	}
 }
 
+func CreateAppManager(fs embed.FS, tmpls *template.Template) App {
+	schema, err := fs.ReadFile("values-tmpl/appmanager.jsonschema")
+	if err != nil {
+		panic(err)
+	}
+	return App{
+		"app-manager",
+		[]string{"core-appmanager"},
+		[]*template.Template{
+			tmpls.Lookup("appmanager.yaml"),
+		},
+		string(schema),
+		tmpls.Lookup("appmanager.md"),
+	}
+}
+
 func CreateIngressPublic(fs embed.FS, tmpls *template.Template) App {
 	schema, err := fs.ReadFile("values-tmpl/ingress-public.jsonschema")
 	if err != nil {
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index c0a619f..84b87a1 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -1,6 +1,7 @@
 package installer
 
 import (
+	"fmt"
 	"io/ioutil"
 	"path/filepath"
 
@@ -31,8 +32,8 @@
 	return m.repoIO.FindAllInstances(appDir, name)
 }
 
-func (m *AppManager) FindInstance(name string) (AppConfig, error) {
-	return m.repoIO.FindInstance(appDir, name)
+func (m *AppManager) FindInstance(id string) (AppConfig, error) {
+	return m.repoIO.FindInstance(appDir, id)
 }
 
 func (m *AppManager) AppConfig(name string) (AppConfig, error) {
@@ -75,19 +76,27 @@
 	if err != nil {
 		return err
 	}
-	all := map[string]any{
-		"Global": globalConfig.Values,
-		"Values": config,
+	derivedValues, err := deriveValues(config, app.ConfigSchema(), CreateNetworks(globalConfig))
+	if err != nil {
+		fmt.Println(err)
+		return err
+	}
+	derived := Derived{
+		Global: globalConfig.Values,
+		Values: derivedValues,
 	}
 	if len(namespaces) > 0 {
-		all["Release"] = map[string]any{
-			"Namespace": namespaces[0],
-		}
+		derived.Release.Namespace = namespaces[0]
 	}
-	return m.repoIO.InstallApp(
+	fmt.Printf("%+v\n", derived)
+	err = m.repoIO.InstallApp(
 		app,
 		filepath.Join(appDir, app.Name+suffix),
-		all)
+		config,
+		derived,
+	)
+	fmt.Println(err)
+	return err
 }
 
 func (m *AppManager) Update(app App, instanceId string, config map[string]any) error {
@@ -104,10 +113,37 @@
 	if err != nil {
 		return err
 	}
-	all := map[string]any{
-		"Global":  globalConfig.Values,
-		"Values":  config,
-		"Release": appConfig.Config["Release"],
+	derivedValues, err := deriveValues(config, app.ConfigSchema(), CreateNetworks(globalConfig))
+	if err != nil {
+		return err
 	}
-	return m.repoIO.InstallApp(app, instanceDir, all)
+	derived := Derived{
+		Global:  globalConfig.Values,
+		Release: appConfig.Derived.Release,
+		Values:  derivedValues,
+	}
+	return m.repoIO.InstallApp(app, instanceDir, config, derived)
+}
+
+func (m *AppManager) Remove(instanceId string) error {
+	// if err := m.repoIO.Fetch(); err != nil {
+	// 	return err
+	// }
+	return m.repoIO.RemoveApp(filepath.Join(appDir, instanceId))
+}
+
+func CreateNetworks(global Config) []Network {
+	return []Network{
+		{
+			Name:              "Public",
+			IngressClass:      fmt.Sprintf("%s-ingress-public", global.Values.PCloudEnvName),
+			CertificateIssuer: fmt.Sprintf("%s-public", global.Values.Id),
+			Domain:            global.Values.Domain,
+		},
+		{
+			Name:         "Private",
+			IngressClass: fmt.Sprintf("%s-ingress-private", global.Values.Id),
+			Domain:       global.Values.PrivateDomain,
+		},
+	}
 }
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index 90f2c4b..6027204 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -1,32 +1,25 @@
 package main
 
 import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"log"
 	"net"
-	"net/http"
-	"net/http/httputil"
-	"net/url"
 	"os"
 
 	"github.com/go-git/go-billy/v5/memfs"
 	"github.com/go-git/go-git/v5"
 	gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
 	"github.com/go-git/go-git/v5/storage/memory"
-	"github.com/labstack/echo/v4"
 	"github.com/spf13/cobra"
 	"golang.org/x/crypto/ssh"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/welcome"
 )
 
 var appManagerFlags struct {
-	sshKey   string
-	repoAddr string
-	port     int
+	sshKey     string
+	repoAddr   string
+	port       int
+	webAppAddr string
 }
 
 func appManagerCmd() *cobra.Command {
@@ -52,6 +45,12 @@
 		8080,
 		"",
 	)
+	cmd.Flags().StringVar(
+		&appManagerFlags.webAppAddr,
+		"web-app-addr",
+		"",
+		"",
+	)
 	return cmd
 }
 
@@ -80,261 +79,16 @@
 		return err
 	}
 	r := installer.NewInMemoryAppRepository[installer.StoreApp](installer.CreateStoreApps())
-	s := &server{
-		port: appManagerFlags.port,
-		m:    m,
-		r:    r,
-	}
-	s.start()
+	s := welcome.NewAppManagerServer(
+		appManagerFlags.port,
+		appManagerFlags.webAppAddr,
+		m,
+		r,
+	)
+	s.Start()
 	return nil
 }
 
-type server struct {
-	port int
-	m    *installer.AppManager
-	r    installer.AppRepository[installer.StoreApp]
-}
-
-func (s *server) start() {
-	e := echo.New()
-	e.GET("/api/app-repo", s.handleAppRepo)
-	e.POST("/api/app/:slug/render", s.handleAppRender)
-	e.POST("/api/app/:slug/install", s.handleAppInstall)
-	e.GET("/api/app/:slug", s.handleApp)
-	e.GET("/api/instance/:slug", s.handleInstance)
-	e.POST("/api/instance/:slug/update", s.handleAppUpdate)
-	webapp, err := url.Parse("http://localhost:5173")
-	if err != nil {
-		panic(err)
-	}
-	// var f ff
-	e.Any("/*", echo.WrapHandler(httputil.NewSingleHostReverseProxy(webapp)))
-	// e.Any("/*", echo.WrapHandler(&f))
-	fmt.Printf("Starting HTTP server on port: %d\n", s.port)
-	log.Fatal(e.Start(fmt.Sprintf(":%d", s.port)))
-}
-
-type app struct {
-	Name             string                `json:"name"`
-	Icon             string                `json:"icon"`
-	ShortDescription string                `json:"shortDescription"`
-	Slug             string                `json:"slug"`
-	Schema           string                `json:"schema"`
-	Instances        []installer.AppConfig `json:"instances,omitempty"`
-}
-
-func (s *server) handleAppRepo(c echo.Context) error {
-	all, err := s.r.GetAll()
-	if err != nil {
-		return err
-	}
-	resp := make([]app, len(all))
-	for i, a := range all {
-		resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, nil}
-	}
-	return c.JSON(http.StatusOK, resp)
-}
-
-func (s *server) handleApp(c echo.Context) error {
-	slug := c.Param("slug")
-	a, err := s.r.Find(slug)
-	if err != nil {
-		return err
-	}
-	instances, err := s.m.FindAllInstances(slug)
-	if err != nil {
-		return err
-	}
-	return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, instances})
-}
-
-func (s *server) handleInstance(c echo.Context) error {
-	slug := c.Param("slug")
-	instance, err := s.m.FindInstance(slug)
-	if err != nil {
-		return err
-	}
-	values, ok := instance.Config["Values"].(map[string]any)
-	if !ok {
-		return fmt.Errorf("Expected map")
-	}
-	for k, v := range values {
-		if k == "Network" {
-			n, ok := v.(map[string]any)
-			if !ok {
-				return fmt.Errorf("Expected map")
-			}
-			values["Network"], ok = n["Name"]
-			if !ok {
-				return fmt.Errorf("Missing Name")
-			}
-			break
-		}
-
-	}
-	a, err := s.r.Find(instance.Id)
-	if err != nil {
-		return err
-	}
-	return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, []installer.AppConfig{instance}})
-}
-
-type file struct {
-	Name     string `json:"name"`
-	Contents string `json:"contents"`
-}
-
-type rendered struct {
-	Readme string `json:"readme"`
-	Files  []file `json:"files"`
-}
-
-type network struct {
-	Name              string
-	IngressClass      string
-	CertificateIssuer string
-	Domain            string
-}
-
-func createNetworks(global installer.Config) []network {
-	return []network{
-		{
-			Name:              "Public",
-			IngressClass:      fmt.Sprintf("%s-ingress-public", global.Values.PCloudEnvName),
-			CertificateIssuer: fmt.Sprintf("%s-public", global.Values.Id),
-			Domain:            global.Values.Domain,
-		},
-		{
-			Name:         "Private",
-			IngressClass: fmt.Sprintf("%s-ingress-private", global.Values.Id),
-			Domain:       global.Values.PrivateDomain,
-		},
-	}
-}
-
-func (s *server) handleAppRender(c echo.Context) error {
-	slug := c.Param("slug")
-	contents, err := ioutil.ReadAll(c.Request().Body)
-	if err != nil {
-		return err
-	}
-	global, err := s.m.Config()
-	if err != nil {
-		return err
-	}
-	var values map[string]any
-	if err := json.Unmarshal(contents, &values); err != nil {
-		return err
-	}
-	if network, ok := values["Network"]; ok {
-		for _, n := range createNetworks(global) {
-			if n.Name == network { // TODO(giolekva): handle not found
-				values["Network"] = n
-			}
-		}
-	}
-	all := map[string]any{
-		"Global": global.Values,
-		"Values": values,
-	}
-	a, err := s.r.Find(slug)
-	if err != nil {
-		return err
-	}
-	var readme bytes.Buffer
-	if err := a.Readme.Execute(&readme, all); err != nil {
-		return err
-	}
-	var resp rendered
-	resp.Readme = readme.String()
-	for _, tmpl := range a.Templates { // TODO(giolekva): deduplicate with Install
-		var f bytes.Buffer
-		if err := tmpl.Execute(&f, all); err != nil {
-			fmt.Printf("%+v\n", all)
-			fmt.Println(err.Error())
-			return err
-		} else {
-			resp.Files = append(resp.Files, file{tmpl.Name(), f.String()})
-		}
-	}
-	out, err := json.Marshal(resp)
-	if err != nil {
-		return err
-	}
-	if _, err := c.Response().Writer.Write(out); err != nil {
-		return err
-	}
-	return nil
-}
-
-func (s *server) handleAppInstall(c echo.Context) error {
-	slug := c.Param("slug")
-	contents, err := ioutil.ReadAll(c.Request().Body)
-	if err != nil {
-		return err
-	}
-	var values map[string]any
-	if err := json.Unmarshal(contents, &values); err != nil {
-		return err
-	}
-	a, err := s.r.Find(slug)
-	if err != nil {
-		return err
-	}
-	config, err := s.m.Config()
-	if err != nil {
-		return err
-	}
-	if network, ok := values["Network"]; ok {
-		for _, n := range createNetworks(config) {
-			if n.Name == network { // TODO(giolekva): handle not found
-				values["Network"] = n
-			}
-		}
-	}
-	nsGen := installer.NewPrefixGenerator(config.Values.NamespacePrefix)
-	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
-	if err := s.m.Install(a.App, nsGen, suffixGen, values); err != nil {
-		return err
-	}
-	return c.String(http.StatusOK, "Installed")
-}
-
-func (s *server) handleAppUpdate(c echo.Context) error {
-	slug := c.Param("slug")
-	appConfig, err := s.m.AppConfig(slug)
-	if err != nil {
-		return err
-	}
-	contents, err := ioutil.ReadAll(c.Request().Body)
-	if err != nil {
-		return err
-	}
-	var values map[string]any
-	if err := json.Unmarshal(contents, &values); err != nil {
-		return err
-	}
-	a, err := s.r.Find(appConfig.Id)
-	if err != nil {
-		return err
-	}
-	config, err := s.m.Config()
-	if err != nil {
-		return err
-	}
-	if network, ok := values["Network"]; ok {
-		for _, n := range createNetworks(config) {
-			if n.Name == network { // TODO(giolekva): handle not found
-				values["Network"] = n
-			}
-		}
-	}
-	if err := s.m.Update(a.App, slug, values); err != nil {
-		return err
-	}
-	return c.String(http.StatusOK, "Installed")
-}
-
 func cloneRepo(address string, signer ssh.Signer) (*git.Repository, error) {
 	return git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
 		URL:             address,
diff --git a/core/installer/cmd/bootstrap.go b/core/installer/cmd/bootstrap.go
index d007ed2..805a5a0 100644
--- a/core/installer/cmd/bootstrap.go
+++ b/core/installer/cmd/bootstrap.go
@@ -110,8 +110,8 @@
 		return err
 	}
 	// TODO(giolekva): commit this to the repo above
-	global := map[string]any{
-		"PCloudEnvName": bootstrapFlags.pcloudEnvName,
+	global := installer.Values{
+		PCloudEnvName: bootstrapFlags.pcloudEnvName,
 	}
 	nsCreator, err := newNSCreator()
 	if err != nil {
@@ -376,7 +376,7 @@
 	return nil
 }
 
-func installInfrastructureServices(repo installer.RepoIO, nsGen installer.NamespaceGenerator, nsCreator installer.NamespaceCreator, global map[string]any) error {
+func installInfrastructureServices(repo installer.RepoIO, nsGen installer.NamespaceGenerator, nsCreator installer.NamespaceCreator, global installer.Values) error {
 	appRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 	install := func(name string) error {
 		app, err := appRepo.Find(name)
@@ -395,15 +395,13 @@
 				return err
 			}
 		}
-		values := map[string]any{
-			"Global": global,
+		derived := installer.Derived{
+			Global: global,
 		}
 		if len(namespaces) > 0 {
-			values["Release"] = map[string]any{
-				"Namespace": namespaces[0],
-			}
+			derived.Release.Namespace = namespaces[0]
 		}
-		return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), values)
+		return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), map[string]any{}, derived)
 	}
 	appsToInstall := []string{
 		"resource-renderer-controller",
@@ -466,7 +464,7 @@
 	return nil
 }
 
-func installEnvManager(ss *soft.Client, repo installer.RepoIO, nsGen installer.NamespaceGenerator, nsCreator installer.NamespaceCreator, global map[string]any) error {
+func installEnvManager(ss *soft.Client, repo installer.RepoIO, nsGen installer.NamespaceGenerator, nsCreator installer.NamespaceCreator, global installer.Values) error {
 	keys, err := installer.NewSSHKeyPair()
 	if err != nil {
 		return err
@@ -495,16 +493,17 @@
 			return err
 		}
 	}
-	return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), map[string]any{
-		"Global": global,
-		"Values": map[string]any{
+	derived := installer.Derived{
+		Global: global,
+		Values: map[string]any{
 			"RepoIP":        bootstrapFlags.softServeIP,
 			"SSHPrivateKey": keys.Private,
 		},
-		"Release": map[string]any{
-			"Namespace": namespaces[0],
-		},
-	})
+	}
+	if len(namespaces) > 0 {
+		derived.Release.Namespace = namespaces[0]
+	}
+	return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), derived.Values, derived)
 }
 
 func createActionConfig(namespace string) (*action.Configuration, error) {
diff --git a/core/installer/kustomization.go b/core/installer/kustomization.go
index 93f806a..1db9614 100644
--- a/core/installer/kustomization.go
+++ b/core/installer/kustomization.go
@@ -53,3 +53,14 @@
 		}
 	}
 }
+
+func (k *Kustomization) RemoveResources(names ...string) {
+	for _, name := range names {
+		for i, r := range k.Resources {
+			if r == name {
+				k.Resources = slices.Delete(k.Resources, i, i+1)
+				break
+			}
+		}
+	}
+}
diff --git a/core/installer/repoio.go b/core/installer/repoio.go
index b01cd82..0582594 100644
--- a/core/installer/repoio.go
+++ b/core/installer/repoio.go
@@ -31,9 +31,10 @@
 	Writer(path string) (io.WriteCloser, error)
 	CreateDir(path string) error
 	RemoveDir(path string) error
-	InstallApp(app App, path string, values map[string]any) error
-	FindAllInstances(root string, name string) ([]AppConfig, error)
-	FindInstance(root string, name string) (AppConfig, error)
+	InstallApp(app App, path string, values map[string]any, derived Derived) error
+	RemoveApp(path string) error
+	FindAllInstances(root string, appId string) ([]AppConfig, error)
+	FindInstance(root string, id string) (AppConfig, error)
 }
 
 type repoIO struct {
@@ -181,12 +182,24 @@
 	return err
 }
 
-type AppConfig struct {
-	Id     string         `json:"id"`
-	Config map[string]any `json:"config"`
+type Release struct {
+	Namespace string `json:"Namespace"`
 }
 
-func (r *repoIO) InstallApp(app App, appRootDir string, values map[string]any) error {
+type Derived struct {
+	Release Release        `json:"Release"`
+	Global  Values         `json:"Global"`
+	Values  map[string]any `json:"Values"`
+}
+
+type AppConfig struct {
+	Id      string         `json:"id"`
+	AppId   string         `json:"appId"`
+	Config  map[string]any `json:"config"`
+	Derived Derived        `json:"derived"`
+}
+
+func (r *repoIO) InstallApp(app App, appRootDir string, values map[string]any, derived Derived) error {
 	if !filepath.IsAbs(appRootDir) {
 		return fmt.Errorf("Expected absolute path: %s", appRootDir)
 	}
@@ -217,8 +230,9 @@
 			return err
 		}
 		cfg := AppConfig{
-			Id:     app.Name,
-			Config: values,
+			AppId:   app.Name,
+			Config:  values,
+			Derived: derived,
 		}
 		if err := r.WriteYaml(path.Join(appRootDir, configFileName), cfg); err != nil {
 			return err
@@ -233,7 +247,7 @@
 				return err
 			}
 			defer out.Close()
-			if err := t.Execute(out, values); err != nil {
+			if err := t.Execute(out, derived); err != nil {
 				return err
 			}
 		}
@@ -244,6 +258,19 @@
 	return r.CommitAndPush(fmt.Sprintf("install: %s", app.Name))
 }
 
+func (r *repoIO) RemoveApp(appRootDir string) error {
+	r.RemoveDir(appRootDir)
+	parent, child := filepath.Split(appRootDir)
+	kustPath := filepath.Join(parent, "kustomization.yaml")
+	kust, err := r.ReadKustomization(kustPath)
+	if err != nil {
+		return err
+	}
+	kust.RemoveResources(child)
+	r.WriteKustomization(kustPath, *kust)
+	return r.CommitAndPush(fmt.Sprintf("uninstall: %s", child))
+}
+
 func (r *repoIO) FindAllInstances(root string, name string) ([]AppConfig, error) {
 	if !filepath.IsAbs(root) {
 		return nil, fmt.Errorf("Expected absolute path: %s", root)
@@ -258,14 +285,15 @@
 		if err != nil {
 			return nil, err
 		}
-		if cfg.Id == name {
+		cfg.Id = app
+		if cfg.AppId == name {
 			ret = append(ret, cfg)
 		}
 	}
 	return ret, nil
 }
 
-func (r *repoIO) FindInstance(root string, name string) (AppConfig, error) {
+func (r *repoIO) FindInstance(root string, id string) (AppConfig, error) {
 	if !filepath.IsAbs(root) {
 		return AppConfig{}, fmt.Errorf("Expected absolute path: %s", root)
 	}
@@ -274,8 +302,13 @@
 		return AppConfig{}, err
 	}
 	for _, app := range kust.Resources {
-		if app == name {
-			return r.ReadAppConfig(filepath.Join(root, app, "config.yaml"))
+		if app == id {
+			cfg, err := r.ReadAppConfig(filepath.Join(root, app, "config.yaml"))
+			if err != nil {
+				return AppConfig{}, err
+			}
+			cfg.Id = id
+			return cfg, nil
 		}
 	}
 	return AppConfig{}, nil
@@ -301,3 +334,71 @@
 		return yaml.UnmarshalStrict(contents, o)
 	}
 }
+
+func deriveValues(values any, schema map[string]any, networks []Network) (map[string]any, error) {
+	ret := make(map[string]any)
+	for k, v := range values.(map[string]any) { // TODO(giolekva): validate
+		def, err := fieldSchema(schema, k)
+		if err != nil {
+			return nil, err
+		}
+		t, ok := def["type"]
+		if !ok {
+			return nil, fmt.Errorf("Found field with undefined type: %s", k)
+		}
+		if t == "string" {
+			role, ok := def["role"]
+			if ok && role == "network" {
+				n, err := findNetwork(networks, v.(string)) // TODO(giolekva): validate
+				if err != nil {
+					return nil, err
+				}
+				ret[k] = n
+			} else {
+				ret[k] = v
+			}
+		} else {
+			ret[k], err = deriveValues(v, def, networks)
+			if err != nil {
+				return nil, err
+			}
+		}
+	}
+	return ret, nil
+}
+
+func findNetwork(networks []Network, name string) (Network, error) {
+	for _, n := range networks {
+		if n.Name == name {
+			return n, nil
+		}
+	}
+	return Network{}, fmt.Errorf("Network not found: %s", name)
+}
+
+func fieldSchema(schema map[string]any, key string) (map[string]any, error) {
+	properties, ok := schema["properties"]
+	if !ok {
+		return nil, fmt.Errorf("Properties not found")
+	}
+	propMap, ok := properties.(map[string]any)
+	if !ok {
+		return nil, fmt.Errorf("Expected properties to be map")
+	}
+	def, ok := propMap[key]
+	if !ok {
+		return nil, fmt.Errorf("Unknown field: %s", key)
+	}
+	ret, ok := def.(map[string]any)
+	if !ok {
+		return nil, fmt.Errorf("Invalid schema")
+	}
+	return ret, nil
+}
+
+type Network struct {
+	Name              string
+	IngressClass      string
+	CertificateIssuer string
+	Domain            string
+}
diff --git a/core/installer/values-tmpl/appmanager.yaml b/core/installer/values-tmpl/appmanager.yaml
index 2ca79b9..c630a76 100644
--- a/core/installer/values-tmpl/appmanager.yaml
+++ b/core/installer/values-tmpl/appmanager.yaml
@@ -16,7 +16,7 @@
     repoAddr: {{ .Values.RepoAddr }}
     sshPrivateKey: {{ .Values.SSHPrivateKey | b64enc }}
     ingress:
-      className: {{ .Global.Id }}-ingress-public
+      className: {{ .Global.Id }}-ingress-private
       domain: apps.{{ .Global.PrivateDomain }}
       certificateIssuer: ""
     clusterRoleName: {{ .Global.Id }}-appmanager
diff --git a/core/installer/values-tmpl/certificate-issuer-private.jsonschema b/core/installer/values-tmpl/certificate-issuer-private.jsonschema
index 46ae9c3..cb7e4dc 100644
--- a/core/installer/values-tmpl/certificate-issuer-private.jsonschema
+++ b/core/installer/values-tmpl/certificate-issuer-private.jsonschema
@@ -1,7 +1,7 @@
 {
   "type": "object",
   "properties": {
-    "GandiAPIToken": { "type": "string" },
+    "GandiAPIToken": { "type": "string" }
   },
   "additionalProperties": false
 }
diff --git a/core/installer/values-tmpl/core-auth.yaml b/core/installer/values-tmpl/core-auth.yaml
index 7a1b7a1..986c13d 100644
--- a/core/installer/values-tmpl/core-auth.yaml
+++ b/core/installer/values-tmpl/core-auth.yaml
@@ -75,7 +75,7 @@
           enabled: false
         config:
           version: v0.7.1-alpha.1
-          dsn: postgres://kratos:kratos@postgres.{{ .Globa.Id }}-core-auth.svc:5432/kratos?sslmode=disable&max_conns=20&max_idle_conns=4
+          dsn: postgres://kratos:kratos@postgres.{{ .Global.Id }}-core-auth.svc:5432/kratos?sslmode=disable&max_conns=20&max_idle_conns=4
           serve:
             public:
               base_url: https://accounts.{{ .Global.Domain }}
@@ -246,7 +246,7 @@
           enabled: true
         config:
           version: v1.10.6
-          dsn: postgres://hydra:hydra@postgres.{{ .Globa.Id }}-core-auth.svc:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4
+          dsn: postgres://hydra:hydra@postgres.{{ .Global.Id }}-core-auth.svc:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4
           serve:
             cookies:
               same_site_mode: None
@@ -298,4 +298,4 @@
       ingressClassName: {{ .Global.PCloudEnvName }}-ingress-public
       domain: {{ .Global.Domain }}
       internalDomain: p.{{ .Global.Domain }}
-      hydra: hydra-admin.{{ .Globa.Id }}-core-auth.svc.cluster.local
+      hydra: hydra-admin.{{ .Global.Id }}-core-auth.svc.cluster.local
diff --git a/core/installer/values-tmpl/metallb-config-env.jsonschema b/core/installer/values-tmpl/metallb-config-env.jsonschema
index f42d895..6eca710 100644
--- a/core/installer/values-tmpl/metallb-config-env.jsonschema
+++ b/core/installer/values-tmpl/metallb-config-env.jsonschema
@@ -1,6 +1,17 @@
 {
   "type": "object",
   "properties": {
+    "IngressPrivate": { "type": "string" },
+    "Headscale": { "type": "string" },
+    "SoftServe": { "type": "string" },
+    "Rest": {
+      "type": "object",
+      "properties": {
+        "From": { "type": "string" },
+        "To": { "type": "string" }
+      },
+      "additionalProperties": false
+    }
   },
   "additionalProperties": false
 }
diff --git a/core/installer/values-tmpl/rpuppy.jsonschema b/core/installer/values-tmpl/rpuppy.jsonschema
index e21b570..d6e0d0d 100644
--- a/core/installer/values-tmpl/rpuppy.jsonschema
+++ b/core/installer/values-tmpl/rpuppy.jsonschema
@@ -1,16 +1,7 @@
 {
-  "definitions": {
-    "network": {
-      "type": "object",
-      "properties": {
-        "name": { "type": "string" },
-        "domain": { "type": "string" }
-      }
-    }
-  },
   "type": "object",
   "properties": {
-    "Network": { "$ref": "#/definitions/network", "default": "Public" },
+    "Network": { "type": "string", "default": "Public", "role": "network" },
     "Subdomain": { "type": "string", "default": "woof" }
   },
   "additionalProperties": false
diff --git a/core/installer/values-tmpl/rpuppy.md b/core/installer/values-tmpl/rpuppy.md
index 2633167..ddd992f 100644
--- a/core/installer/values-tmpl/rpuppy.md
+++ b/core/installer/values-tmpl/rpuppy.md
@@ -1 +1 @@
-rpuppy application will be installed on public network and be accessible to any user on https://{{ .Values.Subdomain }}.{{ .Values.Network.Domain }}
+rpuppy application will be installed on {{ .Values.Network.Name }} network and be accessible to any user on https://{{ .Values.Subdomain }}.{{ .Values.Network.Domain }}
diff --git a/core/installer/welcome/appmanager-tmpl/app.html b/core/installer/welcome/appmanager-tmpl/app.html
new file mode 100644
index 0000000..df7501b
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/app.html
@@ -0,0 +1,214 @@
+{{ define "schema-form" }}
+  {{ $readonly := .ReadOnly }}
+  {{ $networks := .AvailableNetworks }}
+  {{ $data := .Data }}
+  {{ range $name, $schema := .Schema.properties }}
+    {{ if eq $schema.type "string" }}
+      <label for="{{ $name }}">
+        <span>{{ $name }}</span>
+      </label>
+      {{ if eq (index $schema "role") "network" }}
+        <select oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} >
+          {{ if not $readonly }}<option disabled selected value> -- select an option -- </option>{{ end }}
+          {{ range $networks }}
+            <option {{if eq .Name (index $data $name) }}selected{{ end }}>{{ .Name }}</option>
+          {{ end }}
+        </select>
+      {{ else }}
+        <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}"/>
+      {{ end }}
+    {{ end }}
+  {{ end }}
+{{ end }}
+
+{{ define "main" }}
+{{ $instance := .Instance }}
+<h1>{{ .App.Icon }}{{ .App.Name }}</h1>
+<pre id="readme"></pre>
+
+{{ $schema := .App.ConfigSchema }}
+{{ $networks := .AvailableNetworks }}
+
+<form id="config-form">
+    {{ if $instance }}
+      {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" $instance.Config) }}
+    {{ else }}
+      {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" (dict)) }}
+    {{ end }}
+    {{ if $instance }}
+      <div class="grid">
+        <button type="submit" id="submit" name="update">Update</button>
+        <button type="submit" id="uninstall" name="remove">Uninstall</button>
+      </div>
+    {{ else }}
+      <button type="submit" id="submit">{{ if $instance }}Update{{ else }}Install{{ end }}</button>
+    {{ end }}
+</form>
+
+{{ range .Instances }}
+  {{ if or (not $instance) (ne $instance.Id .Id)}}
+    <details>
+      <summary>{{ .Id }}</summary>
+      {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" true "Data" .Config ) }}
+      <a href="/instance/{{ .Id }}" role="button" class="secondary">View</a>
+    </details>
+  {{ end }}
+{{ end }}
+
+
+<div id="toast-success" class="toast hidden">
+  <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><path fill="currentColor" d="M18 2a16 16 0 1 0 16 16A16 16 0 0 0 18 2Zm0 30a14 14 0 1 1 14-14a14 14 0 0 1-14 14Z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M28 12.1a1 1 0 0 0-1.41 0l-11.1 11.05l-6-6A1 1 0 0 0 8 18.53L15.49 26L28 13.52a1 1 0 0 0 0-1.42Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="none" d="M0 0h36v36H0z"/></svg> {{ if $instance }}Update succeeded{{ else }}Install succeeded{{ end}}
+</div>
+
+<div id="toast-failure" class="toast hidden">
+  <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10Zm3-6L9 8m0 8l6-8"/></svg> {{ if $instance }}Update failed{{ else}}Install failed{{ end }}
+</div>
+
+<div id="toast-uninstall-success" class="toast hidden">
+  <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><path fill="currentColor" d="M18 2a16 16 0 1 0 16 16A16 16 0 0 0 18 2Zm0 30a14 14 0 1 1 14-14a14 14 0 0 1-14 14Z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M28 12.1a1 1 0 0 0-1.41 0l-11.1 11.05l-6-6A1 1 0 0 0 8 18.53L15.49 26L28 13.52a1 1 0 0 0 0-1.42Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="none" d="M0 0h36v36H0z"/></svg> Uninstalled application
+</div>
+
+<div id="toast-uninstall-failure" class="toast hidden">
+  <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10Zm3-6L9 8m0 8l6-8"/></svg> Failed to uninstall application
+</div>
+
+<style>
+  pre {
+    white-space: pre-wrap;       /* Since CSS 2.1 */
+    white-space: -moz-pre-wrap;  /* Mozilla, since 1999 */
+    white-space: -pre-wrap;      /* Opera 4-6 */
+    white-space: -o-pre-wrap;    /* Opera 7 */
+    word-wrap: break-word;       /* Internet Explorer 5.5+ */
+    background-color: transparent;
+  }
+
+ .hidden {
+     visibility: hidden;
+ }
+
+ .toast {
+     position: fixed;
+     z-index: 999;
+     bottom: 10px;
+ }
+</style>
+
+<script>
+ let readme = "";
+ let config = {{ if $instance }}JSON.parse({{ toJson $instance.Config }}){{ else }}{}{{ end }};
+
+ function valueChanged(name, value) {
+     config[name] = value;
+     renderReadme();
+ }
+
+ async function renderReadme() {
+	 const resp = await fetch("/api/app/{{ .App.Name }}/render", {
+         method: "POST",
+         headers: {
+             "Content-Type": "application/json",
+             "Accept": "application/json",
+         },
+         body: JSON.stringify(config),
+     });
+     const app = await resp.json();
+     document.getElementById("readme").innerHTML = app.readme;
+ }
+
+ {{ if $instance }}renderReadme();{{ end }}
+
+ function disableForm() {
+     document.querySelectorAll("#config-form input").forEach((i) => i.setAttribute("disabled", ""));
+     document.querySelectorAll("#config-form select").forEach((i) => i.setAttribute("disabled", ""));
+     document.querySelectorAll("#config-form button").forEach((i) => i.setAttribute("disabled", ""));
+ }
+
+ function enableForm() {
+     document.querySelectorAll("[aria-busy]").forEach((i) => i.removeAttribute("aria-busy"));
+     document.querySelectorAll("#config-form input").forEach((i) => i.removeAttribute("disabled"));
+     document.querySelectorAll("#config-form select").forEach((i) => i.removeAttribute("disabled"));
+     document.querySelectorAll("#config-form button").forEach((i) => i.removeAttribute("disabled"));
+ }
+
+ function installStarted() {
+     const submit = document.getElementById("submit");
+     submit.setAttribute("aria-busy", true);
+     submit.innerHTML = {{ if $instance }}"Updating ..."{{ else }}"Installing ..."{{ end }};
+     disableForm();
+ }
+
+ function uninstallStarted() {
+     const submit = document.getElementById("uninstall");
+     submit.setAttribute("aria-busy", true);
+     submit.innerHTML = "Uninstalling ...";
+     disableForm();
+ }
+
+ function actionFinished(toast) {
+     enableForm();
+     toast.classList.remove("hidden");
+     setTimeout(
+         () => toast.classList.add("hidden"),
+         2000,
+     );
+ }
+
+ function installSucceeded() {
+     actionFinished(document.getElementById("toast-success"));
+ }
+
+ function installFailed() {
+     actionFinished(document.getElementById("toast-failure"));
+ }
+
+ function uninstallSucceeded() {
+     actionFinished(document.getElementById("toast-uninstall-success"));
+ }
+
+ function uninstallFailed() {
+     actionFinished(document.getElementById("toast-uninstall-failure"));
+ }
+
+ const submitAddr = {{ if $instance }}"/api/instance/{{ $instance.Id }}/update"{{ else }}"/api/app/{{ .App.Name }}/install"{{ end }};
+
+ async function install() {
+     installStarted();
+	 const resp = await fetch(submitAddr, {
+         method: "POST",
+         headers: {
+             "Content-Type": "application/json",
+             "Accept": "application/json",
+         },
+         body: JSON.stringify(config),
+     });
+     if (resp.status === 200) {
+         installSucceeded();
+     } else {
+         installFailed();
+     }
+ }
+
+ async function uninstall() {
+     {{ if $instance }}
+     uninstallStarted();
+	 const resp = await fetch("/api/instance/{{ $instance.Id }}/remove", {
+         method: "POST",
+     });
+     if (resp.status === 200) {
+         uninstallSucceeded();
+     } else {
+         uninstallFailed();
+     }
+     {{ end }}
+ }
+
+ document.getElementById("config-form").addEventListener("submit", (event) => {
+     event.preventDefault();
+     if (event.submitter.id === "submit") {
+         install();
+     } if (event.submitter.id === "uninstall") {
+         uninstall();
+     }
+ });
+</script>
+{{ end }}
diff --git a/core/installer/welcome/appmanager-tmpl/base.html b/core/installer/welcome/appmanager-tmpl/base.html
new file mode 100644
index 0000000..9e4bb49
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/base.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en" data-theme="light">
+	<head>
+		<meta charset="utf-8" />
+        <link rel="stylesheet" href="/static/pico.min.css">
+		<meta name="viewport" content="width=device-width, initial-scale=1" />
+        <style>
+          #main {
+            margin-top: 10px;
+          }
+
+          @media screen and (min-width:801px) {
+            #main {
+              width: 600px;
+            }
+          }
+        </style>
+	</head>
+	<body>
+		<div id="main" class="container">{{ block "main" . }}CHILD MUST OVERRIDE THIS{{ end }}</div>
+	</body>
+</html>
diff --git a/core/installer/welcome/appmanager-tmpl/index.html b/core/installer/welcome/appmanager-tmpl/index.html
new file mode 100644
index 0000000..02150ff
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/index.html
@@ -0,0 +1,50 @@
+{{ define "main" }}
+<form>
+  <input type="search" placeholder="Search" />
+</form>
+
+<aside>
+  <nav>
+    <ul>
+      {{ range . }}
+        <li>
+          <article>
+                  <div>
+                      <a href="/app/{{ .Slug }}" class="logo">
+                          {{ .Icon }}
+                      </a>
+                  </div>
+                  <div>
+                      <a href="/app/{{ .Slug }}">{{ .Name }}</a>
+                      {{ .ShortDescription }}
+                  </div>
+          </article>
+        </li>
+      {{ end }}
+    </ul>
+  </nav>
+</aside>
+
+<style>
+  article {
+    margin: 0.3em;
+    margin-bottom: 0.3em;
+
+    display: flex;
+    flex-direction: row;
+  }
+
+  .logo {
+    display: table-cell;
+    vertical-align: middle;
+  }
+  nav li {
+    padding-top: 0;
+    padding-bottom: 0;
+  }
+
+  input[type="search"] {
+    margin-bottom: 0;
+  }
+</style>
+{{ end }}
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
new file mode 100644
index 0000000..242faed
--- /dev/null
+++ b/core/installer/welcome/appmanager.go
@@ -0,0 +1,370 @@
+package welcome
+
+import (
+	"bytes"
+	"embed"
+	"encoding/json"
+	"fmt"
+	"html/template"
+	"io/ioutil"
+	"log"
+	"net/http"
+	// "net/http/httputil"
+	// "net/url"
+
+	"github.com/Masterminds/sprig/v3"
+	"github.com/labstack/echo/v4"
+
+	"github.com/giolekva/pcloud/core/installer"
+)
+
+//go:embed appmanager-tmpl
+var mgrTmpl embed.FS
+
+//go:embed appmanager-tmpl/base.html
+var baseHtmlTmpl string
+
+//go:embed appmanager-tmpl/app.html
+var appHtmlTmpl string
+
+type AppManagerServer struct {
+	port       int
+	webAppAddr string
+	m          *installer.AppManager
+	r          installer.AppRepository[installer.StoreApp]
+}
+
+func NewAppManagerServer(
+	port int,
+	webAppAddr string,
+	m *installer.AppManager,
+	r installer.AppRepository[installer.StoreApp],
+) *AppManagerServer {
+	return &AppManagerServer{
+		port,
+		webAppAddr,
+		m,
+		r,
+	}
+}
+
+func (s *AppManagerServer) Start() {
+	e := echo.New()
+	e.StaticFS("/static", echo.MustSubFS(staticAssets, "static"))
+	e.GET("/api/app-repo", s.handleAppRepo)
+	e.POST("/api/app/:slug/render", s.handleAppRender)
+	e.POST("/api/app/:slug/install", s.handleAppInstall)
+	e.GET("/api/app/:slug", s.handleApp)
+	e.GET("/api/instance/:slug", s.handleInstance)
+	e.POST("/api/instance/:slug/update", s.handleAppUpdate)
+	e.POST("/api/instance/:slug/remove", s.handleAppRemove)
+	e.GET("/", s.handleIndex)
+	e.GET("/app/:slug", s.handleAppUI)
+	e.GET("/instance/:slug", s.handleInstanceUI)
+	fmt.Printf("Starting HTTP server on port: %d\n", s.port)
+	log.Fatal(e.Start(fmt.Sprintf(":%d", s.port)))
+}
+
+type app struct {
+	Name             string                `json:"name"`
+	Icon             template.HTML         `json:"icon"`
+	ShortDescription string                `json:"shortDescription"`
+	Slug             string                `json:"slug"`
+	Schema           string                `json:"schema"`
+	Instances        []installer.AppConfig `json:"instances,omitempty"`
+}
+
+func (s *AppManagerServer) handleAppRepo(c echo.Context) error {
+	all, err := s.r.GetAll()
+	if err != nil {
+		return err
+	}
+	resp := make([]app, len(all))
+	for i, a := range all {
+		resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, nil}
+	}
+	return c.JSON(http.StatusOK, resp)
+}
+
+func (s *AppManagerServer) handleApp(c echo.Context) error {
+	slug := c.Param("slug")
+	a, err := s.r.Find(slug)
+	if err != nil {
+		return err
+	}
+	instances, err := s.m.FindAllInstances(slug)
+	if err != nil {
+		return err
+	}
+	for _, instance := range instances {
+		values, ok := instance.Config["Values"].(map[string]any)
+		if !ok {
+			return fmt.Errorf("Expected map")
+		}
+		for k, v := range values {
+			if k == "Network" {
+				n, ok := v.(map[string]any)
+				if !ok {
+					return fmt.Errorf("Expected map")
+				}
+				values["Network"], ok = n["Name"]
+				if !ok {
+					return fmt.Errorf("Missing Name")
+				}
+				break
+			}
+		}
+	}
+	return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, instances})
+}
+
+func (s *AppManagerServer) handleInstance(c echo.Context) error {
+	slug := c.Param("slug")
+	instance, err := s.m.FindInstance(slug)
+	if err != nil {
+		return err
+	}
+	values, ok := instance.Config["Values"].(map[string]any)
+	if !ok {
+		return fmt.Errorf("Expected map")
+	}
+	for k, v := range values {
+		if k == "Network" {
+			n, ok := v.(map[string]any)
+			if !ok {
+				return fmt.Errorf("Expected map")
+			}
+			values["Network"], ok = n["Name"]
+			if !ok {
+				return fmt.Errorf("Missing Name")
+			}
+			break
+		}
+	}
+	a, err := s.r.Find(instance.AppId)
+	if err != nil {
+		return err
+	}
+	return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, []installer.AppConfig{instance}})
+}
+
+type file struct {
+	Name     string `json:"name"`
+	Contents string `json:"contents"`
+}
+
+type rendered struct {
+	Readme string `json:"readme"`
+	Files  []file `json:"files"`
+}
+
+func (s *AppManagerServer) handleAppRender(c echo.Context) error {
+	slug := c.Param("slug")
+	contents, err := ioutil.ReadAll(c.Request().Body)
+	if err != nil {
+		return err
+	}
+	global, err := s.m.Config()
+	if err != nil {
+		return err
+	}
+	var values map[string]any
+	if err := json.Unmarshal(contents, &values); err != nil {
+		return err
+	}
+	if network, ok := values["Network"]; ok {
+		for _, n := range installer.CreateNetworks(global) {
+			if n.Name == network { // TODO(giolekva): handle not found
+				values["Network"] = n
+			}
+		}
+	}
+	all := map[string]any{
+		"Global": global.Values,
+		"Values": values,
+	}
+	a, err := s.r.Find(slug)
+	if err != nil {
+		return err
+	}
+	var readme bytes.Buffer
+	if err := a.Readme.Execute(&readme, all); err != nil {
+		return err
+	}
+	var resp rendered
+	resp.Readme = readme.String()
+	for _, tmpl := range a.Templates { // TODO(giolekva): deduplicate with Install
+		var f bytes.Buffer
+		if err := tmpl.Execute(&f, all); err != nil {
+			fmt.Printf("%+v\n", all)
+			fmt.Println(err.Error())
+			return err
+		} else {
+			resp.Files = append(resp.Files, file{tmpl.Name(), f.String()})
+		}
+	}
+	out, err := json.Marshal(resp)
+	if err != nil {
+		return err
+	}
+	if _, err := c.Response().Writer.Write(out); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (s *AppManagerServer) handleAppInstall(c echo.Context) error {
+	slug := c.Param("slug")
+	contents, err := ioutil.ReadAll(c.Request().Body)
+	if err != nil {
+		return err
+	}
+	var values map[string]any
+	if err := json.Unmarshal(contents, &values); err != nil {
+		return err
+	}
+	fmt.Println(values)
+	a, err := s.r.Find(slug)
+	if err != nil {
+		return err
+	}
+	config, err := s.m.Config()
+	if err != nil {
+		return err
+	}
+	nsGen := installer.NewPrefixGenerator(config.Values.NamespacePrefix)
+	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
+	if err := s.m.Install(a.App, nsGen, suffixGen, values); err != nil {
+		return err
+	}
+	return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleAppUpdate(c echo.Context) error {
+	slug := c.Param("slug")
+	appConfig, err := s.m.AppConfig(slug)
+	if err != nil {
+		return err
+	}
+	contents, err := ioutil.ReadAll(c.Request().Body)
+	if err != nil {
+		return err
+	}
+	var values map[string]any
+	if err := json.Unmarshal(contents, &values); err != nil {
+		return err
+	}
+	a, err := s.r.Find(appConfig.AppId)
+	if err != nil {
+		return err
+	}
+	if err := s.m.Update(a.App, slug, values); err != nil {
+		return err
+	}
+	return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleAppRemove(c echo.Context) error {
+	slug := c.Param("slug")
+	if err := s.m.Remove(slug); err != nil {
+		return err
+	}
+	return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleIndex(c echo.Context) error {
+	tmpl, err := template.ParseFS(mgrTmpl, "appmanager-tmpl/base.html", "appmanager-tmpl/index.html")
+	if err != nil {
+		return err
+	}
+	all, err := s.r.GetAll()
+	if err != nil {
+		return err
+	}
+	resp := make([]app, len(all))
+	for i, a := range all {
+		resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, nil}
+	}
+	return tmpl.Execute(c.Response(), resp)
+}
+
+type appContext[T any] struct {
+	App               *T
+	Instance          *installer.AppConfig
+	Instances         []installer.AppConfig
+	AvailableNetworks []installer.Network
+}
+
+func (s *AppManagerServer) handleAppUI(c echo.Context) error {
+	baseTmpl, err := newTemplate().Parse(baseHtmlTmpl)
+	if err != nil {
+		return err
+	}
+	appTmpl, err := template.Must(baseTmpl.Clone()).Parse(appHtmlTmpl)
+	if err != nil {
+		fmt.Println(err)
+		return err
+	}
+	global, err := s.m.Config()
+	if err != nil {
+		return err
+	}
+	slug := c.Param("slug")
+	a, err := s.r.Find(slug)
+	if err != nil {
+		return err
+	}
+	instances, err := s.m.FindAllInstances(slug)
+	if err != nil {
+		return err
+	}
+	err = appTmpl.Execute(c.Response(), appContext[installer.StoreApp]{
+		App:               a,
+		Instances:         instances,
+		AvailableNetworks: installer.CreateNetworks(global),
+	})
+	fmt.Println(err)
+	return err
+}
+
+func (s *AppManagerServer) handleInstanceUI(c echo.Context) error {
+	baseTmpl, err := newTemplate().Parse(baseHtmlTmpl)
+	if err != nil {
+		return err
+	}
+	appTmpl, err := template.Must(baseTmpl.Clone()).Parse(appHtmlTmpl)
+	// tmpl, err := newTemplate().ParseFS(mgrTmpl, "appmanager-tmpl/base.html", "appmanager-tmpl/app.html")
+	if err != nil {
+		fmt.Println(err)
+		return err
+	}
+	global, err := s.m.Config()
+	if err != nil {
+		return err
+	}
+	slug := c.Param("slug")
+	instance, err := s.m.FindInstance(slug)
+	if err != nil {
+		return err
+	}
+	a, err := s.r.Find(instance.AppId)
+	if err != nil {
+		return err
+	}
+	instances, err := s.m.FindAllInstances(a.Name)
+	if err != nil {
+		return err
+	}
+	err = appTmpl.Execute(c.Response(), appContext[installer.StoreApp]{
+		App:               a,
+		Instance:          &instance,
+		Instances:         instances,
+		AvailableNetworks: installer.CreateNetworks(global),
+	})
+	fmt.Println(err)
+	return err
+}
+
+func newTemplate() *template.Template {
+	return template.New("base").Funcs(template.FuncMap(sprig.FuncMap()))
+}
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index ab1504d..6779035 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -262,6 +262,29 @@
 			return err
 		}
 	}
+	{
+		keys, err := installer.NewSSHKeyPair()
+		if err != nil {
+			return err
+		}
+		user := fmt.Sprintf("%s-appmanager", req.Name)
+		if err := ss.AddUser(user, keys.Public); err != nil {
+			return err
+		}
+		if err := ss.AddCollaborator(req.Name, user); err != nil {
+			return err
+		}
+		app, err := appsRepo.Find("app-manager") // TODO(giolekva): configure
+		if err != nil {
+			return err
+		}
+		if err := appManager.Install(*app, nsGen, suffixGen, map[string]any{
+			"RepoAddr":      ss.GetRepoAddress(req.Name),
+			"SSHPrivateKey": keys.Private,
+		}); err != nil {
+			return err
+		}
+	}
 	return nil
 }