diff --git a/cmd/redirectord/redirectord.go b/cmd/redirectord/redirectord.go
index 352f166a846b0785569111e1dceb89f2f287b719..ccec7671798eba0a197a2c2e5723be439d41f5b6 100644
--- a/cmd/redirectord/redirectord.go
+++ b/cmd/redirectord/redirectord.go
@@ -4,8 +4,6 @@ import (
 	"flag"
 	"fmt"
 	"log"
-	"net/http"
-	"time"
 
 	"git.autistici.org/ale/radioai"
 	"git.autistici.org/ale/radioai/fe"
@@ -17,6 +15,9 @@ var (
 	httpPort = flag.Int("http-port", 80, "HTTP port")
 	publicIp = flag.String("ip", "127.0.0.1", "Public IP for this machine")
 
+	staticDir = flag.String("static-dir", "./static", "Static content directory")
+	templateDir = flag.String("template-dir", "./templates", "HTML templates directory")
+
 	// Default DNS TTL (seconds).
 	dnsTtl = 5
 )
@@ -30,18 +31,10 @@ func main() {
 
 	client := radioai.NewEtcdClient()
 	api := radioai.NewRadioAPI(client)
-	red := fe.NewHttpRedirector(api)
 
 	dnsRed := fe.NewDnsRedirector(api, *domain, *publicIp, dnsTtl)
 	dnsRed.Run(fmt.Sprintf(":%d", *dnsPort))
 
-	httpServer := &http.Server{
-		Addr: fmt.Sprintf(":%d", *httpPort),
-		Handler: red,
-		ReadTimeout: 10 * time.Second,
-		WriteTimeout: 10 * time.Second,
-	}
-
-	log.Printf("starting HTTP server on %s/tcp", httpServer.Addr)
-	log.Fatal(httpServer.ListenAndServe())
+	red := fe.NewHttpRedirector(api, *domain)
+	red.Run(fmt.Sprintf(":%d", *httpPort), *staticDir, *templateDir)
 }
diff --git a/fe/gzip.go b/fe/gzip.go
new file mode 100644
index 0000000000000000000000000000000000000000..9513dbb50d05a92cc5d8be72a9a4d3a03e4f7b4a
--- /dev/null
+++ b/fe/gzip.go
@@ -0,0 +1,205 @@
+package fe
+
+import (
+	"compress/gzip"
+	"io"
+	"net/http"
+	"strings"
+)
+
+// Slightly modified by ale@incal.net, based on:
+// https://github.com/PuerkitoBio/ghost
+
+// Thanks to Andrew Gerrand for inspiration:
+// https://groups.google.com/d/msg/golang-nuts/eVnTcMwNVjM/4vYU8id9Q2UJ
+//
+// Also, node's Connect library implementation of the compress middleware:
+// https://github.com/senchalabs/connect/blob/master/lib/middleware/compress.js
+//
+// And StackOverflow's explanation of Vary: Accept-Encoding header:
+// http://stackoverflow.com/questions/7848796/what-does-varyaccept-encoding-mean
+
+// Internal gzipped writer that satisfies both the (body) writer in gzipped format,
+// and maintains the rest of the ResponseWriter interface for header manipulation.
+type gzipResponseWriter struct {
+	io.Writer
+	http.ResponseWriter
+	r        *http.Request // Keep a hold of the Request, for the filter function
+	filtered bool          // Has the request been run through the filter function?
+	dogzip   bool          // Should we do GZIP compression for this request?
+	filterFn func(http.ResponseWriter, *http.Request) bool
+}
+
+// Make sure the filter function is applied.
+func (w *gzipResponseWriter) applyFilter() {
+	if !w.filtered {
+		if w.dogzip = w.filterFn(w, w.r); w.dogzip {
+			setGzipHeaders(w.Header())
+		}
+		w.filtered = true
+	}
+}
+
+// Unambiguous Write() implementation (otherwise both ResponseWriter and Writer
+// want to claim this method).
+func (w *gzipResponseWriter) Write(b []byte) (int, error) {
+	w.applyFilter()
+	if w.dogzip {
+		// Write compressed
+		return w.Writer.Write(b)
+	}
+	// Write uncompressed
+	return w.ResponseWriter.Write(b)
+}
+
+// Intercept the WriteHeader call to correctly set the GZIP headers.
+func (w *gzipResponseWriter) WriteHeader(code int) {
+	w.applyFilter()
+	w.ResponseWriter.WriteHeader(code)
+}
+
+// Implement WrapWriter interface
+func (w *gzipResponseWriter) WrappedWriter() http.ResponseWriter {
+	return w.ResponseWriter
+}
+
+var (
+	defaultFilterTypes = [...]string{
+		"text/",
+		"javascript",
+		"json",
+	}
+)
+
+// Default filter to check if the response should be GZIPped.
+// By default, all text (html, css, xml, ...), javascript and json
+// content types are candidates for GZIP.
+func defaultFilter(w http.ResponseWriter, r *http.Request) bool {
+	hdr := w.Header()
+	for _, tp := range defaultFilterTypes {
+		ok := headerMatch(hdr, "Content-Type", tp)
+		if ok {
+			return true
+		}
+	}
+	return false
+}
+
+// GZIPHandlerFunc is the same as GZIPHandler, it is just a convenience
+// signature that accepts a func(http.ResponseWriter, *http.Request) instead of
+// a http.Handler interface. It saves the boilerplate http.HandlerFunc() cast.
+func GZIPHandlerFunc(h http.HandlerFunc, filterFn func(http.ResponseWriter, *http.Request) bool) http.HandlerFunc {
+	return GZIPHandler(h, filterFn)
+}
+
+// Gzip compression HTTP handler. If the client supports it, it compresses the response
+// written by the wrapped handler. The filter function is called when the response is about
+// to be written to determine if compression should be applied. If this argument is nil,
+// the default filter will GZIP only content types containing /json|text|javascript/.
+func GZIPHandler(h http.Handler, filterFn func(http.ResponseWriter, *http.Request) bool) http.HandlerFunc {
+	if filterFn == nil {
+		filterFn = defaultFilter
+	}
+	return func(w http.ResponseWriter, r *http.Request) {
+		if _, ok := getGzipWriter(w); ok {
+			// Self-awareness, gzip handler is already set up
+			h.ServeHTTP(w, r)
+			return
+		}
+		hdr := w.Header()
+		setVaryHeader(hdr)
+
+		// Do nothing on a HEAD request
+		if r.Method == "HEAD" {
+			h.ServeHTTP(w, r)
+			return
+		}
+		if !acceptsGzip(r.Header) {
+			// No gzip support from the client, return uncompressed
+			h.ServeHTTP(w, r)
+			return
+		}
+
+		// Prepare a gzip response container
+		gz := gzip.NewWriter(w)
+		gzw := &gzipResponseWriter{
+			Writer:         gz,
+			ResponseWriter: w,
+			r:              r,
+			filterFn:       filterFn,
+		}
+		h.ServeHTTP(gzw, r)
+		// Iff the handler completed successfully (no panic) and GZIP was indeed used, close the gzip writer,
+		// which seems to generate a Write to the underlying writer.
+		if gzw.dogzip {
+			gz.Close()
+		}
+	}
+}
+
+// Add the vary by "accept-encoding" header if it is not already set.
+func setVaryHeader(hdr http.Header) {
+	if !headerMatch(hdr, "Vary", "accept-encoding") {
+		hdr.Add("Vary", "Accept-Encoding")
+	}
+}
+
+// Checks if the client accepts GZIP-encoded responses.
+func acceptsGzip(hdr http.Header) bool {
+	ok := headerMatch(hdr, "Accept-Encoding", "gzip")
+	if !ok {
+		ok = headerEquals(hdr, "Accept-Encoding", "*")
+	}
+	return ok
+}
+
+func setGzipHeaders(hdr http.Header) {
+	// The content-type will be explicitly set somewhere down the path of handlers
+	hdr.Set("Content-Encoding", "gzip")
+	hdr.Del("Content-Length")
+}
+
+// Helper function to retrieve the gzip writer.
+func getGzipWriter(w http.ResponseWriter) (*gzipResponseWriter, bool) {
+	gz, ok := GetResponseWriter(w, func(tst http.ResponseWriter) bool {
+		_, ok := tst.(*gzipResponseWriter)
+		return ok
+	})
+	if ok {
+		return gz.(*gzipResponseWriter), true
+	}
+	return nil, false
+}
+
+func headerMatch(hdr http.Header, name, s string) bool {
+	return strings.Contains(hdr.Get(name), s)
+}
+
+func headerEquals(hdr http.Header, name, s string) bool {
+	return hdr.Get(name) == s
+}
+
+// This interface can be implemented by an augmented ResponseWriter, so that
+// it doesn't hide other augmented writers in the chain.
+type WrapWriter interface {
+        http.ResponseWriter
+        WrappedWriter() http.ResponseWriter
+}
+
+// Helper function to retrieve a specific ResponseWriter.
+func GetResponseWriter(w http.ResponseWriter,
+        predicate func(http.ResponseWriter) bool) (http.ResponseWriter, bool) {
+
+        for {
+                // Check if this writer is the one we're looking for
+                if w != nil && predicate(w) {
+                        return w, true
+                }
+                // If it is a WrapWriter, move back the chain of wrapped writers
+                ww, ok := w.(WrapWriter)
+                if !ok {
+                        return nil, false
+                }
+                w = ww.WrappedWriter()
+        }
+}
diff --git a/fe/http.go b/fe/http.go
index 6a8b07a44eada84890c87f088fdd3968b909cd7f..cc72036dfa46b79d444dbe3731a23c4e916257ba 100644
--- a/fe/http.go
+++ b/fe/http.go
@@ -1,11 +1,15 @@
 package fe
 
 import (
+	"bytes"
 	"fmt"
+	"html/template"
 	"io"
+	"log"
 	"math/rand"
 	"net/http"
 	"net/http/httputil"
+	"path/filepath"
 	"strconv"
 	"strings"
 	"time"
@@ -23,12 +27,15 @@ import (
 // a .m3u file directly pointing at the relays.
 //
 type HttpRedirector struct {
-	client    *radioai.RadioAPI
+	domain   string
+	client   *radioai.RadioAPI
+	template *template.Template
 }
 
-func NewHttpRedirector(client *radioai.RadioAPI) *HttpRedirector {
+func NewHttpRedirector(client *radioai.RadioAPI, domain string) *HttpRedirector {
 	return &HttpRedirector{
 		client: client,
+		domain: domain,
 	}
 }
 
@@ -97,10 +104,55 @@ func (h *HttpRedirector) serveSource(w http.ResponseWriter, r *http.Request) {
 	proxy.ServeHTTP(w, r)
 }
 
+func (h *HttpRedirector) serveStatusPage(w http.ResponseWriter, r *http.Request) {
+	nodes, _ := h.client.GetNodes()
+	mounts, _ := h.client.ListMounts()
+	ctx := struct {
+		Domain string
+		Nodes  []string
+		Mounts []*radioai.Mount
+	}{h.domain, nodes, mounts}
+
+	var buf bytes.Buffer
+	if err := h.template.ExecuteTemplate(&buf, "index.html", ctx); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Write(buf.Bytes())
+}
+
 func (h *HttpRedirector) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	if r.Method == "SOURCE" {
+	if r.URL.Path == "" || r.URL.Path == "/" {
+		h.serveStatusPage(w, r)
+	} else if r.Method == "SOURCE" {
 		h.serveSource(w, r)
 	} else {
 		h.serveRelay(w, r)
 	}
 }
+
+func (h *HttpRedirector) Run(addr, staticDir, templateDir string) {
+	h.template = template.Must(
+		template.ParseGlob(
+			filepath.Join(templateDir, "*.html")))
+
+	mux := http.NewServeMux()
+	mux.HandleFunc(
+		"/static/",
+		GZIPHandler(
+			http.StripPrefix(
+				"/static/",
+				http.FileServer(http.Dir(staticDir))),
+			nil))
+	mux.Handle("/", h)
+
+	httpServer := &http.Server{
+		Addr:         addr,
+		Handler:      mux,
+		ReadTimeout:  10 * time.Second,
+		WriteTimeout: 10 * time.Second,
+	}
+
+	log.Printf("starting HTTP server on %s/tcp", httpServer.Addr)
+	log.Fatal(httpServer.ListenAndServe())
+}
diff --git a/fe/static/fullbg.js b/fe/static/fullbg.js
new file mode 100644
index 0000000000000000000000000000000000000000..f038380330689e829f7d77b3fe2565452144492c
--- /dev/null
+++ b/fe/static/fullbg.js
@@ -0,0 +1,50 @@
+/**
+ * jQuery.fullBg
+ * Version 1.0
+ * Copyright (c) 2010 c.bavota - http://bavotasan.com
+ * Dual licensed under MIT and GPL.
+ * Date: 02/23/2010
+**/
+(function($) {
+  $.fn.fullBg = function(){
+    var bgImg = $(this);		
+    
+    function resizeImg() {
+      var imgwidth = bgImg.width();
+      var imgheight = bgImg.height();
+
+      var winwidth = $(window).width();
+      var winheight = $(window).height();
+
+      var widthratio = winwidth / imgwidth;
+      var heightratio = winheight / imgheight;
+
+      var widthdiff = heightratio * imgwidth;
+      var heightdiff = widthratio * imgheight;
+
+      var newwidth, newheight;
+      if(heightdiff>winheight) {
+          newwidth = winwidth;
+          newheight = heightdiff;
+      } else {
+          newwidth = widthdiff;
+          newheight = winheight;
+      }
+      var xoffset = (newwidth - winwidth) / 2,
+        yoffset = (newheight - winheight) / 2;
+
+      bgImg.css({
+          width: newwidth+'px',
+          height: newheight+'px',
+          top: '-'+yoffset+'px',
+          left: '-'+xoffset+'px'
+      });
+    } 
+    resizeImg();
+    $(window).resize(function() {
+      resizeImg();
+    });
+
+    return this;
+  };
+})(jQuery)
diff --git a/fe/static/radiomast_bw.jpg b/fe/static/radiomast_bw.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..d6d52f22f58821f24e350832680a128db004fc43
Binary files /dev/null and b/fe/static/radiomast_bw.jpg differ
diff --git a/fe/static/style.css b/fe/static/style.css
new file mode 100644
index 0000000000000000000000000000000000000000..6a2dbdaa75cdc874edb07a6e44947492749bb0b7
--- /dev/null
+++ b/fe/static/style.css
@@ -0,0 +1,74 @@
+/* Space out content a bit */
+body {
+  padding-top: 80px;
+  padding-bottom: 20px;
+}
+
+/* Everything gets side spacing for mobile first views */
+.header,
+.mainitems,
+.footer {
+  padding-left: 15px;
+  padding-right: 15px;
+}
+
+/* Custom page header */
+.header {
+  border-bottom: 1px solid #e5e5e5;
+}
+/* Make the masthead heading the same height as the navigation */
+.header h3 {
+  margin-top: 0;
+  margin-bottom: 0;
+  line-height: 40px;
+  padding-bottom: 19px;
+}
+
+/* Custom page footer */
+.footer {
+  padding-top: 19px;
+  color: #777;
+  border-top: 1px solid #e5e5e5;
+}
+
+/* Customize container */
+@media (min-width: 768px) {
+  .container {
+    max-width: 730px;
+  }
+}
+.container-narrow > hr {
+  margin: 30px 0;
+}
+
+.mainitems {
+  margin: 40px 0;
+}
+.mainitems p + h4 {
+  margin-top: 28px;
+}
+
+/* Responsive: Portrait tablets and up */
+@media screen and (min-width: 768px) {
+  /* Remove the padding we set earlier */
+  .header,
+  .mainitems,
+  .footer {
+    padding-left: 0;
+    padding-right: 0;
+  }
+  /* Space out the masthead */
+  .header {
+    margin-bottom: 30px;
+  }
+}
+
+#bgImg {
+  display: none;
+  position: fixed;
+  top: 0;
+  left: 0;
+  overflow: hidden;
+  z-index: -100;
+  opacity: 0.3;
+}
\ No newline at end of file
diff --git a/fe/templates/index.html b/fe/templates/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..973fc678b0da52d51b8e26b9bae649fdb12654f3
--- /dev/null
+++ b/fe/templates/index.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>stream.{{.Domain}}</title>
+    <link rel="stylesheet"
+          href="//netdna.bootstrapcdn.com/bootstrap/3.0.2/css/bootstrap.min.css">
+    <link rel="stylesheet" href="/static/style.css">
+    <link rel="shortcut icon" href="/static/favicon.png">
+
+    <script src="//ajax.googleapis.com/ajax/libs/jquery/2.0.3/jquery.min.js"></script>
+    <script src="/static/fullbg.js"></script>
+  </head>
+  <body>
+
+    <div class="container">
+      <div class="page-header">
+        <h1>{{.Domain}}
+          <small>
+            streaming network
+          </small>
+        </h1>
+      </div>
+
+      <div class="row mainitems">
+        <div class="col-lg-6">
+          <h4>Streams</h4>
+          {{$domain := .Domain}}
+          {{range .Mounts}}
+          <p>
+            <a href="http://stream.{{$domain}}{{.Name}}.m3u">{{.Name}}</a>
+          </p>
+          {{end}}
+        </div>
+
+        <div class="col-lg-6">
+          <h4>Nodes</h4>
+          {{range .Nodes}}
+          <p>{{.}}</p>
+          {{end}}
+        </div>
+      </div>
+
+      <div class="footer">
+        powered by
+        <a href="https://git.autistici.org/public/ale/radioai">
+          radioai 0.1
+        </a>
+      </div>
+    </div>
+
+    <img src="/static/radiomast_bw.jpg" id="bgImg">
+
+    <script type="text/javascript">
+      $(function() {
+        $('#bgImg').fullBg().show();
+      });
+    </script>
+  </body>
+</html>