diff --git a/Dockerfile b/Dockerfile
index 07d9826f9bcab938f729ec20c2427a4f70bb00f3..a2d942beb4a90171676ee5bab74f1afa5457f227 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.19 as build
+FROM golang:1.23 as build
 ADD . /src
 RUN cd /src && go build -tags netgo -o gitlab-review-float-env-dashboard ./main.go && strip gitlab-review-float-env-dashboard
 
diff --git a/debian/changelog b/debian/changelog
index d2792618ee127f550f0c313bae5529aefbcff149..d0ac3557b2a19cd506dc416851e0a2cf75e62215 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+gitlab-review-float-env-dashboard (0.2) unstable; urgency=medium
+
+  * Refactor template handling.
+
+ -- ale <ale@incal.net>  Thu, 09 Jan 2025 08:59:46 +0100
+
 gitlab-review-float-env-dashboard (0.1) unstable; urgency=medium
 
   * First release.
diff --git a/main.go b/main.go
index c50238a71b207bb9361aa6128bfc31008ceac103..c6f981abf075550397a0f718d1eb8585f3020dc3 100644
--- a/main.go
+++ b/main.go
@@ -10,21 +10,64 @@ import (
 	"io"
 	"log"
 	"net/http"
+	"os"
 )
 
 var (
-	addr        = flag.String("addr", ":3499", "address to listen on")
-	jumpHost    = flag.String("jump-host", "localhost", "SSH jump host")
-	hostname    = flag.String("hostname", "", "(deprecated) see --jump-host")
-	hostConnFmt = flag.String(
-		"host-ssh-format",
-		"ssh -o UserKnownHostsFile=/dev/null -i ~/.vagrant.d/insecure_private_key -J {{.JumpHost}} root@{{.Host.IP}}",
-		"format string for host-specific SSH connections")
-	enableSOCKS = flag.Bool("enable-socks", false, "show SOCKS5 connection params")
-	title       = flag.String("title", "VMine test cluster", "title string")
+	addr           = flag.String("addr", ":3499", "address to listen on")
+	gitlabURL      = flag.String("gitlab-url", "https://git.autistici.org", "Gitlab public URL")
+	title          = flag.String("title", "VMine test cluster", "title string")
+	contentTplFile = flag.String("template", "", "custom content template file, use default if empty")
 )
 
 var (
+	defaultContentTplSrc = `
+{{$jumpHost := "miscredenza.investici.org"}}
+{{$socksIP := (idx .Inventory 0).IP}}
+
+<h4>Hosts</h4>
+
+<p>
+  You can connect to individual hosts over SSH via the jump host <i>{{$jumpHost}}</i>.<br>
+  Download the required SSH private key here:
+  <a href="{{.GitlabURL}}/{{.ProjectName}}/-/jobs/{{.JobID}}/artifacts/browse/build-{{.JobID}}/ssh/vmkey">vmkey</a>.
+  Remember to <code>chmod 600</code> the private key file after you download it.
+</p>
+
+<table class="table">
+<tbody>
+{{range $index, $host := .Inventory}}
+<tr>
+  <td>
+   <div class="host">
+    <b class="name">{{$host.Name}}</b> {{$host.IP}}<br>
+    <span class="attrs">
+      {{$host.CPU}} cores, {{$host.RAM}}M ram
+    </span>
+   </div>
+  </td>
+  <td>
+    <pre>ssh -o UserKnownHostsFile=/dev/null -i vmkey -J {{$jumpHost}} root@{{$host.IP}}</pre>
+  </td>
+</tr>
+{{end}}
+</tbody>
+</table>
+
+<h4>HTTP connection (via SOCKS5 proxy)</h4>
+
+<p>
+  Forward a local port to the SOCKS5 proxy running on {{$socksIP}}, and run a
+  private browser that uses it:
+</p>
+
+<pre>ssh -nN -L 9051:{{$socksIP}}:9051 {{$jumpHost}} &
+chromium --user-data-dir=$(mktemp -d) --no-first-run --no-default-browser-check \
+  --proxy-server=socks5://localhost:9051 https://...
+</pre>
+`
+	defaultContentTpl = template.Must(template.New("").Parse(defaultContentTplSrc))
+
 	dashTplSrc = `<!doctype html>
 <html lang="en">
 <head>
@@ -44,64 +87,28 @@ body { background: white; }
 
   <h1>{{.Title}}</h1>
 
-  <h4>Hosts</h4>
-
-  <p>
-    You can connect to individual hosts over SSH via the jump host <i>{{.JumpHost}}</i>.<br>
-    Download the required SSH private key here:
-    <a href="https://github.com/hashicorp/vagrant/blob/main/keys/vagrant">Vagrant default insecure private key</a>,
-    if you do not already have it, and save it to <i>~/.vagrant.d/insecure_private_key</i>.
-  </p>
-
-  <table class="table">
-  <tbody>
-  {{range $index, $host := .Inventory}}
-  <tr>
-    <td>
-     <div class="host">
-      <b class="name">{{$host.Name}}</b> {{$host.IP}}<br>
-      <span class="attrs">
-        {{$host.CPU}} cores, {{$host.RAM}}M ram
-      </span>
-     </div>
-    </td>
-    <td>
-      <pre>{{hostConn $host}}</pre>
-    </td>
-  </tr>
-  {{end}}
-  </tbody>
-  </table>
-
-  {{if .EnableSOCKS}}
-  <h4>HTTP connection (via SOCKS5 proxy)</h4>
-
-  <p>
-    Forward a local port to the SOCKS5 proxy running on {{.SocksIP}}, and run a
-    private browser that uses it:
-  </p>
-
-  <pre>ssh -nN -L 9051:{{.SocksIP}}:9051 {{.JumpHost}} &
-chromium --user-data-dir=$(mktemp -d) --no-first-run --no-default-browser-check \
-    --proxy-server=socks5://localhost:9051 https://...
-</pre>
-  {{end}}
+  {{.Content}}
+
 </body>
 </html>`
 
-	dashTpl = template.Must(template.New("").Funcs(map[string]interface{}{
-		"hostConn": hostConnString,
-	}).Parse(dashTplSrc))
-	hostConnTpl *template.Template
+	dashTpl = template.Must(template.New("").Parse(dashTplSrc))
+
+	contentTpl *template.Template
 )
 
-func hostConnString(host *hostinfo) string {
-	var buf bytes.Buffer
-	hostConnTpl.Execute(&buf, map[string]interface{}{
-		"JumpHost": *jumpHost,
-		"Host":     host,
-	})
-	return buf.String()
+func parseTemplate() error {
+	if *contentTplFile == "" {
+		contentTpl = defaultContentTpl
+		return nil
+	}
+
+	data, err := os.ReadFile(*contentTplFile)
+	if err != nil {
+		return err
+	}
+	contentTpl, err = template.New("").Parse(string(data))
+	return err
 }
 
 type hostinfo struct {
@@ -112,6 +119,12 @@ type hostinfo struct {
 	IP    string  `json:"ip"`
 }
 
+type payload struct {
+	Inventory   []*hostinfo `json:"inv"`
+	JobID       string      `json:"job"`
+	ProjectName string      `json:"proj"`
+}
+
 func decompress(input []byte) ([]byte, error) {
 	var out bytes.Buffer
 	r := flate.NewReader(bytes.NewReader(input))
@@ -120,7 +133,7 @@ func decompress(input []byte) ([]byte, error) {
 	return out.Bytes(), err
 }
 
-func decodePayload(encoded string) ([]*hostinfo, error) {
+func decodePayload(encoded string) (*payload, error) {
 	compressed, err := base64.URLEncoding.DecodeString(encoded)
 	if err != nil {
 		return nil, err
@@ -131,15 +144,18 @@ func decodePayload(encoded string) ([]*hostinfo, error) {
 		return nil, err
 	}
 
-	var inventory []*hostinfo
-	err = json.Unmarshal(decompressed, &inventory)
-	return inventory, err
+	var p payload
+	if err := json.Unmarshal(decompressed, &p); err != nil {
+		return nil, err
+	}
+
+	return &p, err
 }
 
 func handleRequest(w http.ResponseWriter, req *http.Request) {
-	inventory, err := decodePayload(req.URL.Path)
+	payload, err := decodePayload(req.URL.Path)
 	if err != nil {
-		log.Printf("error decoding payload: %s", err)
+		log.Printf("error decoding payload: %v", err)
 		http.Error(w, "Bad Request", http.StatusBadRequest)
 		return
 	}
@@ -147,19 +163,33 @@ func handleRequest(w http.ResponseWriter, req *http.Request) {
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 	w.Header().Set("Cache-Control", "no-store")
 
-	// nolint: errcheck
-	dashTpl.Execute(w, struct {
+	// Render internal template.
+	var buf bytes.Buffer
+	if err := contentTpl.Execute(&buf, struct {
 		Inventory   []*hostinfo
-		SocksIP     string
-		JumpHost    string
-		EnableSOCKS bool
+		ProjectName string
+		JobID       string
 		Title       string
+		GitlabURL   string
 	}{
-		Inventory:   inventory,
-		SocksIP:     inventory[0].IP,
-		JumpHost:    *jumpHost,
-		EnableSOCKS: *enableSOCKS,
+		Inventory:   payload.Inventory,
+		ProjectName: payload.ProjectName,
+		JobID:       payload.JobID,
 		Title:       *title,
+		GitlabURL:   *gitlabURL,
+	}); err != nil {
+		log.Printf("template error: %v", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// nolint: errcheck
+	dashTpl.Execute(w, struct {
+		Title   string
+		Content template.HTML
+	}{
+		Title:   *title,
+		Content: template.HTML(buf.String()),
 	})
 }
 
@@ -167,14 +197,8 @@ func main() {
 	log.SetFlags(0)
 	flag.Parse()
 
-	if *hostname != "" {
-		*jumpHost = *hostname
-	}
-
-	var err error
-	hostConnTpl, err = template.New("hostconn").Parse(*hostConnFmt)
-	if err != nil {
-		log.Fatalf("error in host connection template: %v", err)
+	if err := parseTemplate(); err != nil {
+		log.Fatalf("template error: %v", err)
 	}
 
 	http.Handle("/dash/", http.StripPrefix("/dash/", http.HandlerFunc(handleRequest)))