Dodo APP: infrastructure to deploy app by pusing to Git repo

Change-Id: I4034c6893255581b014ddb207c844261cb34202b
diff --git a/apps/app-runner/.gitignore b/apps/app-runner/.gitignore
new file mode 100644
index 0000000..f7f9db4
--- /dev/null
+++ b/apps/app-runner/.gitignore
@@ -0,0 +1 @@
+app-runner_*
diff --git a/apps/app-runner/Dockerfile b/apps/app-runner/Dockerfile
new file mode 100644
index 0000000..964cd31
--- /dev/null
+++ b/apps/app-runner/Dockerfile
@@ -0,0 +1,5 @@
+FROM golang:1.22.0-bookworm
+
+ARG TARGETARCH
+
+COPY app-runner_${TARGETARCH} /usr/bin/app-runner
diff --git a/apps/app-runner/Makefile b/apps/app-runner/Makefile
new file mode 100644
index 0000000..4c2424e
--- /dev/null
+++ b/apps/app-runner/Makefile
@@ -0,0 +1,35 @@
+repo_name ?= giolekva
+podman ?= docker
+ifeq ($(podman), podman)
+manifest_dest=docker://docker.io/$(repo_name)/app-runner:golang-1.22.0
+endif
+
+clean:
+	rm -f app-runner
+
+build_arm64: export CGO_ENABLED=0
+build_arm64: export GO111MODULE=on
+build_arm64: export GOOS=linux
+build_arm64: export GOARCH=arm64
+build_arm64:
+	/usr/local/go/bin/go build -o app-runner_arm64 *.go
+
+build_amd64: export CGO_ENABLED=0
+build_amd64: export GO111MODULE=on
+build_amd64: export GOOS=linux
+build_amd64: export GOARCH=amd64
+build_amd64:
+	/usr/local/go/bin/go build -o app-runner_amd64 *.go
+
+push_arm64: clean build_arm64
+	$(podman) build --platform linux/arm64 --tag=$(repo_name)/app-runner:golang-1.22.0-arm64 .
+	$(podman) push $(repo_name)/app-runner:golang-1.22.0-arm64
+
+push_amd64: clean build_amd64
+	$(podman) build --platform linux/amd64 --tag=$(repo_name)/app-runner:golang-1.22.0-amd64 .
+	$(podman) push $(repo_name)/app-runner:golang-1.22.0-amd64
+
+push: push_arm64 push_amd64
+	$(podman) manifest create $(repo_name)/app-runner:golang-1.22.0 $(repo_name)/app-runner:golang-1.22.0-arm64 $(repo_name)/app-runner:golang-1.22.0-amd64
+	$(podman) manifest push $(repo_name)/app-runner:golang-1.22.0 $(manifest_dest)
+	$(podman) manifest rm $(repo_name)/app-runner:golang-1.22.0
diff --git a/apps/app-runner/go.mod b/apps/app-runner/go.mod
new file mode 100644
index 0000000..5ee731f
--- /dev/null
+++ b/apps/app-runner/go.mod
@@ -0,0 +1,31 @@
+module github.com/giolekva/pcloud/apps/app-runner
+
+go 1.18
+
+require (
+	github.com/go-git/go-billy/v5 v5.5.0
+	github.com/go-git/go-git/v5 v5.12.0
+	golang.org/x/crypto v0.23.0
+)
+
+require (
+	dario.cat/mergo v1.0.0 // indirect
+	github.com/Microsoft/go-winio v0.6.1 // indirect
+	github.com/ProtonMail/go-crypto v1.0.0 // indirect
+	github.com/cloudflare/circl v1.3.7 // indirect
+	github.com/cyphar/filepath-securejoin v0.2.4 // indirect
+	github.com/emirpasic/gods v1.18.1 // indirect
+	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+	github.com/kevinburke/ssh_config v1.2.0 // indirect
+	github.com/pjbgf/sha1cd v0.3.0 // indirect
+	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
+	github.com/skeema/knownhosts v1.2.2 // indirect
+	github.com/xanzy/ssh-agent v0.3.3 // indirect
+	golang.org/x/mod v0.12.0 // indirect
+	golang.org/x/net v0.22.0 // indirect
+	golang.org/x/sys v0.20.0 // indirect
+	golang.org/x/tools v0.13.0 // indirect
+	gopkg.in/warnings.v0 v0.1.2 // indirect
+)
diff --git a/apps/app-runner/go.sum b/apps/app-runner/go.sum
new file mode 100644
index 0000000..602310e
--- /dev/null
+++ b/apps/app-runner/go.sum
@@ -0,0 +1,130 @@
+dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
+github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
+github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
+github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
+github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
+github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
+github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
+github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
+github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
+github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
+github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
+github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
+github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
+github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
+github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
+golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
+golang.org/x/net v0.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.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
+golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/apps/app-runner/main.go b/apps/app-runner/main.go
new file mode 100644
index 0000000..fb17415
--- /dev/null
+++ b/apps/app-runner/main.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+	"encoding/json"
+	"flag"
+	"fmt"
+	"net"
+	"os"
+
+	"golang.org/x/crypto/ssh"
+
+	"github.com/go-git/go-billy/v5/osfs"
+	"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"
+)
+
+var port = flag.Int("port", 3000, "Port to listen on")
+var repoAddr = flag.String("repo-addr", "", "Git repository address")
+var sshKey = flag.String("ssh-key", "", "Private SSH key to access Git repository")
+var appDir = flag.String("app-dir", "", "Path to store application repository locally")
+var runCfg = flag.String("run-cfg", "", "Run configuration")
+var manager = flag.String("manager", "", "Address of the manager")
+
+type Command struct {
+	Bin  string   `json:"bin"`
+	Args []string `json:"args"`
+}
+
+func CloneRepository(addr string, signer ssh.Signer, path string) error {
+	_, err := git.Clone(memory.NewStorage(), osfs.New(path, osfs.WithBoundOS()), &git.CloneOptions{
+		URL: addr,
+		Auth: &gitssh.PublicKeys{
+			User:   "git",
+			Signer: signer,
+			HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{
+				HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
+					// TODO(giolekva): verify server public key
+					fmt.Printf("--- %+v\n", ssh.MarshalAuthorizedKey(key))
+					return nil
+				},
+			},
+		},
+		RemoteName:      "origin",
+		ReferenceName:   "refs/heads/master",
+		Depth:           1,
+		InsecureSkipTLS: true,
+	})
+	return err
+}
+
+func main() {
+	flag.Parse()
+	self, ok := os.LookupEnv("SELF_IP")
+	if !ok {
+		panic("no SELF_IP")
+	}
+	key, err := os.ReadFile(*sshKey)
+	if err != nil {
+		panic(err)
+	}
+	signer, err := ssh.ParsePrivateKey(key)
+	if err != nil {
+		panic(err)
+	}
+	if err := CloneRepository(*repoAddr, signer, *appDir); err != nil {
+		panic(err)
+	}
+	r, err := os.Open(*runCfg)
+	if err != nil {
+		panic(err)
+	}
+	defer r.Close()
+	var cmds []Command
+	if err := json.NewDecoder(r).Decode(&cmds); err != nil {
+		panic(err)
+	}
+	s := NewServer(*port, *repoAddr, signer, *appDir, cmds, self, *manager)
+	s.Start()
+}
diff --git a/apps/app-runner/server.go b/apps/app-runner/server.go
new file mode 100644
index 0000000..665f493
--- /dev/null
+++ b/apps/app-runner/server.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"os"
+	"os/exec"
+	"sync"
+	"time"
+
+	"golang.org/x/crypto/ssh"
+)
+
+type Server struct {
+	l           sync.Locker
+	port        int
+	ready       bool
+	cmd         *exec.Cmd
+	repoAddr    string
+	signer      ssh.Signer
+	appDir      string
+	runCommands []Command
+	self        string
+	manager     string
+}
+
+func NewServer(port int, repoAddr string, signer ssh.Signer, appDir string, runCommands []Command, self string, manager string) *Server {
+	return &Server{
+		l:           &sync.Mutex{},
+		port:        port,
+		ready:       false,
+		repoAddr:    repoAddr,
+		signer:      signer,
+		appDir:      appDir,
+		runCommands: runCommands,
+		self:        self,
+		manager:     manager,
+	}
+}
+
+func (s *Server) Start() error {
+	http.HandleFunc("/update", s.handleUpdate)
+	http.HandleFunc("/ready", s.handleReady)
+	if err := s.run(); err != nil {
+		return err
+	}
+	go s.pingManager()
+	return http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil)
+}
+
+func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
+	s.l.Lock()
+	defer s.l.Unlock()
+	if s.ready {
+		fmt.Fprintln(w, "ok")
+	} else {
+		http.Error(w, "not ready", http.StatusInternalServerError)
+	}
+}
+
+func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
+	fmt.Println("update")
+	s.l.Lock()
+	s.ready = false
+	s.l.Unlock()
+	if s.cmd != nil {
+		err := s.cmd.Process.Kill()
+		s.cmd.Wait()
+		s.cmd = nil
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	}
+	if err := os.RemoveAll(s.appDir); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.run(); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	s.l.Lock()
+	s.ready = true
+	s.l.Unlock()
+}
+
+func (s *Server) run() error {
+	if err := CloneRepository(s.repoAddr, s.signer, s.appDir); err != nil {
+		return err
+	}
+	for i, c := range s.runCommands {
+		args := []string{c.Bin}
+		args = append(args, c.Args...)
+		cmd := &exec.Cmd{
+			Dir:    *appDir,
+			Path:   c.Bin,
+			Args:   args,
+			Stdout: os.Stdout,
+			Stderr: os.Stderr,
+		}
+		fmt.Printf("Running: %s\n", c.Bin)
+		if i < len(s.runCommands)-1 {
+			if err := cmd.Run(); err != nil {
+				return err
+			}
+		} else {
+			if err := cmd.Start(); err != nil {
+				return err
+			}
+			s.cmd = cmd
+		}
+	}
+	return nil
+}
+
+type pingReq struct {
+	Address string `json:"address"`
+}
+
+func (s *Server) pingManager() {
+	defer func() {
+		go func() {
+			time.Sleep(5 * time.Second)
+			s.pingManager()
+		}()
+	}()
+	buf, err := json.Marshal(pingReq{s.self})
+	if err != nil {
+		return
+	}
+	http.Post(s.manager, "application/json", bytes.NewReader(buf))
+}
diff --git a/apps/app-runner/test-cfg.json b/apps/app-runner/test-cfg.json
new file mode 100644
index 0000000..235e6ee
--- /dev/null
+++ b/apps/app-runner/test-cfg.json
@@ -0,0 +1,9 @@
+[
+  {
+  "bin": "/usr/local/go/bin/go",
+  "args": ["mod", "tidy"]
+}, {
+  "bin": "/usr/local/go/bin/go",
+  "args": ["run", "main.go", "--port=8081"]
+}
+]
diff --git a/charts/app-runner/.helmignore b/charts/app-runner/.helmignore
new file mode 100644
index 0000000..0e8a0eb
--- /dev/null
+++ b/charts/app-runner/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/charts/app-runner/Chart.yaml b/charts/app-runner/Chart.yaml
new file mode 100644
index 0000000..b1bad90
--- /dev/null
+++ b/charts/app-runner/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: app-runner
+description: A Helm chart for PCloud App Runner
+type: application
+version: 0.0.1
+appVersion: "0.0.1"
diff --git a/charts/app-runner/templates/install.yaml b/charts/app-runner/templates/install.yaml
new file mode 100644
index 0000000..5bdf72e
--- /dev/null
+++ b/charts/app-runner/templates/install.yaml
@@ -0,0 +1,101 @@
+{{ $runCfg := .Values.runCfg | b64dec }}
+---
+apiVersion: v1
+kind: Secret
+metadata:
+  name: app-ssh-key
+type: Opaque
+data:
+  private: {{ .Values.sshPrivateKey }}
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: app-run-cfg
+data:
+  run: |
+{{ indent 4 $runCfg }}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: app-app
+  namespace: {{ .Release.Namespace }}
+spec:
+  type: ClusterIP
+  selector:
+    app: app-app
+  ports:
+  - name: app
+    port: 80
+    targetPort: app
+    protocol: TCP
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: app-api
+  namespace: {{ .Release.Namespace }}
+spec:
+  type: ClusterIP
+  selector:
+    app: app-app
+  ports:
+  - name: api
+    port: 3000
+    targetPort: api
+    protocol: TCP
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: app-app
+  namespace: {{ .Release.Namespace }}
+spec:
+  selector:
+    matchLabels:
+      app: app-app
+  replicas: 1
+  template:
+    metadata:
+      labels:
+        app: app-app
+    spec:
+      volumes:
+      - name: ssh-key
+        secret:
+          secretName: app-ssh-key
+      - name: run-cfg
+        configMap:
+          name: app-run-cfg
+      containers:
+      - name: app
+        image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
+        imagePullPolicy: {{ .Values.image.pullPolicy }}
+        ports:
+        - name: api
+          containerPort: 3000
+          protocol: TCP
+        - name: app
+          containerPort: {{ .Values.appPort }}
+          protocol: TCP
+        env:
+        - name: SELF_IP
+          valueFrom:
+            fieldRef:
+              fieldPath: status.podIP
+        command:
+        - app-runner
+        - --port=3000
+        - --app-dir=/dodo-app
+        - --repo-addr={{ .Values.repoAddr }}
+        - --ssh-key=/pcloud/ssh-key/private
+        - --run-cfg=/pcloud/config/run
+        - --manager={{ .Values.manager }}
+        volumeMounts:
+        - name: ssh-key
+          readOnly: true
+          mountPath: /pcloud/ssh-key
+        - name: run-cfg
+          readOnly: true
+          mountPath: /pcloud/config
diff --git a/charts/app-runner/values.yaml b/charts/app-runner/values.yaml
new file mode 100644
index 0000000..9ec9b55
--- /dev/null
+++ b/charts/app-runner/values.yaml
@@ -0,0 +1,10 @@
+image:
+  repository: giolekva/app-runner
+  tag: latest
+  pullPolicy: Always
+repoAddr: 192.168.0.11
+sshPrivateKey: key
+runCfg: ""
+appDir: /dodo-app
+appPort: 8080
+manager: ""
diff --git a/charts/dodo-app/.helmignore b/charts/dodo-app/.helmignore
new file mode 100644
index 0000000..0e8a0eb
--- /dev/null
+++ b/charts/dodo-app/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/charts/dodo-app/Chart.yaml b/charts/dodo-app/Chart.yaml
new file mode 100644
index 0000000..b91b7b9
--- /dev/null
+++ b/charts/dodo-app/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: dodo-app
+description: A Helm chart for updaring Dodo apps
+type: application
+version: 0.0.1
+appVersion: "0.0.1"
diff --git a/charts/dodo-app/templates/install.yaml b/charts/dodo-app/templates/install.yaml
new file mode 100644
index 0000000..f19ea1e
--- /dev/null
+++ b/charts/dodo-app/templates/install.yaml
@@ -0,0 +1,77 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: ssh-key
+type: Opaque
+data:
+  private: {{ .Values.sshPrivateKey }}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: dodo-app
+  namespace: {{ .Release.Namespace }}
+spec:
+  type: ClusterIP
+  selector:
+    app: dodo-app
+  ports:
+  - name: http
+    port: 80
+    targetPort: http
+    protocol: TCP
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: dodo-app
+  namespace: {{ .Release.Namespace }}
+spec:
+  selector:
+    matchLabels:
+      app: dodo-app
+  replicas: 1
+  template:
+    metadata:
+      labels:
+        app: dodo-app
+    spec:
+      volumes:
+      - name: ssh-key
+        secret:
+          secretName: ssh-key
+      - name: env-config
+        secret:
+          secretName: env-config
+      containers:
+      - name: dodo-app
+        image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
+        imagePullPolicy: {{ .Values.image.pullPolicy }}
+        ports:
+        - name: http
+          containerPort: 8080
+          protocol: TCP
+        command:
+        - pcloud-installer
+        - dodo-app
+        - --repo-addr={{ .Values.repoAddr }}
+        - --ssh-key=/pcloud/ssh-key/private
+        - --port=8080
+        - --self={{ .Values.self }}
+        - --namespace={{ .Values.namespace }} # TODO(gio): maybe use .Release.Namespace ?
+        - --env-config=/pcloud/env-config/config.json
+        volumeMounts:
+        - name: ssh-key
+          readOnly: true
+          mountPath: /pcloud/ssh-key
+        - name: env-config
+          readOnly: true
+          mountPath: /pcloud/env-config
+---
+apiVersion: v1
+kind: Secret
+metadata:
+  name: env-config
+type: Opaque
+data:
+  config.json: {{ .Values.envConfig }}
diff --git a/charts/dodo-app/values.yaml b/charts/dodo-app/values.yaml
new file mode 100644
index 0000000..dcdc380
--- /dev/null
+++ b/charts/dodo-app/values.yaml
@@ -0,0 +1,9 @@
+image:
+  repository: giolekva/pcloud-installer
+  tag: latest
+  pullPolicy: Always
+repoAddr: 192.168.0.11
+sshPrivateKey: key
+self: ""
+namespace: ""
+envConfig: ""
diff --git a/charts/soft-serve/templates/stateful-set.yaml b/charts/soft-serve/templates/stateful-set.yaml
index 0d84eca..0ed35ea 100644
--- a/charts/soft-serve/templates/stateful-set.yaml
+++ b/charts/soft-serve/templates/stateful-set.yaml
@@ -33,7 +33,8 @@
         - name: SOFT_SERVE_SSH_PUBLIC_URL
           value: "ssh://{{ .Values.ingress.domain }}:{{ .Values.sshPublicPort }}"
         - name: SOFT_SERVE_INITIAL_ADMIN_KEYS
-          value: "{{ .Values.adminKey }}"
+          value: |-
+{{ indent 12 .Values.adminKey }}
         {{ if and .Values.privateKey .Values.publicKey }}
         - name: SOFT_SERVE_SSH_KEY_PATH
           value: /.ssh/key
diff --git a/core/installer/Makefile b/core/installer/Makefile
index 800a8eb..ce9c184 100644
--- a/core/installer/Makefile
+++ b/core/installer/Makefile
@@ -37,6 +37,9 @@
 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
 
+dodo-app:
+	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner dodo-app --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://localhost:2222/test
+
 welc:
 	./pcloud --kubeconfig=../../priv/kubeconfig welcome --ssh-key=/Users/lekva/.ssh/id_rsa --repo-addr=ssh://192.168.0.210/config --port=9090
 
diff --git a/core/installer/app.go b/core/installer/app.go
index 1626a1a..2a16c1e 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -2,6 +2,7 @@
 
 import (
 	"bytes"
+	_ "embed"
 	"encoding/json"
 	"fmt"
 	template "html/template"
@@ -16,8 +17,15 @@
 	cueyaml "cuelang.org/go/encoding/yaml"
 )
 
+//go:embed pcloud_app.cue
+var DodoAppCue []byte
+
 // TODO(gio): import
 const cueEnvAppGlobal = `
+import (
+    "net"
+)
+
 #Global: {
 	id: string | *""
 	pcloudEnvName: string | *""
@@ -31,20 +39,13 @@
 	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"
-	}
+#EnvNetwork: {
+	dns: net.IPv4
+	dnsInClusterIP: net.IPv4
+	ingress: net.IPv4
+	headscale: net.IPv4
+	servicesFrom: net.IPv4
+	servicesTo: net.IPv4
 }
 
 // TODO(gio): remove
@@ -164,10 +165,6 @@
 `
 
 const cueBaseConfig = `
-import (
-  "net"
-)
-
 name: string | *""
 description: string | *""
 readme: string | *""
@@ -187,9 +184,11 @@
 #AppType: "infra" | "env"
 appType: #AppType | *"env"
 
-#Auth: {
-  enabled: bool | *false // TODO(gio): enabled by default?
-  groups: string | *"" // TODO(gio): []string
+#Release: {
+	appInstanceId: string
+	namespace: string
+	repoAddr: string
+	appDir: string
 }
 
 #Network: {
@@ -200,6 +199,11 @@
 	allocatePortAddr: string
 }
 
+#Auth: {
+  enabled: bool | *false // TODO(gio): enabled by default?
+  groups: string | *"" // TODO(gio): []string
+}
+
 #Image: {
 	registry: string | *"docker.io"
 	repository: string
@@ -222,22 +226,6 @@
 	namespace: string // TODO(gio): default global.id
 }
 
-#EnvNetwork: {
-	dns: net.IPv4
-	dnsInClusterIP: net.IPv4
-	ingress: net.IPv4
-	headscale: net.IPv4
-	servicesFrom: net.IPv4
-	servicesTo: net.IPv4
-}
-
-#Release: {
-	appInstanceId: string
-	namespace: string
-	repoAddr: string
-	appDir: string
-}
-
 #PortForward: {
 	allocator: string
 	protocol: "TCP" | "UDP" | *"TCP"
@@ -302,6 +290,8 @@
 	}
 }
 
+resources: {}
+
 #HelmRelease: {
 	_name: string
 	_chart: #Chart
@@ -349,6 +339,8 @@
 help: [...#HelpDocument] | *[]
 
 url: string | *""
+
+networks: {}
 `
 
 type rendered struct {
@@ -620,17 +612,34 @@
 	if err := res.LookupPath(cue.ParsePath("portForward")).Decode(&ret.Ports); err != nil {
 		return rendered{}, err
 	}
-	output := res.LookupPath(cue.ParsePath("output"))
-	i, err := output.Fields()
-	if err != nil {
-		return rendered{}, err
-	}
-	for i.Next() {
-		if contents, err := cueyaml.Encode(i.Value()); err != nil {
+	{
+		output := res.LookupPath(cue.ParsePath("output"))
+		i, err := output.Fields()
+		if err != nil {
 			return rendered{}, err
-		} else {
-			name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
-			ret.Resources[name] = contents
+		}
+		for i.Next() {
+			if contents, err := cueyaml.Encode(i.Value()); err != nil {
+				return rendered{}, err
+			} else {
+				name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
+				ret.Resources[name] = contents
+			}
+		}
+	}
+	{
+		resources := res.LookupPath(cue.ParsePath("resources"))
+		i, err := resources.Fields()
+		if err != nil {
+			return rendered{}, err
+		}
+		for i.Next() {
+			if contents, err := cueyaml.Encode(i.Value()); err != nil {
+				return rendered{}, err
+			} else {
+				name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
+				ret.Resources[name] = contents
+			}
 		}
 	}
 	helpValue := res.LookupPath(cue.ParsePath("help"))
@@ -664,6 +673,15 @@
 	return cueEnvApp{app}, nil
 }
 
+func NewDodoApp(appCfg []byte) (EnvApp, error) {
+	return NewCueEnvApp(CueAppData{
+		"app.cue":        appCfg,
+		"base.cue":       []byte(cueBaseConfig),
+		"pcloud_app.cue": DodoAppCue,
+		"env_app.cue":    []byte(cueEnvAppGlobal),
+	})
+}
+
 func (a cueEnvApp) Type() AppType {
 	return AppTypeEnv
 }
@@ -675,9 +693,10 @@
 		return EnvAppRendered{}, nil
 	}
 	ret, err := a.cueApp.render(map[string]any{
-		"global":  env,
-		"release": release,
-		"input":   derived,
+		"global":   env,
+		"release":  release,
+		"input":    derived,
+		"networks": networkMap(networks),
 	})
 	if err != nil {
 		return EnvAppRendered{}, err
@@ -747,3 +766,11 @@
 	}
 	return strings.Join(tmp, ",")
 }
+
+func networkMap(networks []Network) map[string]Network {
+	ret := make(map[string]Network)
+	for _, n := range networks {
+		ret[strings.ToLower(n.Name)] = n
+	}
+	return ret
+}
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 44e39e6..ae18ff8 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -178,7 +178,7 @@
 }
 
 // TODO(gio): rename to CommitApp
-func InstallApp(
+func installApp(
 	repo soft.RepoIO,
 	appDir string,
 	name string,
@@ -242,7 +242,11 @@
 }
 
 // TODO(gio): commit instanceId -> appDir mapping as well
-func (m *AppManager) Install(app EnvApp, instanceId string, appDir string, namespace string, values map[string]any) (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)
+	}
 	appDir = filepath.Clean(appDir)
 	if err := m.repoIO.Pull(); err != nil {
 		return ReleaseResources{}, err
@@ -250,9 +254,15 @@
 	if err := m.nsCreator.Create(namespace); err != nil {
 		return ReleaseResources{}, err
 	}
-	env, err := m.Config()
-	if err != nil {
-		return ReleaseResources{}, err
+	var env EnvConfig
+	if o.Env != nil {
+		env = *o.Env
+	} else {
+		var err error
+		env, err = m.Config()
+		if err != nil {
+			return ReleaseResources{}, err
+		}
 	}
 	release := Release{
 		AppInstanceId: instanceId,
@@ -264,7 +274,12 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	if _, err := InstallApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data); err != nil {
+	dopts := []soft.DoOption{}
+	if o.Branch != "" {
+		dopts = append(dopts, soft.WithForce())
+		dopts = append(dopts, soft.WithCommitToBranch(o.Branch))
+	}
+	if _, err := installApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, dopts...); err != nil {
 		return ReleaseResources{}, err
 	}
 	// TODO(gio): add ingress-nginx to release resources
@@ -278,6 +293,7 @@
 
 type helmRelease struct {
 	Metadata Resource `json:"metadata"`
+	Kind     string   `json:"kind"`
 	Status   struct {
 		Conditions []struct {
 			Type   string `json:"type"`
@@ -293,7 +309,9 @@
 		if err := yaml.Unmarshal(contents, &h); err != nil {
 			panic(err) // TODO(gio): handle
 		}
-		ret = append(ret, h.Metadata)
+		if h.Kind == "HelmRelease" {
+			ret = append(ret, h.Metadata)
+		}
 	}
 	return ret
 }
@@ -322,7 +340,7 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	return InstallApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
+	return installApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
 }
 
 func (m *AppManager) Remove(instanceId string) error {
@@ -368,6 +386,25 @@
 	nsCreator NamespaceCreator
 }
 
+type installOptions struct {
+	Env    *EnvConfig
+	Branch string
+}
+
+type InstallOption func(*installOptions)
+
+func WithConfig(env *EnvConfig) InstallOption {
+	return func(o *installOptions) {
+		o.Env = env
+	}
+}
+
+func WithBranch(branch string) InstallOption {
+	return func(o *installOptions) {
+		o.Branch = branch
+	}
+}
+
 func NewInfraAppManager(repoIO soft.RepoIO, nsCreator NamespaceCreator) (*InfraAppManager, error) {
 	return &InfraAppManager{
 		repoIO,
@@ -432,7 +469,7 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	return InstallApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data)
+	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) {
@@ -459,5 +496,5 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	return InstallApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
+	return installApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
 }
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
index b51f766..f35eb97 100644
--- a/core/installer/app_repository.go
+++ b/core/installer/app_repository.go
@@ -18,6 +18,7 @@
 var valuesTmpls embed.FS
 
 var storeEnvAppConfigs = []string{
+	"values-tmpl/dodo-app.cue",
 	"values-tmpl/url-shortener.cue",
 	"values-tmpl/matrix.cue",
 	"values-tmpl/vaultwarden.cue",
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 9b59d1c..a646425 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -1,6 +1,7 @@
 package installer
 
 import (
+	_ "embed"
 	"net"
 	"testing"
 )
@@ -298,3 +299,19 @@
 		t.Log(string(r))
 	}
 }
+
+//go:embed testapp.cue
+var testAppCue []byte
+
+type appInput struct {
+	RepoAddr string  `json:"repoAddr"`
+	SSHKey   string  `json:"sshKey"`
+	Network  Network `json:"network"`
+}
+
+func TestPCloudApp(t *testing.T) {
+	_, err := NewDodoApp(testAppCue)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
new file mode 100644
index 0000000..d9f9a69
--- /dev/null
+++ b/core/installer/cmd/dodo_app.go
@@ -0,0 +1,175 @@
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+	"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"
+)
+
+var dodoAppFlags struct {
+	port      int
+	sshKey    string
+	repoAddr  string
+	self      string
+	namespace string
+	envConfig string
+}
+
+func dodoAppCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:  "dodo-app",
+		RunE: dodoAppCmdRun,
+	}
+	cmd.Flags().IntVar(
+		&dodoAppFlags.port,
+		"port",
+		8080,
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.repoAddr,
+		"repo-addr",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.sshKey,
+		"ssh-key",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.self,
+		"self",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.namespace,
+		"namespace",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.envConfig,
+		"env-config",
+		"",
+		"",
+	)
+	return cmd
+}
+
+func dodoAppCmdRun(cmd *cobra.Command, args []string) error {
+	envConfig, err := os.Open(dodoAppFlags.envConfig)
+	if err != nil {
+		return err
+	}
+	defer envConfig.Close()
+	var env installer.EnvConfig
+	if err := json.NewDecoder(envConfig).Decode(&env); err != nil {
+		return err
+	}
+	sshKey, err := os.ReadFile(dodoAppFlags.sshKey)
+	if err != nil {
+		return err
+	}
+	softClient, err := soft.NewClient(dodoAppFlags.repoAddr, sshKey, log.Default())
+	if err != nil {
+		return err
+	}
+	if err := softClient.AddRepository("app"); err == nil {
+		repo, err := softClient.GetRepo("app")
+		if err != nil {
+			return err
+		}
+		if err := initRepo(repo); err != nil {
+			return err
+		}
+		if err := welcome.UpdateDodoApp(softClient, dodoAppFlags.namespace, string(sshKey), &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 {
+			return err
+		}
+	} else if !errors.Is(err, soft.ErrorAlreadyExists) {
+		return err
+	}
+	s := welcome.NewDodoAppServer(dodoAppFlags.port, string(sshKey), softClient, dodoAppFlags.namespace, env)
+	return s.Start()
+}
+
+const goMod = `module dodo.app
+
+go 1.18
+`
+
+const mainGo = `package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"net/http"
+)
+
+var port = flag.Int("port", 8080, "Port to listen on")
+
+func handler(w http.ResponseWriter, r *http.Request) {
+	fmt.Fprintln(w, "Hello from Dodo App!")
+}
+
+func main() {
+	flag.Parse()
+	http.HandleFunc("/", handler)
+	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
+}
+`
+
+const appCue = `app: {
+	type: "golang:1.22.0"
+	run: "main.go"
+	ingress: {
+		network: "Private" // or Public
+		subdomain: "testapp"
+		auth: enabled: false
+	}
+}
+`
+
+func initRepo(repo soft.RepoIO) error {
+	return repo.Do(func(fs soft.RepoFS) (string, error) {
+		{
+			w, err := fs.Writer("go.mod")
+			if err != nil {
+				return "", err
+			}
+			defer w.Close()
+			fmt.Fprint(w, goMod)
+		}
+		{
+			w, err := fs.Writer("main.go")
+			if err != nil {
+				return "", err
+			}
+			defer w.Close()
+			fmt.Fprintf(w, "%s", mainGo)
+		}
+		{
+			w, err := fs.Writer("app.cue")
+			if err != nil {
+				return "", err
+			}
+			defer w.Close()
+			fmt.Fprint(w, appCue)
+		}
+		return "go web app template", nil
+	})
+}
diff --git a/core/installer/cmd/main.go b/core/installer/cmd/main.go
index 5b05381..568efae 100644
--- a/core/installer/cmd/main.go
+++ b/core/installer/cmd/main.go
@@ -28,6 +28,7 @@
 	rootCmd.AddCommand(welcomeCmd())
 	rootCmd.AddCommand(rewriteCmd())
 	rootCmd.AddCommand(launcherCmd())
+	rootCmd.AddCommand(dodoAppCmd())
 }
 
 func main() {
diff --git a/core/installer/kube.go b/core/installer/kube.go
index a8ed275..c8251ff 100644
--- a/core/installer/kube.go
+++ b/core/installer/kube.go
@@ -31,6 +31,16 @@
 	Fetch(addr string) (string, error)
 }
 
+type noOpNamespaceCreator struct{}
+
+func (n *noOpNamespaceCreator) Create(name string) error {
+	return nil
+}
+
+func NewNoOpNamespaceCreator() NamespaceCreator {
+	return &noOpNamespaceCreator{}
+}
+
 type realNamespaceCreator struct {
 	clientset *kubernetes.Clientset
 }
diff --git a/core/installer/pcloud_app.cue b/core/installer/pcloud_app.cue
new file mode 100644
index 0000000..d453747
--- /dev/null
+++ b/core/installer/pcloud_app.cue
@@ -0,0 +1,102 @@
+import (
+	"encoding/base64"
+	"encoding/json"
+	"strings"
+)
+
+input: {
+	repoAddr: string
+	sshPrivateKey: string
+}
+
+#AppIngress: {
+	network: string
+	subdomain: string
+	auth: #Auth
+}
+
+_goVer1220: "golang:1.22.0"
+_goVer1200: "golang:1.20.0"
+
+#GoAppTmpl: {
+	type: _goVer1220 | _goVer1200
+	run: string
+	ingress: #AppIngress
+
+	runConfiguration: [{
+		bin: "/usr/local/go/bin/go",
+		args: ["mod", "tidy"]
+	}, {
+		bin: "/usr/local/go/bin/go",
+		args: ["build", "-o", ".app", run]
+	}, {
+		bin: ".app",
+		args: []
+	}]
+}
+
+#GoApp1200: #GoAppTmpl & {
+	type: _goVer1200
+}
+
+#GoApp1220: #GoAppTmpl & {
+	type: _goVer1220
+}
+
+#GoApp: #GoApp1200 | #GoApp1220
+
+app: #GoApp
+
+// output
+
+_app: app
+ingress: {
+	app: {
+		network: networks[strings.ToLower(_app.ingress.network)]
+		subdomain: _app.ingress.subdomain
+		auth: _app.ingress.auth
+		service: {
+			name: "app-app"
+			port: name: "app"
+		}
+	}
+}
+
+images: {
+	app: {
+		repository: "giolekva"
+		name: "app-runner"
+		tag: strings.Replace(_app.type, ":", "-", -1)
+		pullPolicy: "Always"
+	}
+}
+
+charts: {
+	app: {
+		chart: "charts/app-runner"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.id
+		}
+	}
+}
+
+helm: {
+	app: {
+		chart: charts.app
+		values: {
+			image: {
+				repository: images.app.fullName
+				tag: images.app.tag
+				pullPolicy: images.app.pullPolicy
+			}
+			appPort: 8080
+			appDir: "/dodo-app"
+			repoAddr: input.repoAddr
+			sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
+			runCfg: base64.Encode(null, json.Marshal(_app.runConfiguration))
+			manager: "http://dodo-app.\(release.namespace).svc.cluster.local/register-worker"
+		}
+	}
+}
diff --git a/core/installer/soft/client.go b/core/installer/soft/client.go
index 269f3d3..08103be 100644
--- a/core/installer/soft/client.go
+++ b/core/installer/soft/client.go
@@ -19,6 +19,8 @@
 	"github.com/go-git/go-git/v5/storage/memory"
 )
 
+var ErrorAlreadyExists = errors.New("already exists")
+
 type Client interface {
 	Address() string
 	Signer() ssh.Signer
@@ -32,6 +34,7 @@
 	MakeUserAdmin(name string) error
 	AddReadWriteCollaborator(repo, user string) error
 	AddReadOnlyCollaborator(repo, user string) error
+	AddWebhook(repo, url string, opts ...string) error
 }
 
 type realClient struct {
@@ -131,6 +134,9 @@
 
 func (ss *realClient) AddRepository(name string) error {
 	log.Printf("Adding repository %s", name)
+	if err := ss.RunCommand("repo", "info", name); err == nil {
+		return ErrorAlreadyExists
+	}
 	return ss.RunCommand("repo", "create", name)
 }
 
@@ -144,6 +150,14 @@
 	return ss.RunCommand("repo", "collab", "add", repo, user, "read-only")
 }
 
+func (ss *realClient) AddWebhook(repo, url string, opts ...string) error {
+	log.Printf("Adding webhook %s %s", repo, url)
+	return ss.RunCommand(append(
+		[]string{"repo", "webhook", "create", repo, url},
+		opts...,
+	)...)
+}
+
 type Repository struct {
 	*git.Repository
 	Addr RepositoryAddress
diff --git a/core/installer/soft/repoio.go b/core/installer/soft/repoio.go
index 6a5097a..b916d24 100644
--- a/core/installer/soft/repoio.go
+++ b/core/installer/soft/repoio.go
@@ -3,10 +3,12 @@
 import (
 	"encoding/json"
 	"errors"
+	"fmt"
 	"io"
 	"io/fs"
 	"io/ioutil"
 	"net"
+	"os"
 	"path/filepath"
 	"sync"
 	"time"
@@ -16,6 +18,7 @@
 	"github.com/go-git/go-billy/v5"
 	"github.com/go-git/go-billy/v5/util"
 	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/config"
 	"github.com/go-git/go-git/v5/plumbing/object"
 	gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
 	"golang.org/x/crypto/ssh"
@@ -33,6 +36,8 @@
 
 type doOptions struct {
 	NoCommit bool
+	Force    bool
+	ToBranch string
 }
 
 type DoOption func(*doOptions)
@@ -43,11 +48,42 @@
 	}
 }
 
+func WithForce() DoOption {
+	return func(o *doOptions) {
+		o.Force = true
+	}
+}
+
+func WithCommitToBranch(branch string) DoOption {
+	return func(o *doOptions) {
+		o.ToBranch = branch
+	}
+}
+
+type pushOptions struct {
+	ToBranch string
+	Force    bool
+}
+
+type PushOption func(*pushOptions)
+
+func WithToBranch(branch string) PushOption {
+	return func(o *pushOptions) {
+		o.ToBranch = branch
+	}
+}
+
+func PushWithForce() PushOption {
+	return func(o *pushOptions) {
+		o.Force = true
+	}
+}
+
 type RepoIO interface {
 	RepoFS
 	FullAddress() string
 	Pull() error
-	CommitAndPush(message string) error
+	CommitAndPush(message string, opts ...PushOption) error
 	Do(op DoFn, opts ...DoOption) error
 }
 
@@ -120,8 +156,9 @@
 		return nil
 	}
 	err = wt.Pull(&git.PullOptions{
-		Auth:  auth(r.signer),
-		Force: true,
+		Auth:     auth(r.signer),
+		Force:    true,
+		Progress: os.Stdout,
 	})
 	if err == nil {
 		return nil
@@ -130,10 +167,15 @@
 		return nil
 	}
 	// TODO(gio): check `remote repository is empty`
+	fmt.Println(err)
 	return nil
 }
 
-func (r *repoIO) CommitAndPush(message string) error {
+func (r *repoIO) CommitAndPush(message string, opts ...PushOption) error {
+	var o pushOptions
+	for _, i := range opts {
+		i(&o)
+	}
 	wt, err := r.repo.Worktree()
 	if err != nil {
 		return err
@@ -149,10 +191,17 @@
 	}); err != nil {
 		return err
 	}
-	return r.repo.Push(&git.PushOptions{
+	gopts := &git.PushOptions{
 		RemoteName: "origin",
 		Auth:       auth(r.signer),
-	})
+	}
+	if o.ToBranch != "" {
+		gopts.RefSpecs = []config.RefSpec{config.RefSpec(fmt.Sprintf("refs/heads/master:refs/heads/%s", o.ToBranch))}
+	}
+	if o.Force {
+		gopts.Force = true
+	}
+	return r.repo.Push(gopts)
 }
 
 func (r *repoIO) Do(op DoFn, opts ...DoOption) error {
@@ -169,7 +218,14 @@
 		return err
 	} else {
 		if !o.NoCommit {
-			return r.CommitAndPush(msg)
+			popts := []PushOption{}
+			if o.Force {
+				popts = append(popts, PushWithForce())
+			}
+			if o.ToBranch != "" {
+				popts = append(popts, WithToBranch(o.ToBranch))
+			}
+			return r.CommitAndPush(msg, popts...)
 		}
 	}
 	return nil
@@ -248,3 +304,12 @@
 	}
 	return ret, nil
 }
+
+func ReadFile(repo RepoFS, path string) ([]byte, error) {
+	r, err := repo.Reader(path)
+	if err != nil {
+		return nil, err
+	}
+	defer r.Close()
+	return io.ReadAll(r)
+}
diff --git a/core/installer/testapp.cue b/core/installer/testapp.cue
new file mode 100644
index 0000000..2573c5c
--- /dev/null
+++ b/core/installer/testapp.cue
@@ -0,0 +1,12 @@
+app: {
+	type: "golang:1.22.0"
+	run: "main.go"
+	ingress: {
+		network: "private"
+		subdomain: "testapp"
+		auth: enabled: false
+	}
+}
+
+// do create app --type=go[1.22.0] [--run-cmd=(*default main.go)]
+// do create ingress --subdomain=testapp [--network=public (*default private)] [--auth] [--auth-groups="admin" (*default empty)] TODO(gio): port
diff --git a/core/installer/values-tmpl/dodo-app.cue b/core/installer/values-tmpl/dodo-app.cue
new file mode 100644
index 0000000..5acc6db
--- /dev/null
+++ b/core/installer/values-tmpl/dodo-app.cue
@@ -0,0 +1,161 @@
+import (
+	"encoding/base64"
+	"encoding/json"
+	"strings"
+)
+
+input: {
+	network: #Network @name(Network)
+	subdomain: string @name(Subdomain)
+	sshPort: int @name(SSH Port)
+	adminKey: string @name(Admin SSH Public Key)
+
+	// TODO(gio): auto generate
+	ssKeys: #SSHKey
+	fluxKeys: #SSHKey
+	dAppKeys: #SSHKey
+}
+
+name: "Dodo App"
+namespace: "dodo-app"
+readme: "Deploy app by pushing to Git repository"
+description: "Deploy app by pushing to Git repository"
+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='M2.837 27.257c3.363 2.45 11.566 3.523 12.546 1.4s.424-10.94.424-10.94s-1.763 1.192-2.302.147s.44-2.433 2.319-2.858c-1.96.05-2.221-.571-2.205-.93s.67-1.878 3.527-1.241c-1.6-.751-1.943-2.956 2.352-1.568c-1.421-.735-.36-2.825 1.649-.62c-.261-1.323 1.584-1.46 2.694.907M10.648 34.633a19 19 0 0 0-4.246.719'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M15.144 43.402c3.625-2.482 7.685-6.32 7.293-13.406s-1.6-6.368-.523-7.577s6.924-.99 10.712 3.353c.032-2.874-2.504-5.508-2.504-5.508a33 33 0 0 1 5.53.163c2.852.49 2.394 2.514 3.58 2.035s.971-3.472-.39-5.377c-1.666-2.33-3.223-2.83-6.358-2.188s-4.474.458-5.54-.587s-2.026-3.538-4.605-2.515c-2.935 1.164-4.398 2.438-3.767 5.04s2.34 4.558 2.972 6.844'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M22.001 16.552c-.925-.043-1.894.055-1.709 1.328'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M20.662 16.763c1.72 2.695 3.405 3.643 9.46 3.501'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M32.14 14.966c-1.223.879-2.18 3.781-2.496 5.307M23.1 14.908c.48 1.209 1.23.728 1.315.283a1.552 1.552 0 0 0-1.543-1.883m-.408 17.472c5.328 2.71 11.631.229 16.269-2.123c-1.176 4.572-5.911 5.585-8.916 6.107'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M29.099 37.115c4.376-.294 8.024-1.578 7.833-5.296'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M20.27 38.702c6.771 3.834 12.505.798 13.786-2.615'/><circle cx='24' cy='24' r='21.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/></svg>"
+_domain: "\(input.subdomain).\(input.network.domain)"
+
+images: {
+	softserve: {
+		repository: "charmcli"
+		name: "soft-serve"
+		tag: "v0.7.1"
+		pullPolicy: "IfNotPresent"
+	}
+	dodoApp: {
+		repository: "giolekva"
+		name: "pcloud-installer"
+		tag: "latest"
+		pullPolicy: "Always"
+	}
+}
+
+charts: {
+	softserve: {
+		chart: "charts/soft-serve"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.pcloudEnvName
+		}
+	}
+	dodoApp: {
+		chart: "charts/dodo-app"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.pcloudEnvName
+		}
+	}
+}
+
+portForward: [#PortForward & {
+	allocator: input.network.allocatePortAddr
+	sourcePort: input.sshPort
+	// TODO(gio): namespace part must be populated by app manager. Otherwise
+	// third-party app developer might point to a service from different namespace.
+	targetService: "\(release.namespace)/soft-serve"
+	targetPort: 22
+}]
+
+helm: {
+	softserve: {
+		chart: charts.softserve
+		values: {
+			serviceType: "ClusterIP"
+			addressPool: ""
+			reservedIP: ""
+			adminKey: strings.Join([input.adminKey, input.fluxKeys.public, input.dAppKeys.public], "\n")
+			privateKey: input.ssKeys.private
+			publicKey: input.ssKeys.public
+			ingress: {
+				enabled: false
+			}
+			image: {
+				repository: images.softserve.fullName
+				tag: images.softserve.tag
+				pullPolicy: images.softserve.pullPolicy
+			}
+		}
+	}
+	"dodo-app": {
+		chart: charts.dodoApp
+		values: {
+			image: {
+				repository: images.dodoApp.fullName
+				tag: images.dodoApp.tag
+				pullPolicy: images.dodoApp.pullPolicy
+			}
+			repoAddr: "soft-serve.\(release.namespace).svc.cluster.local:22"
+			sshPrivateKey: base64.Encode(null, input.dAppKeys.private)
+			self: "dodo-app.\(release.namespace).svc.cluster.local"
+			namespace: release.namespace
+			envConfig: base64.Encode(null, json.Marshal(global))
+		}
+	}
+}
+
+resources: {
+	"config-kustomization": {
+		apiVersion: "kustomize.toolkit.fluxcd.io/v1"
+		kind: "Kustomization"
+		metadata: {
+			name: "app"
+			namespace: release.namespace
+		}
+		spec: {
+			interval: "1m"
+			path: "./.dodo"
+			sourceRef: {
+				kind: "GitRepository"
+				name: "app"
+				namespace: release.namespace
+			}
+			prune: true
+		}
+	}
+	"config-secret": {
+		apiVersion: "v1"
+		kind: "Secret"
+		type: "Opaque"
+		metadata: {
+			name: "app"
+			namespace: release.namespace
+		}
+		data: {
+			identity: base64.Encode(null, input.fluxKeys.private)
+			"identity.pub": base64.Encode(null, input.fluxKeys.public)
+			known_hosts: base64.Encode(null, "soft-serve.\(release.namespace).svc.cluster.local \(input.ssKeys.public)")
+		}
+	}
+	"config-source": {
+		apiVersion: "source.toolkit.fluxcd.io/v1"
+		kind: "GitRepository"
+		metadata: {
+			name: "app"
+			namespace: release.namespace
+		}
+		spec: {
+			interval: "1m0s"
+			ref: branch: "dodo"
+			secretRef: name: "app"
+			timeout: "60s"
+			url: "ssh://soft-serve.\(release.namespace).svc.cluster.local:22/app"
+		}
+	}
+}
+
+help: [{
+	title: "How to use"
+	contents: """
+	Clone: git clone ssh://\(_domain):\(input.sshPort)/app  
+	"""
+}]
diff --git a/core/installer/values-tmpl/soft-serve.cue b/core/installer/values-tmpl/soft-serve.cue
index b99430a..1bae24f 100644
--- a/core/installer/values-tmpl/soft-serve.cue
+++ b/core/installer/values-tmpl/soft-serve.cue
@@ -5,7 +5,7 @@
 	adminKey: string @name(Admin SSH Public Key)
 }
 
-_domain: "\(input.subdomain).\(global.privateDomain)"
+_domain: "\(input.subdomain).\(input.network.domain)"
 
 name: "Soft-Serve"
 namespace: "app-soft-serve"
@@ -35,7 +35,7 @@
 }
 
 ingress: {
-	gerrit: {
+	gerrit: { // TODO(gio): rename to soft-serve
 		auth: enabled: false
 		network: input.network
 		subdomain: input.subdomain
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
new file mode 100644
index 0000000..5eb2f58
--- /dev/null
+++ b/core/installer/welcome/dodo_app.go
@@ -0,0 +1,122 @@
+package welcome
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
+)
+
+type DodoAppServer struct {
+	port      int
+	sshKey    string
+	client    soft.Client
+	namespace string
+	env       installer.EnvConfig
+	workers   map[string]struct{}
+}
+
+func NewDodoAppServer(
+	port int,
+	sshKey string,
+	client soft.Client,
+	namespace string,
+	env installer.EnvConfig,
+) *DodoAppServer {
+	return &DodoAppServer{
+		port,
+		sshKey,
+		client,
+		namespace,
+		env,
+		map[string]struct{}{},
+	}
+}
+
+func (s *DodoAppServer) Start() error {
+	http.HandleFunc("/update", s.handleUpdate)
+	http.HandleFunc("/register-worker", s.handleRegisterWorker)
+	return http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil)
+}
+
+type updateReq struct {
+	Ref string `json:"ref"`
+}
+
+func (s *DodoAppServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
+	fmt.Println("update")
+	var req updateReq
+	var contents strings.Builder
+	io.Copy(&contents, r.Body)
+	c := contents.String()
+	fmt.Println(c)
+	if err := json.NewDecoder(strings.NewReader(c)).Decode(&req); err != nil {
+		fmt.Println(err)
+		return
+	}
+	if req.Ref != "refs/heads/master" {
+		return
+	}
+	go func() {
+		time.Sleep(20 * time.Second)
+		if err := UpdateDodoApp(s.client, s.namespace, s.sshKey, &s.env); err != nil {
+			fmt.Println(err)
+		}
+	}()
+	for addr, _ := range s.workers {
+		go func() {
+			// TODO(gio): make port configurable
+			http.Get(fmt.Sprintf("http://%s:3000/update", addr))
+		}()
+	}
+}
+
+type registerWorkerReq struct {
+	Address string `json:"address"`
+}
+
+func (s *DodoAppServer) handleRegisterWorker(w http.ResponseWriter, r *http.Request) {
+	var req registerWorkerReq
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	s.workers[req.Address] = struct{}{}
+	fmt.Printf("registered worker: %s\n", req.Address)
+}
+
+func UpdateDodoApp(client soft.Client, namespace string, sshKey string, env *installer.EnvConfig) error {
+	repo, err := client.GetRepo("app")
+	if err != nil {
+		return err
+	}
+	nsCreator := installer.NewNoOpNamespaceCreator()
+	if err != nil {
+		return err
+	}
+	m, err := installer.NewAppManager(repo, nsCreator, "/.dodo")
+	if err != nil {
+		return err
+	}
+	appCfg, err := soft.ReadFile(repo, "app.cue")
+	fmt.Println(string(appCfg))
+	if err != nil {
+		return err
+	}
+	app, err := installer.NewDodoApp(appCfg)
+	if err != nil {
+		return err
+	}
+	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 {
+		return err
+	}
+	return nil
+}
diff --git a/core/installer/welcome/env_test.go b/core/installer/welcome/env_test.go
index e4cee83..0803e64 100644
--- a/core/installer/welcome/env_test.go
+++ b/core/installer/welcome/env_test.go
@@ -59,7 +59,7 @@
 	return nil
 }
 
-func (r mockRepoIO) CommitAndPush(message string) error {
+func (r mockRepoIO) CommitAndPush(message string, opts ...soft.PushOption) error {
 	r.t.Logf("Commit and push: %s", message)
 	return nil
 }
@@ -128,6 +128,10 @@
 	return nil
 }
 
+func (f fakeSoftServeClient) AddWebhook(repo, url string, opts ...string) error {
+	return nil
+}
+
 type fakeClientGetter struct {
 	t     *testing.T
 	envFS billy.Filesystem