diff --git a/Makefile b/Makefile
index c5bb237a185e88e176e82476ed5c1345928eb799..82b266a66826cdd08bff1d6cfdd532323931948a 100644
--- a/Makefile
+++ b/Makefile
@@ -24,7 +24,6 @@ build: bin/dex
 
 bin/dex:
 	@mkdir -p bin/
-	@go generate ./...
 	@go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex
 
 examples: bin/grpc-client bin/example-app
diff --git a/cmd/dex/config_test.go b/cmd/dex/config_test.go
index c62d18b56be423671c7f0773beb8eac79fe0dd50..8ee02d5aa253089208aa02babcd04316717ac600 100644
--- a/cmd/dex/config_test.go
+++ b/cmd/dex/config_test.go
@@ -74,7 +74,7 @@ web:
   http: 127.0.0.1:5556
 
 frontend:
-  dir: /web
+  dir: ./web
   extra:
     foo: bar
 
@@ -144,7 +144,7 @@ logger:
 			HTTP: "127.0.0.1:5556",
 		},
 		Frontend: server.WebConfig{
-			Dir: "/web",
+			Dir: "./web",
 			Extra: map[string]string{
 				"foo": "bar",
 			},
@@ -274,6 +274,7 @@ web:
   http: 127.0.0.1:5556
 
 frontend:
+  dir: ./web
   extra:
     foo: bar
 
@@ -351,7 +352,7 @@ logger:
 			HTTP: "127.0.0.1:5556",
 		},
 		Frontend: server.WebConfig{
-			Dir: "/web",
+			Dir: "./web",
 			Extra: map[string]string{
 				"foo": "bar",
 			},
diff --git a/cmd/dex/main.go b/cmd/dex/main.go
index 978bcea12de24418e95ea31be0dd1442a4c296a2..be334d92072faa14acffda9bc43504604858ecd5 100644
--- a/cmd/dex/main.go
+++ b/cmd/dex/main.go
@@ -1,5 +1,3 @@
-//go:generate pkger
-
 package main
 
 import (
diff --git a/go.mod b/go.mod
index 6fd434c6d9b0549dce86cccf81de9103951aa535..6291284cca8c2b2419f5eb53a8340c1b8a861661 100644
--- a/go.mod
+++ b/go.mod
@@ -17,7 +17,6 @@ require (
 	github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0
 	github.com/kylelemons/godebug v1.1.0
 	github.com/lib/pq v1.10.0
-	github.com/markbates/pkger v0.17.1
 	github.com/mattermost/xml-roundtrip-validator v0.0.0-20201219040909-8fd2afad43d1
 	github.com/mattn/go-sqlite3 v1.14.6
 	github.com/oklog/run v1.1.0
diff --git a/go.sum b/go.sum
index 36971d116be10e576e90cfc85880be859afbfc92..3e624f53f14c35d9c78c724db7b8be5a008af141 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,5 @@
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo=
 cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
 cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
 cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
@@ -15,7 +14,6 @@ cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiy
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/AppsFlyer/go-sundheit v0.3.1 h1:Zqnr3wV3WQmXonc234k9XZAoV2KHUHw3osR5k2iHQZE=
 github.com/AppsFlyer/go-sundheit v0.3.1/go.mod h1:iZ8zWMS7idcvmqewf5mEymWWgoOiG/0WD4+aeh+heX4=
-github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
@@ -42,9 +40,7 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa h1:OaNxuTZr7kxeODyLWsRMC+OD03aFUH+mW6r2d+MWa5Y=
 github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
-github.com/coreos/bbolt v1.3.2 h1:wZwiHHUieZCquLkDL0B8UhzreNWsPHooDAG3q34zk0s=
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
-github.com/coreos/etcd v3.3.13+incompatible h1:8F3hqu9fGYLBifCmRCJsicFqDx/D68Rt3q1JMazcgBQ=
 github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 github.com/coreos/go-oidc/v3 v3.0.0 h1:/mAA0XMgYJw2Uqm7WKGCsKnjitE/+A0FFbOmiRJm7LQ=
 github.com/coreos/go-oidc/v3 v3.0.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo=
@@ -74,7 +70,6 @@ github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8S
 github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
-github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -86,8 +81,6 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
 github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/gobuffalo/here v0.6.0 h1:hYrd0a6gDmWxBM4TnrGw8mQg24iSVoIkHEk7FodQcBI=
-github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
 github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls=
@@ -189,8 +182,6 @@ github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+
 github.com/lib/pq v1.10.0 h1:Zx5DJFEYQXio93kgXnQ09fXNiUKsqv4OUEu2UtGcB1E=
 github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno=
-github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
 github.com/mattermost/xml-roundtrip-validator v0.0.0-20201219040909-8fd2afad43d1 h1:x37Q11fexMtlhecRnkdzLL6dgnS1NF1nzAJ1vic22BY=
 github.com/mattermost/xml-roundtrip-validator v0.0.0-20201219040909-8fd2afad43d1/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@@ -487,7 +478,6 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
diff --git a/server/handlers_test.go b/server/handlers_test.go
index d52a45c016d29f0c7990827cd828d0115a985d55..d195af64b41d9f4829533f1afed38b2582bdca93 100644
--- a/server/handlers_test.go
+++ b/server/handlers_test.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"net/http"
 	"net/http/httptest"
+	"os"
 	"testing"
 	"time"
 
@@ -135,7 +136,7 @@ func TestHandleInvalidSAMLCallbacks(t *testing.T) {
 func TestConnectorLoginDoesNotAllowToChangeConnectorForAuthRequest(t *testing.T) {
 	memStorage := memory.New(logger)
 
-	templates, err := loadTemplates(WebConfig{Dir: "/web"}, "/web/templates")
+	templates, err := loadTemplates(webConfig{webFS: os.DirFS("../web")}, "templates")
 	if err != nil {
 		t.Fatal("failed to load templates")
 	}
diff --git a/server/server.go b/server/server.go
index df81a253e6d6a9ca0a8827ee7a21b72fd2449267..46b237716945992d3815fab47bf820d7ddcc8f7f 100644
--- a/server/server.go
+++ b/server/server.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io/fs"
 	"net/http"
 	"net/url"
 	"path"
@@ -19,7 +20,6 @@ import (
 	"github.com/felixge/httpsnoop"
 	"github.com/gorilla/handlers"
 	"github.com/gorilla/mux"
-	"github.com/markbates/pkger"
 	"github.com/prometheus/client_golang/prometheus"
 	"golang.org/x/crypto/bcrypt"
 
@@ -101,7 +101,7 @@ type Config struct {
 
 // WebConfig holds the server's frontend templates and asset configuration.
 type WebConfig struct {
-	// A filepath to web static.
+	// A file path to web static. If set, WebFS will be ignored.
 	//
 	// It is expected to contain the following directories:
 	//
@@ -111,6 +111,10 @@ type WebConfig struct {
 	//
 	Dir string
 
+	// A file system includes web static. Will be overwritten by Dir
+	// It is expected to contain the directories as Dir.
+	WebFS fs.FS
+
 	// Defaults to "( issuer URL )/theme/logo.png"
 	LogoURL string
 
@@ -122,9 +126,6 @@ type WebConfig struct {
 
 	// Map of extra values passed into the templates
 	Extra map[string]string
-
-	// Defaults to issuer URL
-	HostURL string
 }
 
 func value(val, defaultValue time.Duration) time.Duration {
@@ -207,13 +208,19 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
 		supported[respType] = true
 	}
 
-	if c.Web.Dir == "" {
-		c.Web.Dir = pkger.Include("/web")
+	web := webConfig{
+		dir:       c.Web.Dir,
+		webFS:     c.Web.WebFS,
+		logoURL:   c.Web.LogoURL,
+		issuerURL: c.Issuer,
+		issuer:    c.Web.Issuer,
+		theme:     c.Web.Theme,
+		extra:     c.Web.Extra,
 	}
 
-	tmpls, err := loadTemplates(c.Web, issuerURL.Path)
+	static, theme, tmpls, err := loadWebConfig(web)
 	if err != nil {
-		return nil, fmt.Errorf("server: failed to load templates: %v", err)
+		return nil, fmt.Errorf("server: failed to load web static: %v", err)
 	}
 
 	now := c.Now
@@ -343,11 +350,8 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
 		fmt.Fprintf(w, "Health check passed")
 	}))
 
-	staticDir := path.Join(c.Web.Dir, "static")
-	themeDir := path.Join(c.Web.Dir, "themes", c.Web.Theme)
-	handlePrefix("/static", http.FileServer(pkger.Dir(staticDir)))
-	handlePrefix(path.Join("/themes", c.Web.Theme), http.FileServer(pkger.Dir(themeDir)))
-
+	handlePrefix("/static", static)
+	handlePrefix("/theme", theme)
 	s.mux = r
 
 	s.startKeyRotation(ctx, rotationStrategy, now)
diff --git a/server/server_test.go b/server/server_test.go
index 03bedbe60f33da608814474f4a28ab800678fd17..87ca6c171e0f78dc0366181cf596bd249f322acc 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -93,7 +93,7 @@ func newTestServer(ctx context.Context, t *testing.T, updateConfig func(c *Confi
 		Issuer:  s.URL,
 		Storage: memory.New(logger),
 		Web: WebConfig{
-			Dir: "/web",
+			Dir: "../web",
 		},
 		Logger:             logger,
 		PrometheusRegistry: prometheus.NewRegistry(),
@@ -132,7 +132,7 @@ func newTestServerMultipleConnectors(ctx context.Context, t *testing.T, updateCo
 		Issuer:  s.URL,
 		Storage: memory.New(logger),
 		Web: WebConfig{
-			Dir: "/web",
+			Dir: "../web",
 		},
 		Logger:             logger,
 		PrometheusRegistry: prometheus.NewRegistry(),
diff --git a/server/templates.go b/server/templates.go
index 49f70d1dd009f42cc051bd863980247fbd632a4f..0d191644278c6f1863e7aa375fe803e207d1c46a 100644
--- a/server/templates.go
+++ b/server/templates.go
@@ -1,17 +1,17 @@
 package server
 
 import (
-	"bytes"
 	"fmt"
 	"html/template"
 	"io"
+	"io/fs"
 	"net/http"
+	"net/url"
+	"os"
 	"path"
 	"path/filepath"
 	"sort"
 	"strings"
-
-	"github.com/markbates/pkger"
 )
 
 const (
@@ -22,10 +22,18 @@ const (
 	tmplError         = "error.html"
 	tmplDevice        = "device.html"
 	tmplDeviceSuccess = "device_success.html"
-	tmplHeader        = "header.html"
-	tmplFooter        = "footer.html"
 )
 
+var requiredTmpls = []string{
+	tmplApproval,
+	tmplLogin,
+	tmplPassword,
+	tmplOOB,
+	tmplError,
+	tmplDevice,
+	tmplDeviceSuccess,
+}
+
 type templates struct {
 	loginTmpl         *template.Template
 	approvalTmpl      *template.Template
@@ -36,117 +44,182 @@ type templates struct {
 	deviceSuccessTmpl *template.Template
 }
 
-// loadTemplates parses the expected templates from the provided directory.
-func loadTemplates(c WebConfig, issuerPath string) (*templates, error) {
+type webConfig struct {
+	dir       string
+	webFS     fs.FS
+	logoURL   string
+	issuer    string
+	theme     string
+	issuerURL string
+	extra     map[string]string
+}
+
+// loadWebConfig returns static assets, theme assets, and templates used by the frontend by
+// reading the dir specified in the webConfig. If directory is not specified it will
+// use the file system specified by webFS.
+//
+// The directory layout is expected to be:
+//
+//    ( web directory )
+//    |- static
+//    |- themes
+//    |  |- (theme name)
+//    |- templates
+//
+func loadWebConfig(c webConfig) (http.Handler, http.Handler, *templates, error) {
 	// fallback to the default theme if the legacy theme name is provided
-	if c.Theme == "coreos" || c.Theme == "tectonic" {
-		c.Theme = ""
+	if c.theme == "coreos" || c.theme == "tectonic" {
+		c.theme = ""
 	}
-	if c.Theme == "" {
-		c.Theme = "light"
+	if c.theme == "" {
+		c.theme = "light"
 	}
-
-	if c.Issuer == "" {
-		c.Issuer = "dex"
+	if c.issuer == "" {
+		c.issuer = "dex"
 	}
-
-	if c.LogoURL == "" {
-		c.LogoURL = "theme/logo.png"
+	if c.dir != "" {
+		c.webFS = os.DirFS(c.dir)
 	}
-
-	hostURL := issuerPath
-	if c.HostURL != "" {
-		hostURL = c.HostURL
+	if c.logoURL == "" {
+		c.logoURL = "theme/logo.png"
 	}
 
-	funcs := template.FuncMap{
-		"issuer": func() string { return c.Issuer },
-		"logo":   func() string { return c.LogoURL },
-		"static": func(assetPath string) string {
-			return path.Join(hostURL, "static", assetPath)
-		},
-		"theme": func(assetPath string) string {
-			return path.Join(hostURL, "themes", c.Theme, assetPath)
-		},
-		"lower": strings.ToLower,
-		"extra": func(k string) string { return c.Extra[k] },
+	staticFiles, err := fs.Sub(c.webFS, "static")
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("read static dir: %v", err)
+	}
+	themeFiles, err := fs.Sub(c.webFS, filepath.Join("themes", c.theme))
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("read themes dir: %v", err)
 	}
 
-	group := template.New("")
+	static := http.FileServer(http.FS(staticFiles))
+	theme := http.FileServer(http.FS(themeFiles))
 
-	// load all of our templates individually.
-	// some http.FilSystem implementations don't implement Readdir
+	templates, err := loadTemplates(c, "templates")
+	return static, theme, templates, err
+}
 
-	loginTemplate, err := loadTemplate(c.Dir, tmplLogin, funcs, group)
+// loadTemplates parses the expected templates from the provided directory.
+func loadTemplates(c webConfig, templatesDir string) (*templates, error) {
+	files, err := fs.ReadDir(c.webFS, templatesDir)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("read dir: %v", err)
 	}
 
-	approvalTemplate, err := loadTemplate(c.Dir, tmplApproval, funcs, group)
-	if err != nil {
-		return nil, err
+	filenames := []string{}
+	for _, file := range files {
+		if file.IsDir() {
+			continue
+		}
+		filenames = append(filenames, filepath.Join(templatesDir, file.Name()))
 	}
-
-	passwordTemplate, err := loadTemplate(c.Dir, tmplPassword, funcs, group)
-	if err != nil {
-		return nil, err
+	if len(filenames) == 0 {
+		return nil, fmt.Errorf("no files in template dir %q", templatesDir)
 	}
 
-	oobTemplate, err := loadTemplate(c.Dir, tmplOOB, funcs, group)
+	issuerURL, err := url.Parse(c.issuerURL)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("error parsing issuerURL: %v", err)
 	}
 
-	errorTemplate, err := loadTemplate(c.Dir, tmplError, funcs, group)
-	if err != nil {
-		return nil, err
+	funcs := map[string]interface{}{
+		"issuer": func() string { return c.issuer },
+		"logo":   func() string { return c.logoURL },
+		"url":    func(reqPath, assetPath string) string { return relativeURL(issuerURL.Path, reqPath, assetPath) },
+		"lower":  strings.ToLower,
+		"extra":  func(k string) string { return c.extra[k] },
 	}
 
-	deviceTemplate, err := loadTemplate(c.Dir, tmplDevice, funcs, group)
+	tmpls, err := template.New("").Funcs(funcs).ParseFS(c.webFS, filenames...)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("parse files: %v", err)
 	}
-
-	deviceSuccessTemplate, err := loadTemplate(c.Dir, tmplDeviceSuccess, funcs, group)
-	if err != nil {
-		return nil, err
+	missingTmpls := []string{}
+	for _, tmplName := range requiredTmpls {
+		if tmpls.Lookup(tmplName) == nil {
+			missingTmpls = append(missingTmpls, tmplName)
+		}
 	}
+	if len(missingTmpls) > 0 {
+		return nil, fmt.Errorf("missing template(s): %s", missingTmpls)
+	}
+	return &templates{
+		loginTmpl:         tmpls.Lookup(tmplLogin),
+		approvalTmpl:      tmpls.Lookup(tmplApproval),
+		passwordTmpl:      tmpls.Lookup(tmplPassword),
+		oobTmpl:           tmpls.Lookup(tmplOOB),
+		errorTmpl:         tmpls.Lookup(tmplError),
+		deviceTmpl:        tmpls.Lookup(tmplDevice),
+		deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess),
+	}, nil
+}
 
-	_, err = loadTemplate(c.Dir, tmplHeader, funcs, group)
-	if err != nil {
-		// we don't actually care if this template exists
+// relativeURL returns the URL of the asset relative to the URL of the request path.
+// The serverPath is consulted to trim any prefix due in case it is not listening
+// to the root path.
+//
+// Algorithm:
+// 1. Remove common prefix of serverPath and reqPath
+// 2. Remove common prefix of assetPath and reqPath
+// 3. For each part of reqPath remaining(minus one), go up one level (..)
+// 4. For each part of assetPath remaining, append it to result
+//
+// eg
+// server listens at localhost/dex so serverPath is dex
+// reqPath is /dex/auth
+// assetPath is static/main.css
+// relativeURL("/dex", "/dex/auth", "static/main.css") = "../static/main.css"
+func relativeURL(serverPath, reqPath, assetPath string) string {
+	if u, err := url.ParseRequestURI(assetPath); err == nil && u.Scheme != "" {
+		// assetPath points to the external URL, no changes needed
+		return assetPath
 	}
 
-	_, err = loadTemplate(c.Dir, tmplFooter, funcs, group)
-	if err != nil {
-		// we don't actually care if this template exists
+	splitPath := func(p string) []string {
+		res := []string{}
+		parts := strings.Split(path.Clean(p), "/")
+		for _, part := range parts {
+			if part != "" {
+				res = append(res, part)
+			}
+		}
+		return res
 	}
 
-	return &templates{
-		loginTmpl:         loginTemplate,
-		approvalTmpl:      approvalTemplate,
-		passwordTmpl:      passwordTemplate,
-		oobTmpl:           oobTemplate,
-		errorTmpl:         errorTemplate,
-		deviceTmpl:        deviceTemplate,
-		deviceSuccessTmpl: deviceSuccessTemplate,
-	}, nil
-}
+	stripCommonParts := func(s1, s2 []string) ([]string, []string) {
+		min := len(s1)
+		if len(s2) < min {
+			min = len(s2)
+		}
 
-// load a template by name from the templates dir
-func loadTemplate(dir string, name string, funcs template.FuncMap, group *template.Template) (*template.Template, error) {
-	file, err := pkger.Open(filepath.Join(dir, "templates", name))
-	if err != nil {
-		return nil, err
+		splitIndex := min
+		for i := 0; i < min; i++ {
+			if s1[i] != s2[i] {
+				splitIndex = i
+				break
+			}
+		}
+		return s1[splitIndex:], s2[splitIndex:]
 	}
 
-	defer file.Close()
+	server, req, asset := splitPath(serverPath), splitPath(reqPath), splitPath(assetPath)
+
+	// Remove common prefix of request path with server path
+	_, req = stripCommonParts(server, req)
 
-	var buffer bytes.Buffer
-	buffer.ReadFrom(file)
-	contents := buffer.String()
+	// Remove common prefix of request path with asset path
+	asset, req = stripCommonParts(asset, req)
+
+	// For each part of the request remaining (minus one) -> go up one level (..)
+	// For each part of the asset remaining               -> append it
+	var relativeURL string
+	for i := 0; i < len(req)-1; i++ {
+		relativeURL = path.Join("..", relativeURL)
+	}
+	relativeURL = path.Join(relativeURL, path.Join(asset...))
 
-	return group.New(name).Funcs(funcs).Parse(contents)
+	return relativeURL
 }
 
 var scopeDescriptions = map[string]string{
diff --git a/server/templates_test.go b/server/templates_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..defb2e5e7d5370aba8e4f93f41e7333f25b2421c
--- /dev/null
+++ b/server/templates_test.go
@@ -0,0 +1,51 @@
+package server
+
+import "testing"
+
+func TestRelativeURL(t *testing.T) {
+	tests := []struct {
+		name       string
+		serverPath string
+		reqPath    string
+		assetPath  string
+		expected   string
+	}{
+		{
+			name:       "server-root-req-one-level-asset-two-level",
+			serverPath: "/",
+			reqPath:    "/auth",
+			assetPath:  "/theme/main.css",
+			expected:   "theme/main.css",
+		},
+		{
+			name:       "server-one-level-req-one-level-asset-two-level",
+			serverPath: "/dex",
+			reqPath:    "/dex/auth",
+			assetPath:  "/theme/main.css",
+			expected:   "theme/main.css",
+		},
+		{
+			name:       "server-root-req-two-level-asset-three-level",
+			serverPath: "/dex",
+			reqPath:    "/dex/auth/connector",
+			assetPath:  "assets/css/main.css",
+			expected:   "../assets/css/main.css",
+		},
+		{
+			name:       "external-url",
+			serverPath: "/dex",
+			reqPath:    "/dex/auth/connector",
+			assetPath:  "https://kubernetes.io/images/favicon.png",
+			expected:   "https://kubernetes.io/images/favicon.png",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			actual := relativeURL(test.serverPath, test.reqPath, test.assetPath)
+			if actual != test.expected {
+				t.Fatalf("Got '%s'. Expected '%s'", actual, test.expected)
+			}
+		})
+	}
+}
diff --git a/web/templates/header.html b/web/templates/header.html
index bbd1c13fa6ecc451bb278a194f3d304276251381..8cf744e51e606f66128ff7c74ce7df327781e853 100644
--- a/web/templates/header.html
+++ b/web/templates/header.html
@@ -5,17 +5,16 @@
     <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
     <title>{{ issuer }}</title>
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <link href="{{ static "main.css" }}" rel="stylesheet">
-    <link href="{{ theme "styles.css" }}" rel="stylesheet">
-    <link rel="icon" href="{{ theme "favicon.png" }}">
+    <link href="{{ url .ReqPath "static/main.css" }}" rel="stylesheet">
+    <link href="{{ url .ReqPath "theme/styles.css" }}" rel="stylesheet">
+    <link rel="icon" href="{{ url .ReqPath "theme/favicon.png" }}">
   </head>
 
   <body class="theme-body">
     <div class="theme-navbar">
       <div class="theme-navbar__logo-wrap">
-        <img class="theme-navbar__logo" src="{{ logo }}">
+        <img class="theme-navbar__logo" src="{{ url .ReqPath logo }}">
       </div>
     </div>
 
     <div class="dex-container">
-