Repository: mccutchen/go-httpbin
Pull request #120 (2023-05-19 20:22:07)

feat: support serving under a path prefix
Make relative links in main help page. Some links (like `absolute-redirekt` and `links`) will not work with prefix. But they still work without.

Could be tested with this program:
```go
package main

import (
	"github.com/mccutchen/go-httpbin/v2/httpbin"
	"net/http"
)

const (
	prefix = "/prefix"
)

func main() {
	var handler http.Handler = httpbin.New()
	handler = http.StripPrefix(prefix, handler)

	mux := http.NewServeMux()
	mux.Handle(prefix+"/", handler)
	http.ListenAndServe(":8090", mux)
}
```

While running this result can be checked at http://localhost:8090/prefix/

mccutchen: Ah, interesting use case, this isn't something I've considered before.  Unfortunately, fixing the links on the index page is an incomplete solution; as you mentioned, any endpoint that generates links to other endpoints (like `/redirect`, `/absolute-redirect`, etc) will break:

```
$ curl -v http://localhost:8090/prefix/redirect/5 2>&1 | grep -e '> GET' -e '< HTTP' -e '< Location'
> GET /prefix/redirect/5 HTTP/1.1
< HTTP/1.1 302 Found
< Location: /relative-redirect/4
```

So, if we want go-httpbin to properly function when being served under a path prefix, we need a more holistic solution.  Probably adding an optional `--prefix` configuration that would be used to compute URLs.  (Unfortunately, this would also require us to turn the static index.html into a template of some sort, but we can probably keep that pretty simple and render it only once at startup and continue serving it quickly from memory.)

Are you interested in taking on that challenge?
waschik: Okay. I tried to add prefix to code and make the index html and form post a template. Executable size increased on my system by about 20% (7.5 MB to 8.9 MB). Because software then needs to know the prefix I made an option like you suggested and also called the `StripPrefix` inside handler creation.

Then after
```bash
./go-httpbin -port 8090 -prefix /prefix &
```
I could run the following:
```
$ curl -v http://localhost:8090/prefix/redirect/5 2>&1 | grep -e '> GET' -e '< HTTP' -e '< Location'
> GET /prefix/redirect/5 HTTP/1.1
< HTTP/1.1 302 Found
< Location: /prefix/relative-redirect/4
```

I did know change all unit tests. This would make tests more complicated (mostly for-loops with and without prefix). I do not know what you prefer. Another option (maybe not the nicest) would be that I write a complete wrapper and do some search and replacement in result body and headers (e.g. `Location` header).
mloskot: @mccutchen 
> Ah, interesting use case, this isn't something I've considered before.

I sometimes deploy go-httpbin  behind Kong proxy, for testing, and where I apply path-based routing, so the service is available at e.g. https://example.com/tests/httpbin and it would be nice if the endpoints on the frontpage could be generated to

/tests/httpbin/anything
/tests/httpbin/base64
...
mccutchen: Yeah, my apologies to @waschik and anyone else interested in this change.  I dropped the ball on getting this reviewed and merged, and _then_ I refactored a few things along with the whole dang test suite, leading to a bunch of churn that will need to be addressed.

I'll try to make time this weekend circle back to it!
waschik: @mccutchen I tried to fixed the merge failures. Hope you did not do them parallel and I home I do not have to do it again.

Do you know https://github.com/stretchr/testify? Looks a bit similar to your assert. With that I would not have to add a assert.Contains method which I needed because of two tests of body.
mccutchen: > Do you know https://github.com/stretchr/testify? Looks a bit similar to your assert. With that I would not have to add a assert.Contains method which I needed because of two tests of body.

Yes, I use that and a variety of other testing helper packages in other projects.  For this project, an explicit goal is using zero dependencies outside the stdlib, in order to make it easier for _other_ projects to decide to pull it into their own test suites.

Patch:
diff --git a/.gitignore b/.gitignore
index 348636d..089b51e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
 *.prof
 dist/*
 coverage.txt
+/cmd/go-httpbin/go-httpbin
diff --git a/README.md b/README.md
index 58f0c81..5cf9c89 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,7 @@ variables (or a combination of the two):
 | `-max-body-size` | `MAX_BODY_SIZE` | Maximum size of request or response, in bytes | 1048576 |
 | `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s |
 | `-port` | `PORT` | Port to listen on | 8080 |
+| `-prefix` | `PREFIX` | Prefix of path to listen on (must start with slash and does not end with slash) | |
 | `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false |
 | `-exclude-headers` | `EXCLUDE_HEADERS` | Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard suffix matching. For example: `"foo,bar,x-fc-*"` | - |
 
diff --git a/httpbin/cmd/cmd.go b/httpbin/cmd/cmd.go
index d2c5283..d3aafdd 100644
--- a/httpbin/cmd/cmd.go
+++ b/httpbin/cmd/cmd.go
@@ -73,6 +73,9 @@ func mainImpl(args []string, getEnv func(string) string, getHostname func() (str
 		httpbin.WithObserver(httpbin.StdLogObserver(logger)),
 		httpbin.WithExcludeHeaders(cfg.ExcludeHeaders),
 	}
+	if cfg.Prefix != "" {
+		opts = append(opts, httpbin.WithPrefix(cfg.Prefix))
+	}
 	if cfg.RealHostname != "" {
 		opts = append(opts, httpbin.WithHostname(cfg.RealHostname))
 	}
@@ -106,6 +109,7 @@ type config struct {
 	ListenPort             int
 	MaxBodySize            int64
 	MaxDuration            time.Duration
+	Prefix                 string
 	RealHostname           string
 	TLSCertFile            string
 	TLSKeyFile             string
@@ -142,6 +146,7 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
 	fs.IntVar(&cfg.ListenPort, "port", defaultListenPort, "Port to listen on")
 	fs.StringVar(&cfg.rawAllowedRedirectDomains, "allowed-redirect-domains", "", "Comma-separated list of domains the /redirect-to endpoint will allow")
 	fs.StringVar(&cfg.ListenHost, "host", defaultListenHost, "Host to listen on")
+	fs.StringVar(&cfg.Prefix, "prefix", "", "Path prefix (empty or start with slash and does not end with slash)")
 	fs.StringVar(&cfg.TLSCertFile, "https-cert-file", "", "HTTPS Server certificate file")
 	fs.StringVar(&cfg.TLSKeyFile, "https-key-file", "", "HTTPS Server private key file")
 	fs.StringVar(&cfg.ExcludeHeaders, "exclude-headers", "", "Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard matching.")
@@ -194,6 +199,19 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
 	if cfg.ListenHost == defaultListenHost && getEnv("HOST") != "" {
 		cfg.ListenHost = getEnv("HOST")
 	}
+	if cfg.Prefix == "" {
+		if prefix := getEnv("PREFIX"); prefix != "" {
+			cfg.Prefix = prefix
+		}
+	}
+	if cfg.Prefix != "" {
+		if !strings.HasPrefix(cfg.Prefix, "/") {
+			return nil, configErr("Prefix %#v must start with a slash", cfg.Prefix)
+		}
+		if strings.HasSuffix(cfg.Prefix, "/") {
+			return nil, configErr("Prefix %#v must not end with a slash", cfg.Prefix)
+		}
+	}
 	if cfg.ExcludeHeaders == "" && getEnv("EXCLUDE_HEADERS") != "" {
 		cfg.ExcludeHeaders = getEnv("EXCLUDE_HEADERS")
 	}
diff --git a/httpbin/handlers.go b/httpbin/handlers.go
index a040ad9..b934fa6 100644
--- a/httpbin/handlers.go
+++ b/httpbin/handlers.go
@@ -31,12 +31,12 @@ func (h *HTTPBin) Index(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' camo.githubusercontent.com")
-	writeHTML(w, mustStaticAsset("index.html"), http.StatusOK)
+	writeHTML(w, h.indexHTML, http.StatusOK)
 }
 
 // FormsPost renders an HTML form that submits a request to the /post endpoint
 func (h *HTTPBin) FormsPost(w http.ResponseWriter, _ *http.Request) {
-	writeHTML(w, mustStaticAsset("forms-post.html"), http.StatusOK)
+	writeHTML(w, h.formsPostHTML, http.StatusOK)
 }
 
 // UTF8 renders an HTML encoding stress test
@@ -161,13 +161,13 @@ type statusCase struct {
 	body    []byte
 }
 
-var (
-	statusRedirectHeaders = &statusCase{
+func createSpecialCases(prefix string) map[int]*statusCase {
+	statusRedirectHeaders := &statusCase{
 		headers: map[string]string{
-			"Location": "/redirect/1",
+			"Location": prefix + "/redirect/1",
 		},
 	}
-	statusNotAcceptableBody = []byte(`{
+	statusNotAcceptableBody := []byte(`{
   "message": "Client did not request a supported media type",
   "accept": [
     "image/webp",
@@ -178,31 +178,31 @@ var (
   ]
 }
 `)
-	statusHTTP300body = []byte(`<!doctype html>
+	statusHTTP300body := []byte(fmt.Sprintf(`<!doctype html>
 <head>
 <title>Multiple Choices</title>
 </head>
 <body>
 <ul>
-<li><a href="/image/jpeg">/image/jpeg</a></li>
-<li><a href="/image/png">/image/png</a></li>
-<li><a href="/image/svg">/image/svg</a></li>
+<li><a href="%[1]s/image/jpeg">/image/jpeg</a></li>
+<li><a href="%[1]s/image/png">/image/png</a></li>
+<li><a href="%[1]s/image/svg">/image/svg</a></li>
 </body>
-</html>`)
+</html>`, prefix))
 
-	statusHTTP308Body = []byte(`<!doctype html>
+	statusHTTP308Body := []byte(fmt.Sprintf(`<!doctype html>
 <head>
 <title>Permanent Redirect</title>
 </head>
-<body>Permanently redirected to <a href="/image/jpeg">/image/jpeg</a>
+<body>Permanently redirected to <a href="%[1]s/image/jpeg">%[1]s/image/jpeg</a>
 </body>
-</html>`)
+</html>`, prefix))
 
-	statusSpecialCases = map[int]*statusCase{
+	return map[int]*statusCase{
 		300: {
 			body: statusHTTP300body,
 			headers: map[string]string{
-				"Location": "/image/jpeg",
+				"Location": prefix + "/image/jpeg",
 			},
 		},
 		301: statusRedirectHeaders,
@@ -213,7 +213,7 @@ var (
 		308: {
 			body: statusHTTP308Body,
 			headers: map[string]string{
-				"Location": "/image/jpeg",
+				"Location": prefix + "/image/jpeg",
 			},
 		},
 		401: {
@@ -245,7 +245,7 @@ var (
 			},
 		},
 	}
-)
+}
 
 // Status responds with the specified status code. TODO: support random choice
 // from multiple, optionally weighted status codes.
@@ -265,7 +265,7 @@ func (h *HTTPBin) Status(w http.ResponseWriter, r *http.Request) {
 	// for special cases
 	w.Header().Set("Content-Type", textContentType)
 
-	if specialCase, ok := statusSpecialCases[code]; ok {
+	if specialCase, ok := h.statusSpecialCases[code]; ok {
 		for key, val := range specialCase.headers {
 			w.Header().Set(key, val)
 		}
@@ -326,7 +326,7 @@ func (h *HTTPBin) ResponseHeaders(w http.ResponseWriter, r *http.Request) {
 	mustMarshalJSON(w, args)
 }
 
-func redirectLocation(r *http.Request, relative bool, n int) string {
+func (h *HTTPBin) redirectLocation(r *http.Request, relative bool, n int) string {
 	var location string
 	var path string
 
@@ -350,7 +350,7 @@ func redirectLocation(r *http.Request, relative bool, n int) string {
 	return location
 }
 
-func doRedirect(w http.ResponseWriter, r *http.Request, relative bool) {
+func (h *HTTPBin) handleRedirect(w http.ResponseWriter, r *http.Request, relative bool) {
 	parts := strings.Split(r.URL.Path, "/")
 	if len(parts) != 3 {
 		writeError(w, http.StatusNotFound, nil)
@@ -365,8 +365,7 @@ func doRedirect(w http.ResponseWriter, r *http.Request, relative bool) {
 		return
 	}
 
-	w.Header().Set("Location", redirectLocation(r, relative, n-1))
-	w.WriteHeader(http.StatusFound)
+	h.doRedirect(w, h.redirectLocation(r, relative, n-1), http.StatusFound)
 }
 
 // Redirect responds with 302 redirect a given number of times. Defaults to a
@@ -375,17 +374,17 @@ func doRedirect(w http.ResponseWriter, r *http.Request, relative bool) {
 func (h *HTTPBin) Redirect(w http.ResponseWriter, r *http.Request) {
 	params := r.URL.Query()
 	relative := strings.ToLower(params.Get("absolute")) != "true"
-	doRedirect(w, r, relative)
+	h.handleRedirect(w, r, relative)
 }
 
 // RelativeRedirect responds with an HTTP 302 redirect a given number of times
 func (h *HTTPBin) RelativeRedirect(w http.ResponseWriter, r *http.Request) {
-	doRedirect(w, r, true)
+	h.handleRedirect(w, r, true)
 }
 
 // AbsoluteRedirect responds with an HTTP 302 redirect a given number of times
 func (h *HTTPBin) AbsoluteRedirect(w http.ResponseWriter, r *http.Request) {
-	doRedirect(w, r, false)
+	h.handleRedirect(w, r, false)
 }
 
 // RedirectTo responds with a redirect to a specific URL with an optional
@@ -423,8 +422,7 @@ func (h *HTTPBin) RedirectTo(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	w.Header().Set("Location", u.String())
-	w.WriteHeader(statusCode)
+	h.doRedirect(w, u.String(), statusCode)
 }
 
 // Cookies responds with the cookies in the incoming request
@@ -447,8 +445,7 @@ func (h *HTTPBin) SetCookies(w http.ResponseWriter, r *http.Request) {
 			HttpOnly: true,
 		})
 	}
-	w.Header().Set("Location", "/cookies")
-	w.WriteHeader(http.StatusFound)
+	h.doRedirect(w, "/cookies", http.StatusFound)
 }
 
 // DeleteCookies deletes cookies specified in query params and redirects to
@@ -464,8 +461,7 @@ func (h *HTTPBin) DeleteCookies(w http.ResponseWriter, r *http.Request) {
 			Expires:  time.Now().Add(-1 * 24 * 365 * time.Hour),
 		})
 	}
-	w.Header().Set("Location", "/cookies")
-	w.WriteHeader(http.StatusFound)
+	h.doRedirect(w, "/cookies", http.StatusFound)
 }
 
 // BasicAuth requires basic authentication
@@ -916,18 +912,17 @@ func (h *HTTPBin) Links(w http.ResponseWriter, r *http.Request) {
 			writeError(w, http.StatusBadRequest, fmt.Errorf("invalid offset: %w", err))
 			return
 		}
-		doLinksPage(w, r, n, offset)
+		h.doLinksPage(w, r, n, offset)
 		return
 	}
 
 	// Otherwise, redirect from /links/<n> to /links/<n>/0
 	r.URL.Path = r.URL.Path + "/0"
-	w.Header().Set("Location", r.URL.String())
-	w.WriteHeader(http.StatusFound)
+	h.doRedirect(w, r.URL.String(), http.StatusFound)
 }
 
 // doLinksPage renders a page with a series of N links
-func doLinksPage(w http.ResponseWriter, _ *http.Request, n int, offset int) {
+func (h *HTTPBin) doLinksPage(w http.ResponseWriter, _ *http.Request, n int, offset int) {
 	w.Header().Add("Content-Type", htmlContentType)
 	w.WriteHeader(http.StatusOK)
 
@@ -936,12 +931,23 @@ func doLinksPage(w http.ResponseWriter, _ *http.Request, n int, offset int) {
 		if i == offset {
 			fmt.Fprintf(w, "%d ", i)
 		} else {
-			fmt.Fprintf(w, `<a href="/links/%d/%d">%d</a> `, n, i, i)
+			fmt.Fprintf(w, `<a href="%s/links/%d/%d">%d</a> `, h.prefix, n, i, i)
 		}
 	}
 	w.Write([]byte("</body></html>"))
 }
 
+// doRedirect set redirect header
+func (h *HTTPBin) doRedirect(w http.ResponseWriter, path string, code int) {
+	var sb strings.Builder
+	if strings.HasPrefix(path, "/") {
+		sb.WriteString(h.prefix)
+	}
+	sb.WriteString(path)
+	w.Header().Set("Location", sb.String())
+	w.WriteHeader(code)
+}
+
 // ImageAccept responds with an appropriate image based on the Accept header
 func (h *HTTPBin) ImageAccept(w http.ResponseWriter, r *http.Request) {
 	accept := r.Header.Get("Accept")
diff --git a/httpbin/httpbin.go b/httpbin/httpbin.go
index b1b420a..f364937 100644
--- a/httpbin/httpbin.go
+++ b/httpbin/httpbin.go
@@ -55,6 +55,13 @@ type HTTPBin struct {
 	// The app's http handler
 	handler http.Handler
 
+	prefix string
+
+	indexHTML     []byte
+	formsPostHTML []byte
+
+	statusSpecialCases map[int]*statusCase
+
 	excludeHeadersProcessor headersProcessorFunc
 }
 
@@ -70,6 +77,12 @@ func New(opts ...OptionFunc) *HTTPBin {
 		opt(h)
 	}
 	h.handler = h.Handler()
+
+	h.statusSpecialCases = createSpecialCases(h.prefix)
+
+	h.indexHTML = h.staticTemplateAssert("index.html.tmpl")
+	h.formsPostHTML = h.staticTemplateAssert("forms-post.html.tmpl")
+
 	return h
 }
 
@@ -180,6 +193,11 @@ func (h *HTTPBin) Handler() http.Handler {
 	handler = limitRequestSize(h.MaxBodySize, handler)
 	handler = preflight(handler)
 	handler = autohead(handler)
+
+	if h.prefix != "" {
+		handler = http.StripPrefix(h.prefix, handler)
+	}
+
 	if h.Observer != nil {
 		handler = observe(h.Observer, handler)
 	}
diff --git a/httpbin/options.go b/httpbin/options.go
index 74747d6..e697a1c 100644
--- a/httpbin/options.go
+++ b/httpbin/options.go
@@ -54,6 +54,13 @@ func WithExcludeHeaders(excludeHeaders string) OptionFunc {
 	}
 }
 
+// WithPrefix sets the path prefix
+func WithPrefix(p string) OptionFunc {
+	return func(h *HTTPBin) {
+		h.prefix = p
+	}
+}
+
 // WithAllowedRedirectDomains limits the domains to which the /redirect-to
 // endpoint will redirect traffic.
 func WithAllowedRedirectDomains(hosts []string) OptionFunc {
diff --git a/httpbin/static/forms-post.html b/httpbin/static/forms-post.html.tmpl
similarity index 96%
rename from httpbin/static/forms-post.html
rename to httpbin/static/forms-post.html.tmpl
index e79dffd..169badf 100644
--- a/httpbin/static/forms-post.html
+++ b/httpbin/static/forms-post.html.tmpl
@@ -4,7 +4,7 @@
   </head>
   <body>
   <!-- Example form from HTML5 spec http://www.w3.org/TR/html5/forms.html#writing-a-form's-user-interface -->
-  <form method="post" action="/post">
+  <form method="post" action="{{.Prefix}}/post">
    <p><label>Customer name: <input name="custname"></label></p>
    <p><label>Telephone: <input type=tel name="custtel"></label></p>
    <p><label>E-mail address: <input type=email name="custemail"></label></p>
diff --git a/httpbin/static/index.html b/httpbin/static/index.html
deleted file mode 100644
index 4d707ee..0000000
--- a/httpbin/static/index.html
+++ /dev/null
@@ -1,196 +0,0 @@
-<!DOCTYPE html>
-<html>
-<head>
-  <meta http-equiv='content-type' value='text/html;charset=utf8'>
-  <meta name='generator' value='Ronn/v0.7.3 (http://github.com/rtomayko/ronn/tree/0.7.3)'>
-  <title>go-httpbin(1): HTTP Client Testing Service</title>
-  <style type='text/css' media='all'>
-  /* style: man */
-  body#manpage {margin:0;background:#fff;}
-  .mp {max-width:100ex;padding:0 9ex 1ex 4ex}
-  .mp p,.mp pre,.mp ul,.mp ol,.mp dl {margin:0 0 20px 0}
-  .mp h2 {margin:10px 0 0 0}
-  .mp > p,.mp > pre,.mp > ul,.mp > ol,.mp > dl {margin-left:8ex}
-  .mp h3 {margin:0 0 0 4ex}
-  .mp dt {margin:0;clear:left}
-  .mp dt.flush {float:left;width:8ex}
-  .mp dd {margin:0 0 0 9ex}
-  .mp h1,.mp h2,.mp h3,.mp h4 {clear:left}
-  .mp pre {margin-bottom:20px}
-  .mp pre+h2,.mp pre+h3 {margin-top:22px}
-  .mp h2+pre,.mp h3+pre {margin-top:5px}
-  .mp img {display:block;margin:auto}
-  .mp h1.man-title {display:none}
-  .mp,.mp code,.mp pre,.mp tt,.mp kbd,.mp samp,.mp h3,.mp h4 {font-family:monospace;font-size:14px;line-height:1.42857142857143}
-  .mp h2 {font-size:16px;line-height:1.25}
-  .mp h1 {font-size:20px;line-height:2}
-  .mp,.mp code,.mp pre,.mp pre code,.mp tt,.mp kbd,.mp samp {color:#131211}
-  .mp h1,.mp h2,.mp h3,.mp h4 {color:#030201}
-  .mp u {text-decoration:underline}
-  .mp code,.mp strong,.mp b {font-weight:bold;color:#131211}
-  .mp em,.mp var {font-style:italic;color:#232221;text-decoration:none}
-  .mp a,.mp a:link,.mp a:hover,.mp a code,.mp a pre,.mp a tt,.mp a kbd,.mp a samp {color:#0000ff}
-  .mp b.man-ref {font-weight:normal;color:#434241}
-  .mp pre {padding:0 4ex}
-  .mp pre code {font-weight:normal;color:#434241}
-  .mp h2+pre,h3+pre {padding-left:0}
-  ol.man-decor,ol.man-decor li {margin:3px 0 10px 0;padding:0;float:left;width:33%;list-style-type:none;text-transform:uppercase;color:#999;letter-spacing:1px}
-  ol.man-decor {width:100%}
-  ol.man-decor li.tl {text-align:left}
-  ol.man-decor li.tc {text-align:center;letter-spacing:4px}
-  ol.man-decor li.tr {text-align:right;float:right}
-  </style>
-  <style type='text/css' media='all'>
-  /* style: 80c */
-  .mp {max-width:86ex}
-  ul {list-style: None; margin-left: 1em!important}
-  .man-navigation {left:101ex}
-  </style>
-</head>
-
-<body id='manpage'>
-
-
-<div class='mp'>
-<h1>go-httpbin(1)</h1>
-<p>A golang port of the venerable <a href="https://httpbin.org/">httpbin.org</a> HTTP request &amp; response testing service.</p>
-
-<h2 id="ENDPOINTS">ENDPOINTS</h2>
-
-<ul>
-<li><a href="/"><code>/</code></a> This page.</li>
-<li><a href="/absolute-redirect/6"><code>/absolute-redirect/:n</code></a> 302 Absolute redirects <em>n</em> times.</li>
-<li><a href="/anything"><code>/anything/:anything</code></a> Returns anything that is passed to request.</li>
-<li><a href="/base64/aHR0cGJpbmdvLm9yZw=="><code>/base64/:value</code></a> Decodes a Base64-encoded string.</li>
-<li><a href="/base64/decode/aHR0cGJpbmdvLm9yZw=="><code>/base64/decode/:value</code></a> Explicit URL for decoding a Base64 encoded string.</li>
-<li><a href="/base64/encode/httpbingo.org"><code>/base64/encode/:value</code></a> Encodes a string into URL-safe Base64.</li>
-<li><a href="/basic-auth/user/passwd"><code>/basic-auth/:user/:passwd</code></a> Challenges HTTPBasic Auth.</li>
-<li><a href="/bearer"><code>/bearer</code></a> Checks Bearer token header - returns 401 if not set.</li>
-<li><a href="/brotli"><code><del>/brotli</del></code></a> Returns brotli-encoded data.</del> <i>Not implemented!</i></li>
-<li><a href="/bytes/1024"><code>/bytes/:n</code></a> Generates <em>n</em> random bytes of binary data, accepts optional <em>seed</em> integer parameter.</li>
-<li><a href="/cache"><code>/cache</code></a> Returns 200 unless an If-Modified-Since or If-None-Match header is provided, when it returns a 304.</li>
-<li><a href="/cache/60"><code>/cache/:n</code></a> Sets a Cache-Control header for <em>n</em> seconds.</li>
-<li><a href="/cookies"><code>/cookies</code></a> Returns cookie data.</li>
-<li><a href="/cookies/delete?k1=&amp;k2="><code>/cookies/delete?name</code></a> Deletes one or more simple cookies.</li>
-<li><a href="/cookies/set?k1=v1&amp;k2=v2"><code>/cookies/set?name=value</code></a> Sets one or more simple cookies.</li>
-<li><a href="/deflate"><code>/deflate</code></a> Returns deflate-encoded data.</li>
-<li><a href="/delay/3"><code>/delay/:n</code></a> Delays responding for <em>min(n, 10)</em> seconds.</li>
-<li><code>/delete</code> Returns request data.  Allows only <code>DELETE</code> requests.</li>
-<li><a href="/deny"><code>/deny</code></a> Denied by robots.txt file.</li>
-<li><a href="/digest-auth/auth/user/passwd/MD5"><code>/digest-auth/:qop/:user/:passwd/:algorithm</code></a> Challenges HTTP Digest Auth.</li>
-<li><a href="/digest-auth/auth/user/passwd/MD5"><code>/digest-auth/:qop/:user/:passwd</code></a> Challenges HTTP Digest Auth.</li>
-<li><a href="/drip?code=200&amp;numbytes=5&amp;duration=5"><code>/drip?numbytes=n&amp;duration=s&amp;delay=s&amp;code=code</code></a> Drips data over a duration after an optional initial delay, then (optionally) returns with the given status code.</li>
-<li><a href="/dump/request"><code>/dump/request</code></a> Returns the given request in its HTTP/1.x wire approximate representation.</li>
-<li><a href="/encoding/utf8"><code>/encoding/utf8</code></a> Returns page containing UTF-8 data.</li>
-<li><a href="/etag/etag"><code>/etag/:etag</code></a> Assumes the resource has the given etag and responds to If-None-Match header with a 200 or 304 and If-Match with a 200 or 412 as appropriate.</li>
-<li><a href="/forms/post"><code>/forms/post</code></a> HTML form that submits to <em>/post</em></li>
-<li><a href="/get"><code>/get</code></a> Returns GET data.</li>
-<li><a href="/gzip"><code>/gzip</code></a> Returns gzip-encoded data.</li>
-<li><code>/head</code> Returns response headers.  Allows only <code>HEAD</code> requests.</li>
-<li><a href="/headers"><code>/headers</code></a> Returns request header dict.</li>
-<li><a href="/hidden-basic-auth/user/passwd"><code>/hidden-basic-auth/:user/:passwd</code></a> 404'd BasicAuth.</li>
-<li><a href="/html"><code>/html</code></a> Renders an HTML Page.</li>
-<li><a href="/hostname"><code>/hostname</code></a> Returns the name of the host serving the request.</li>
-<li><a href="/image"><code>/image</code></a> Returns page containing an image based on sent Accept header.</li>
-<li><a href="/image/jpeg"><code>/image/jpeg</code></a> Returns a JPEG image.</li>
-<li><a href="/image/png"><code>/image/png</code></a> Returns a PNG image.</li>
-<li><a href="/image/svg"><code>/image/svg</code></a> Returns a SVG image.</li>
-<li><a href="/image/webp"><code>/image/webp</code></a> Returns a WEBP image.</li>
-<li><a href="/ip"><code>/ip</code></a> Returns Origin IP.</li>
-<li><a href="/json"><code>/json</code></a> Returns JSON.</li>
-<li><a href="/links/10"><code>/links/:n</code></a> Returns page containing <em>n</em> HTML links.</li>
-<li><code>/patch</code> Returns request data.  Allows only <code>PATCH</code> requests.</li>
-<li><code>/post</code> Returns request data.  Allows only <code>POST</code> requests.</li>
-<li><code>/put</code> Returns request data.  Allows only <code>PUT</code> requests.</li>
-<li><a href="/range/1024"><code>/range/1024?duration=s&amp;chunk_size=code</code></a> Streams <em>n</em> bytes, and allows specifying a <em>Range</em> header to select a subset of the data. Accepts a <em>chunk_size</em> and request <em>duration</em> parameter.</li>
-<li><a href="/redirect-to?status_code=307&amp;url=http%3A%2F%2Fexample.com%2F"><code>/redirect-to?url=foo&status_code=307</code></a> 307 Redirects to the <em>foo</em> URL.</li>
-<li><a href="/redirect-to?url=http%3A%2F%2Fexample.com%2F"><code>/redirect-to?url=foo</code></a> 302 Redirects to the <em>foo</em> URL.</li>
-<li><a href="/redirect/6"><code>/redirect/:n</code></a> 302 Redirects <em>n</em> times.</li>
-<li><a href="/relative-redirect/6"><code>/relative-redirect/:n</code></a> 302 Relative redirects <em>n</em> times.</li>
-<li><a href="/response-headers?Server=httpbin&amp;Content-Type=text%2Fplain%3B+charset%3DUTF-8"><code>/response-headers?key=val</code></a> Returns given response headers.</li>
-<li><a href="/robots.txt"><code>/robots.txt</code></a> Returns some robots.txt rules.</li>
-<li><a href="/status/418"><code>/status/:code</code></a> Returns given HTTP Status code.</li>
-<li><a href="/stream-bytes/1024"><code>/stream-bytes/:n</code></a> Streams <em>n</em> random bytes of binary data, accepts optional <em>seed</em> and <em>chunk_size</em> integer parameters.</li>
-<li><a href="/stream/20"><code>/stream/:n</code></a> Streams <em>min(n, 100)</em> lines.</li>
-<li><a href="/unstable"><code>/unstable</code></a> Fails half the time, accepts optional <em>failure_rate</em> float and <em>seed</em> integer parameters.</li>
-<li><a href="/user-agent"><code>/user-agent</code></a> Returns user-agent.</li>
-<li><a href="/uuid"><code>/uuid</code></a> Generates a <a href="https://en.wikipedia.org/wiki/Universally_unique_identifier">UUIDv4</a> value.</li>
-<li><a href="/websocket/echo?max_fragment_size=2048&amp;max_message_size=10240"><code>/websocket/echo?max_fragment_size=2048&amp;max_message_size=10240</code></a> A WebSocket echo service.</li>
-<li><a href="/xml"><code>/xml</code></a> Returns some XML</li>
-</ul>
-
-<h2 id="DESCRIPTION">DESCRIPTION</h2>
-
-<p>Testing an HTTP Library can become difficult sometimes. <a href="http://requestb.in">RequestBin</a> is fantastic for testing POST requests, but doesn't let you control the response. This exists to cover all kinds of HTTP scenarios. Additional endpoints are being considered.</p>
-
-<p>All endpoint responses are JSON-encoded.</p>
-
-<h2 id="EXAMPLES">EXAMPLES</h2>
-
-<h3 id="-curl-http-httpbin-org-ip">$ curl https://httpbingo.org/ip</h3>
-
-<pre><code>{"origin":"73.238.9.52, 77.83.142.42"}
-</code></pre>
-
-<h3 id="-curl-http-httpbin-org-user-agent">$ curl https://httpbingo.org/user-agent</h3>
-
-<pre><code>{"user-agent":"curl/7.64.1"}</code></pre>
-
-<h3 id="-curl-http-httpbin-org-get">$ curl https://httpbingo.org/get?foo=bar</h3>
-
-<pre><code>{
-  "args": {
-    "foo": [
-      "bar"
-    ]
-  },
-  "headers": {
-    "Accept": [
-      "*/*"
-    ],
-    "Host": [
-      "httpbingo.org"
-    ],
-    "User-Agent": [
-      "curl/7.64.1"
-    ]
-  },
-  "origin": "73.238.9.52, 77.83.142.42",
-  "url": "https://httpbingo.org/get?foo=bar"
-}
-</code></pre>
-
-<h3 id="-curl-http-httpbin-org-dump-request">$ curl https://httpbingo.org/dump/request?foo=bar</h3>
-
-<pre><code>GET /dump/request?foo=bar HTTP/1.1
-Host: httpbingo.org
-Accept: */*
-User-Agent: curl/7.64.1
-</code></pre>
-
-<h3 id="-curl-I-http-httpbin-org-status-418">$ curl -I https://httpbingo.org/status/418</h3>
-
-<pre><code>HTTP/1.1 418 I'm a teapot
-Access-Control-Allow-Credentials: true
-Access-Control-Allow-Origin: *
-X-More-Info: http://tools.ietf.org/html/rfc2324
-Date: Tue, 13 Jul 2021 13:12:37 GMT
-Content-Length: 0
-</code></pre>
-
-
-<h2 id="AUTHOR">AUTHOR</h2>
-
-<p>Ported to Go by <a href="https://github.com/mccutchen">Will McCutchen</a>.</p>
-<p>From <a href="https://httpbin.org/">the original</a> <a href="https://kennethreitz.org/">Kenneth Reitz</a> project.</p>
-
-<h2 id="SEE-ALSO">SEE ALSO</h2>
-
-<p><a href="https://httpbin.org/">httpbin.org</a> &mdash; the original httpbin</p>
-
-</div>
-
-<a href="https://github.com/mccutchen/go-httpbin"><img style="position: absolute; top: 0; right: 0; border: 0;" src="https://camo.githubusercontent.com/38ef81f8aca64bb9a64448d0d70f1308ef5341ab/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732f666f726b6d655f72696768745f6461726b626c75655f3132313632312e706e67" alt="Fork me on GitHub" data-canonical-src="https://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png"></a>
-
-</body>
-</html>
diff --git a/httpbin/static/index.html.tmpl b/httpbin/static/index.html.tmpl
new file mode 100644
index 0000000..bbbabb1
--- /dev/null
+++ b/httpbin/static/index.html.tmpl
@@ -0,0 +1,196 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta http-equiv='content-type' value='text/html;charset=utf8'>
+  <meta name='generator' value='Ronn/v0.7.3 (http://github.com/rtomayko/ronn/tree/0.7.3)'>
+  <title>go-httpbin(1): HTTP Client Testing Service</title>
+  <style type='text/css' media='all'>
+  /* style: man */
+  body#manpage {margin:0;background:#fff;}
+  .mp {max-width:100ex;padding:0 9ex 1ex 4ex}
+  .mp p,.mp pre,.mp ul,.mp ol,.mp dl {margin:0 0 20px 0}
+  .mp h2 {margin:10px 0 0 0}
+  .mp > p,.mp > pre,.mp > ul,.mp > ol,.mp > dl {margin-left:8ex}
+  .mp h3 {margin:0 0 0 4ex}
+  .mp dt {margin:0;clear:left}
+  .mp dt.flush {float:left;width:8ex}
+  .mp dd {margin:0 0 0 9ex}
+  .mp h1,.mp h2,.mp h3,.mp h4 {clear:left}
+  .mp pre {margin-bottom:20px}
+  .mp pre+h2,.mp pre+h3 {margin-top:22px}
+  .mp h2+pre,.mp h3+pre {margin-top:5px}
+  .mp img {display:block;margin:auto}
+  .mp h1.man-title {display:none}
+  .mp,.mp code,.mp pre,.mp tt,.mp kbd,.mp samp,.mp h3,.mp h4 {font-family:monospace;font-size:14px;line-height:1.42857142857143}
+  .mp h2 {font-size:16px;line-height:1.25}
+  .mp h1 {font-size:20px;line-height:2}
+  .mp,.mp code,.mp pre,.mp pre code,.mp tt,.mp kbd,.mp samp {color:#131211}
+  .mp h1,.mp h2,.mp h3,.mp h4 {color:#030201}
+  .mp u {text-decoration:underline}
+  .mp code,.mp strong,.mp b {font-weight:bold;color:#131211}
+  .mp em,.mp var {font-style:italic;color:#232221;text-decoration:none}
+  .mp a,.mp a:link,.mp a:hover,.mp a code,.mp a pre,.mp a tt,.mp a kbd,.mp a samp {color:#0000ff}
+  .mp b.man-ref {font-weight:normal;color:#434241}
+  .mp pre {padding:0 4ex}
+  .mp pre code {font-weight:normal;color:#434241}
+  .mp h2+pre,h3+pre {padding-left:0}
+  ol.man-decor,ol.man-decor li {margin:3px 0 10px 0;padding:0;float:left;width:33%;list-style-type:none;text-transform:uppercase;color:#999;letter-spacing:1px}
+  ol.man-decor {width:100%}
+  ol.man-decor li.tl {text-align:left}
+  ol.man-decor li.tc {text-align:center;letter-spacing:4px}
+  ol.man-decor li.tr {text-align:right;float:right}
+  </style>
+  <style type='text/css' media='all'>
+  /* style: 80c */
+  .mp {max-width:86ex}
+  ul {list-style: None; margin-left: 1em!important}
+  .man-navigation {left:101ex}
+  </style>
+</head>
+
+<body id='manpage'>
+
+
+<div class='mp'>
+<h1>go-httpbin(1)</h1>
+<p>A golang port of the venerable <a href="https://httpbin.org/">httpbin.org</a> HTTP request &amp; response testing service.</p>
+
+<h2 id="ENDPOINTS">ENDPOINTS</h2>
+
+<ul>
+<li><a href="{{.Prefix}}/"><code>{{.Prefix}}/</code></a> This page.</li>
+<li><a href="{{.Prefix}}/absolute-redirect/6"><code>{{.Prefix}}/absolute-redirect/:n</code></a> 302 Absolute redirects <em>n</em> times.</li>
+<li><a href="{{.Prefix}}/anything"><code>{{.Prefix}}/anything/:anything</code></a> Returns anything that is passed to request.</li>
+<li><a href="{{.Prefix}}/base64/aHR0cGJpbmdvLm9yZw=="><code>{{.Prefix}}/base64/:value</code></a> Decodes a Base64-encoded string.</li>
+<li><a href="{{.Prefix}}/base64/decode/aHR0cGJpbmdvLm9yZw=="><code>{{.Prefix}}/base64/decode/:value</code></a> Explicit URL for decoding a Base64 encoded string.</li>
+<li><a href="{{.Prefix}}/base64/encode/httpbingo.org"><code>{{.Prefix}}/base64/encode/:value</code></a> Encodes a string into URL-safe Base64.</li>
+<li><a href="{{.Prefix}}/basic-auth/user/passwd"><code>{{.Prefix}}/basic-auth/:user/:passwd</code></a> Challenges HTTPBasic Auth.</li>
+<li><a href="{{.Prefix}}/bearer"><code>{{.Prefix}}/bearer</code></a> Checks Bearer token header - returns 401 if not set.</li>
+<li><a href="{{.Prefix}}/brotli"><code><del>{{.Prefix}}/brotli</del></code></a> Returns brotli-encoded data.</del> <i>Not implemented!</i></li>
+<li><a href="{{.Prefix}}/bytes/1024"><code>{{.Prefix}}/bytes/:n</code></a> Generates <em>n</em> random bytes of binary data, accepts optional <em>seed</em> integer parameter.</li>
+<li><a href="{{.Prefix}}/cache"><code>{{.Prefix}}/cache</code></a> Returns 200 unless an If-Modified-Since or If-None-Match header is provided, when it returns a 304.</li>
+<li><a href="{{.Prefix}}/cache/60"><code>{{.Prefix}}/cache/:n</code></a> Sets a Cache-Control header for <em>n</em> seconds.</li>
+<li><a href="{{.Prefix}}/cookies"><code>{{.Prefix}}/cookies</code></a> Returns cookie data.</li>
+<li><a href="{{.Prefix}}/cookies/delete?k1=&amp;k2="><code>{{.Prefix}}/cookies/delete?name</code></a> Deletes one or more simple cookies.</li>
+<li><a href="{{.Prefix}}/cookies/set?k1=v1&amp;k2=v2"><code>{{.Prefix}}/cookies/set?name=value</code></a> Sets one or more simple cookies.</li>
+<li><a href="{{.Prefix}}/deflate"><code>{{.Prefix}}/deflate</code></a> Returns deflate-encoded data.</li>
+<li><a href="{{.Prefix}}/delay/3"><code>{{.Prefix}}/delay/:n</code></a> Delays responding for <em>min(n, 10)</em> seconds.</li>
+<li><code>{{.Prefix}}/delete</code> Returns request data.  Allows only <code>DELETE</code> requests.</li>
+<li><a href="{{.Prefix}}/deny"><code>{{.Prefix}}/deny</code></a> Denied by robots.txt file.</li>
+<li><a href="{{.Prefix}}/digest-auth/auth/user/passwd/MD5"><code>{{.Prefix}}/digest-auth/:qop/:user/:passwd/:algorithm</code></a> Challenges HTTP Digest Auth.</li>
+<li><a href="{{.Prefix}}/digest-auth/auth/user/passwd/MD5"><code>{{.Prefix}}/digest-auth/:qop/:user/:passwd</code></a> Challenges HTTP Digest Auth.</li>
+<li><a href="{{.Prefix}}/drip?code=200&amp;numbytes=5&amp;duration=5"><code>{{.Prefix}}/drip?numbytes=n&amp;duration=s&amp;delay=s&amp;code=code</code></a> Drips data over a duration after an optional initial delay, then (optionally) returns with the given status code.</li>
+<li><a href="{{.Prefix}}/dump/request"><code>{{.Prefix}}/dump/request</code></a> Returns the given request in its HTTP/1.x wire approximate representation.</li>
+<li><a href="{{.Prefix}}/encoding/utf8"><code>{{.Prefix}}/encoding/utf8</code></a> Returns page containing UTF-8 data.</li>
+<li><a href="{{.Prefix}}/etag/etag"><code>{{.Prefix}}/etag/:etag</code></a> Assumes the resource has the given etag and responds to If-None-Match header with a 200 or 304 and If-Match with a 200 or 412 as appropriate.</li>
+<li><a href="{{.Prefix}}/forms/post"><code>{{.Prefix}}/forms/post</code></a> HTML form that submits to <em>{{.Prefix}}/post</em></li>
+<li><a href="{{.Prefix}}/get"><code>{{.Prefix}}/get</code></a> Returns GET data.</li>
+<li><a href="{{.Prefix}}/gzip"><code>{{.Prefix}}/gzip</code></a> Returns gzip-encoded data.</li>
+<li><code>{{.Prefix}}/head</code> Returns response headers.  Allows only <code>HEAD</code> requests.</li>
+<li><a href="{{.Prefix}}/headers"><code>{{.Prefix}}/headers</code></a> Returns request header dict.</li>
+<li><a href="{{.Prefix}}/hidden-basic-auth/user/passwd"><code>{{.Prefix}}/hidden-basic-auth/:user/:passwd</code></a> 404'd BasicAuth.</li>
+<li><a href="{{.Prefix}}/html"><code>{{.Prefix}}/html</code></a> Renders an HTML Page.</li>
+<li><a href="{{.Prefix}}/hostname"><code>{{.Prefix}}/hostname</code></a> Returns the name of the host serving the request.</li>
+<li><a href="{{.Prefix}}/image"><code>{{.Prefix}}/image</code></a> Returns page containing an image based on sent Accept header.</li>
+<li><a href="{{.Prefix}}/image/jpeg"><code>{{.Prefix}}/image/jpeg</code></a> Returns a JPEG image.</li>
+<li><a href="{{.Prefix}}/image/png"><code>{{.Prefix}}/image/png</code></a> Returns a PNG image.</li>
+<li><a href="{{.Prefix}}/image/svg"><code>{{.Prefix}}/image/svg</code></a> Returns a SVG image.</li>
+<li><a href="{{.Prefix}}/image/webp"><code>{{.Prefix}}/image/webp</code></a> Returns a WEBP image.</li>
+<li><a href="{{.Prefix}}/ip"><code>{{.Prefix}}/ip</code></a> Returns Origin IP.</li>
+<li><a href="{{.Prefix}}/json"><code>{{.Prefix}}/json</code></a> Returns JSON.</li>
+<li><a href="{{.Prefix}}/links/10"><code>{{.Prefix}}/links/:n</code></a> Returns page containing <em>n</em> HTML links.</li>
+<li><code>{{.Prefix}}/patch</code> Returns request data.  Allows only <code>PATCH</code> requests.</li>
+<li><code>{{.Prefix}}/post</code> Returns request data.  Allows only <code>POST</code> requests.</li>
+<li><code>{{.Prefix}}/put</code> Returns request data.  Allows only <code>PUT</code> requests.</li>
+<li><a href="{{.Prefix}}/range/1024"><code>{{.Prefix}}/range/1024?duration=s&amp;chunk_size=code</code></a> Streams <em>n</em> bytes, and allows specifying a <em>Range</em> header to select a subset of the data. Accepts a <em>chunk_size</em> and request <em>duration</em> parameter.</li>
+<li><a href="{{.Prefix}}/redirect-to?status_code=307&amp;url=http%3A%2F%2Fexample.com%2F"><code>{{.Prefix}}/redirect-to?url=foo&status_code=307</code></a> 307 Redirects to the <em>foo</em> URL.</li>
+<li><a href="{{.Prefix}}/redirect-to?url=http%3A%2F%2Fexample.com%2F"><code>{{.Prefix}}/redirect-to?url=foo</code></a> 302 Redirects to the <em>foo</em> URL.</li>
+<li><a href="{{.Prefix}}/redirect/6"><code>{{.Prefix}}/redirect/:n</code></a> 302 Redirects <em>n</em> times.</li>
+<li><a href="{{.Prefix}}/relative-redirect/6"><code>{{.Prefix}}/relative-redirect/:n</code></a> 302 Relative redirects <em>n</em> times.</li>
+<li><a href="{{.Prefix}}/response-headers?Server=httpbin&amp;Content-Type=text%2Fplain%3B+charset%3DUTF-8"><code>{{.Prefix}}/response-headers?key=val</code></a> Returns given response headers.</li>
+<li><a href="{{.Prefix}}/robots.txt"><code>{{.Prefix}}/robots.txt</code></a> Returns some robots.txt rules.</li>
+<li><a href="{{.Prefix}}/status/418"><code>{{.Prefix}}/status/:code</code></a> Returns given HTTP Status code.</li>
+<li><a href="{{.Prefix}}/stream-bytes/1024"><code>{{.Prefix}}/stream-bytes/:n</code></a> Streams <em>n</em> random bytes of binary data, accepts optional <em>seed</em> and <em>chunk_size</em> integer parameters.</li>
+<li><a href="{{.Prefix}}/stream/20"><code>{{.Prefix}}/stream/:n</code></a> Streams <em>min(n, 100)</em> lines.</li>
+<li><a href="{{.Prefix}}/unstable"><code>{{.Prefix}}/unstable</code></a> Fails half the time, accepts optional <em>failure_rate</em> float and <em>seed</em> integer parameters.</li>
+<li><a href="{{.Prefix}}/user-agent"><code>{{.Prefix}}/user-agent</code></a> Returns user-agent.</li>
+<li><a href="{{.Prefix}}/uuid"><code>{{.Prefix}}/uuid</code></a> Generates a <a href="https://en.wikipedia.org/wiki/Universally_unique_identifier">UUIDv4</a> value.</li>
+<li><a href="{{.Prefix}}/websocket/echo?max_fragment_size=2048&amp;max_message_size=10240"><code>{{.Prefix}}/websocket/echo?max_fragment_size=2048&amp;max_message_size=10240</code></a> A WebSocket echo service.</li>
+<li><a href="{{.Prefix}}/xml"><code>{{.Prefix}}/xml</code></a> Returns some XML</li>
+</ul>
+
+<h2 id="DESCRIPTION">DESCRIPTION</h2>
+
+<p>Testing an HTTP Library can become difficult sometimes. <a href="http://requestb.in">RequestBin</a> is fantastic for testing POST requests, but doesn't let you control the response. This exists to cover all kinds of HTTP scenarios. Additional endpoints are being considered.</p>
+
+<p>All endpoint responses are JSON-encoded.</p>
+
+<h2 id="EXAMPLES">EXAMPLES</h2>
+
+<h3 id="-curl-http-httpbin-org-ip">$ curl https://httpbingo.org/ip</h3>
+
+<pre><code>{"origin":"73.238.9.52, 77.83.142.42"}
+</code></pre>
+
+<h3 id="-curl-http-httpbin-org-user-agent">$ curl https://httpbingo.org/user-agent</h3>
+
+<pre><code>{"user-agent":"curl/7.64.1"}</code></pre>
+
+<h3 id="-curl-http-httpbin-org-get">$ curl https://httpbingo.org/get?foo=bar</h3>
+
+<pre><code>{
+  "args": {
+    "foo": [
+      "bar"
+    ]
+  },
+  "headers": {
+    "Accept": [
+      "*/*"
+    ],
+    "Host": [
+      "httpbingo.org"
+    ],
+    "User-Agent": [
+      "curl/7.64.1"
+    ]
+  },
+  "origin": "73.238.9.52, 77.83.142.42",
+  "url": "https://httpbingo.org/get?foo=bar"
+}
+</code></pre>
+
+<h3 id="-curl-http-httpbin-org-dump-request">$ curl https://httpbingo.org/dump/request?foo=bar</h3>
+
+<pre><code>GET /dump/request?foo=bar HTTP/1.1
+Host: httpbingo.org
+Accept: */*
+User-Agent: curl/7.64.1
+</code></pre>
+
+<h3 id="-curl-I-http-httpbin-org-status-418">$ curl -I https://httpbingo.org/status/418</h3>
+
+<pre><code>HTTP/1.1 418 I'm a teapot
+Access-Control-Allow-Credentials: true
+Access-Control-Allow-Origin: *
+X-More-Info: http://tools.ietf.org/html/rfc2324
+Date: Tue, 13 Jul 2021 13:12:37 GMT
+Content-Length: 0
+</code></pre>
+
+
+<h2 id="AUTHOR">AUTHOR</h2>
+
+<p>Ported to Go by <a href="https://github.com/mccutchen">Will McCutchen</a>.</p>
+<p>From <a href="https://httpbin.org/">the original</a> <a href="https://kennethreitz.org/">Kenneth Reitz</a> project.</p>
+
+<h2 id="SEE-ALSO">SEE ALSO</h2>
+
+<p><a href="https://httpbin.org/">httpbin.org</a> &mdash; the original httpbin</p>
+
+</div>
+
+<a href="https://github.com/mccutchen/go-httpbin"><img style="position: absolute; top: 0; right: 0; border: 0;" src="https://camo.githubusercontent.com/38ef81f8aca64bb9a64448d0d70f1308ef5341ab/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732f666f726b6d655f72696768745f6461726b626c75655f3132313632312e706e67" alt="Fork me on GitHub" data-canonical-src="https://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png"></a>
+
+</body>
+</html>
diff --git a/httpbin/static_assets.go b/httpbin/static_assets.go
index 70b3109..bbc361b 100644
--- a/httpbin/static_assets.go
+++ b/httpbin/static_assets.go
@@ -1,16 +1,23 @@
 package httpbin
 
 import (
+	"bytes"
 	"embed"
 	"path"
+	"text/template"
 )
 
 //go:embed static/*
 var staticAssets embed.FS
 
+// return full name of static assert
+func staticName(name string) string {
+	return path.Join("static", name)
+}
+
 // staticAsset loads an embedded static asset by name.
 func staticAsset(name string) ([]byte, error) {
-	return staticAssets.ReadFile(path.Join("static", name))
+	return staticAssets.ReadFile(staticName(name))
 }
 
 // mustStaticAsset loads an embedded static asset by name, panicking on error.
@@ -21,3 +28,23 @@ func mustStaticAsset(name string) []byte {
 	}
 	return b
 }
+
+func (h *HTTPBin) staticTemplateAssert(name string) []byte {
+	t, err := template.ParseFS(staticAssets, staticName(name))
+	if err != nil {
+		panic(err)
+	}
+
+	var buf bytes.Buffer
+
+	d := &struct {
+		Prefix string
+	}{h.prefix}
+
+	err = t.Execute(&buf, d)
+	if err != nil {
+		panic(err)
+	}
+
+	return buf.Bytes()
+}


Test patch:
diff --git a/httpbin/cmd/cmd_test.go b/httpbin/cmd/cmd_test.go
index 4a74153..e00c588 100644
--- a/httpbin/cmd/cmd_test.go
+++ b/httpbin/cmd/cmd_test.go
@@ -13,7 +13,10 @@ import (
 )
 
 // To update, run:
+// OSX:
 // make && ./dist/go-httpbin -h 2>&1 | pbcopy
+// Linux (paste with middle mouse):
+// make && ./dist/go-httpbin -h 2>&1 | xclip
 const usage = `Usage of go-httpbin:
   -allowed-redirect-domains string
     	Comma-separated list of domains the /redirect-to endpoint will allow
@@ -31,6 +34,8 @@ const usage = `Usage of go-httpbin:
     	Maximum duration a response may take (default 10s)
   -port int
     	Port to listen on (default 8080)
+  -prefix string
+    	Path prefix (empty or start with slash and does not end with slash)
   -use-real-hostname
     	Expose value of os.Hostname() in the /hostname endpoint instead of dummy value
 `
@@ -212,6 +217,37 @@ func TestLoadConfig(t *testing.T) {
 			},
 		},
 
+		// prefix
+		"invalid -prefix (does not start with slash)": {
+			args:    []string{"-prefix", "invalidprefix1"},
+			wantErr: errors.New("Prefix \"invalidprefix1\" must start with a slash"),
+		},
+		"invalid -prefix (ends with with slash)": {
+			args:    []string{"-prefix", "/invalidprefix2/"},
+			wantErr: errors.New("Prefix \"/invalidprefix2/\" must not end with a slash"),
+		},
+		"ok -prefix takes precedence over env": {
+			args: []string{"-prefix", "/prefix1"},
+			env:  map[string]string{"PREFIX": "/prefix2"},
+			wantCfg: &config{
+				ListenHost:  defaultListenHost,
+				ListenPort:  defaultListenPort,
+				Prefix:      "/prefix1",
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+		"ok PREFIX": {
+			env: map[string]string{"PREFIX": "/prefix2"},
+			wantCfg: &config{
+				ListenHost:  defaultListenHost,
+				ListenPort:  defaultListenPort,
+				Prefix:      "/prefix2",
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+
 		// https cert file
 		"https cert and key must both be provided, cert only": {
 			args:    []string{"-https-cert-file", "/tmp/test.crt"},
diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go
index 8dafb85..c8eb939 100644
--- a/httpbin/handlers_test.go
+++ b/httpbin/handlers_test.go
@@ -33,21 +33,27 @@ import (
 const (
 	maxBodySize int64         = 1024
 	maxDuration time.Duration = 1 * time.Second
+	testPrefix                = "/a-prefix"
 )
 
+type environment struct {
+	prefix string
+	srv    *httptest.Server
+	client *http.Client
+}
+
 // "Global" test app, server, & client to be reused across test cases.
 // Initialized in TestMain.
 var (
-	app    *HTTPBin
-	srv    *httptest.Server
-	client *http.Client
+	app        *HTTPBin
+	srv        *httptest.Server
+	client     *http.Client
+	envs       []*environment
+	defaultEnv *environment
 )
 
-func TestMain(m *testing.M) {
-	// enable additional safety checks
-	testMode = true
-
-	app = New(
+func createApp(opts ...OptionFunc) *HTTPBin {
+	return New(append(append(make([]OptionFunc, 0, 6+len(opts)),
 		WithAllowedRedirectDomains([]string{
 			"httpbingo.org",
 			"example.org",
@@ -61,38 +67,60 @@ func TestMain(m *testing.M) {
 		WithMaxBodySize(maxBodySize),
 		WithMaxDuration(maxDuration),
 		WithObserver(StdLogObserver(log.New(io.Discard, "", 0))),
-		WithExcludeHeaders("x-ignore-*,x-info-this-key"),
-	)
-	srv, client = newTestServer(app)
-	defer srv.Close()
+		WithExcludeHeaders("x-ignore-*,x-info-this-key")),
+		opts...)...)
+}
+
+func TestMain(m *testing.M) {
+	// enable additional safety checks
+	testMode = true
+
+	var env *environment
+	app = createApp()
+	env = newTestEnvironment(app)
+	defer env.srv.Close()
+	srv = env.srv
+	client = env.client
+	envs = append(envs, env)
+	defaultEnv = env
+
+	env = newTestEnvironment(createApp(WithPrefix(testPrefix)))
+	defer env.srv.Close()
+	envs = append(envs, env)
+
 	os.Exit(m.Run())
 }
 
 func TestIndex(t *testing.T) {
-	t.Run("ok", func(t *testing.T) {
-		t.Parallel()
+	for _, env := range envs {
+		env := env
+		t.Run("ok"+env.prefix, func(t *testing.T) {
+			t.Parallel()
 
-		req := newTestRequest(t, "GET", "/")
-		resp := must.DoReq(t, client, req)
+			req := newTestRequest(t, "GET", env.prefix+"/", env)
+			resp := must.DoReq(t, env.client, req)
 
-		assert.ContentType(t, resp, htmlContentType)
-		assert.Header(t, resp, "Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' camo.githubusercontent.com")
-		assert.BodyContains(t, resp, "go-httpbin")
-	})
+			assert.ContentType(t, resp, htmlContentType)
+			assert.Header(t, resp, "Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' camo.githubusercontent.com")
+			body := must.ReadAll(t, resp.Body)
+			assert.Contains(t, body, "go-httpbin", "body")
+			assert.Contains(t, body, env.prefix+"/get", "body")
+		})
 
-	t.Run("not found", func(t *testing.T) {
-		t.Parallel()
-		req := newTestRequest(t, "GET", "/foo")
-		resp := must.DoReq(t, client, req)
-		assert.StatusCode(t, resp, http.StatusNotFound)
-		assert.ContentType(t, resp, jsonContentType)
-		got := must.Unmarshal[errorRespnose](t, resp.Body)
-		want := errorRespnose{
-			StatusCode: http.StatusNotFound,
-			Error:      "Not Found",
-		}
-		assert.DeepEqual(t, got, want, "incorrect error response")
-	})
+		t.Run("not found"+env.prefix, func(t *testing.T) {
+			t.Parallel()
+			req := newTestRequest(t, "GET", env.prefix+"/foo", env)
+			resp := must.DoReq(t, env.client, req)
+			assert.StatusCode(t, resp, http.StatusNotFound)
+			assert.ContentType(t, resp, jsonContentType)
+			got := must.Unmarshal[errorRespnose](t, resp.Body)
+			want := errorRespnose{
+				StatusCode: http.StatusNotFound,
+				Error:      "Not Found",
+			}
+			assert.DeepEqual(t, got, want, "incorrect error response")
+		})
+	}
 }
 
 func TestFormsPost(t *testing.T) {
@@ -1197,77 +1225,87 @@ func TestRedirects(t *testing.T) {
 		requestURL       string
 		expectedLocation string
 	}{
-		{"/redirect/1", "/get"},
-		{"/redirect/2", "/relative-redirect/1"},
-		{"/redirect/100", "/relative-redirect/99"},
-
-		{"/redirect/1?absolute=true", "http://host/get"},
-		{"/redirect/2?absolute=TRUE", "http://host/absolute-redirect/1"},
-		{"/redirect/100?absolute=True", "http://host/absolute-redirect/99"},
-
-		{"/redirect/100?absolute=t", "/relative-redirect/99"},
-		{"/redirect/100?absolute=1", "/relative-redirect/99"},
-		{"/redirect/100?absolute=yes", "/relative-redirect/99"},
-
-		{"/relative-redirect/1", "/get"},
-		{"/relative-redirect/2", "/relative-redirect/1"},
-		{"/relative-redirect/100", "/relative-redirect/99"},
-
-		{"/absolute-redirect/1", "http://host/get"},
-		{"/absolute-redirect/2", "http://host/absolute-redirect/1"},
-		{"/absolute-redirect/100", "http://host/absolute-redirect/99"},
-	}
-
-	for _, test := range tests {
-		test := test
-		t.Run("ok"+test.requestURL, func(t *testing.T) {
-			t.Parallel()
+		// append %.s to expected string if result does not contain prefix:
+		// https://stackoverflow.com/a/41209086
+		{"%s/redirect/1", "%s/get"},
+		{"%s/redirect/2", "%s/relative-redirect/1"},
+		{"%s/redirect/100", "%s/relative-redirect/99"},
+
+		{"%s/redirect/1?absolute=true", "http://host/get%.s"},
+		{"%s/redirect/2?absolute=TRUE", "http://host/absolute-redirect/1%.s"},
+		{"%s/redirect/100?absolute=True", "http://host/absolute-redirect/99%.s"},
+
+		{"%s/redirect/100?absolute=t", "%s/relative-redirect/99"},
+		{"%s/redirect/100?absolute=1", "%s/relative-redirect/99"},
+		{"%s/redirect/100?absolute=yes", "%s/relative-redirect/99"},
+
+		{"%s/relative-redirect/1", "%s/get"},
+		{"%s/relative-redirect/2", "%s/relative-redirect/1"},
+		{"%s/relative-redirect/100", "%s/relative-redirect/99"},
+
+		{"%s/absolute-redirect/1", "http://host/get%.s"},
+		{"%s/absolute-redirect/2", "http://host/absolute-redirect/1%.s"},
+		{"%s/absolute-redirect/100", "http://host/absolute-redirect/99%.s"},
+	}
+
+	for _, env := range envs {
+		for _, test := range tests {
+			env := env
+			test := test
+			requestURL := fmt.Sprintf(test.requestURL, env.prefix)
+			t.Run("ok"+requestURL, func(t *testing.T) {
+				t.Parallel()
 
-			req := newTestRequest(t, "GET", test.requestURL)
-			req.Host = "host"
-			resp := must.DoReq(t, client, req)
-			defer consumeAndCloseBody(resp)
+				req := newTestRequest(t, "GET", requestURL, env)
+				req.Host = "host"
+				resp := must.DoReq(t, env.client, req)
+				defer consumeAndCloseBody(resp)
 
-			assert.StatusCode(t, resp, http.StatusFound)
-			assert.Header(t, resp, "Location", test.expectedLocation)
-		})
+				assert.StatusCode(t, resp, http.StatusFound)
+				assert.Header(t, resp, "Location", fmt.Sprintf(test.expectedLocation, env.prefix))
+			})
+		}
 	}
 
 	errorTests := []struct {
 		requestURL     string
 		expectedStatus int
 	}{
-		{"/redirect", http.StatusNotFound},
-		{"/redirect/", http.StatusBadRequest},
-		{"/redirect/-1", http.StatusBadRequest},
-		{"/redirect/3.14", http.StatusBadRequest},
-		{"/redirect/foo", http.StatusBadRequest},
-		{"/redirect/10/foo", http.StatusNotFound},
-
-		{"/relative-redirect", http.StatusNotFound},
-		{"/relative-redirect/", http.StatusBadRequest},
-		{"/relative-redirect/-1", http.StatusBadRequest},
-		{"/relative-redirect/3.14", http.StatusBadRequest},
-		{"/relative-redirect/foo", http.StatusBadRequest},
-		{"/relative-redirect/10/foo", http.StatusNotFound},
-
-		{"/absolute-redirect", http.StatusNotFound},
-		{"/absolute-redirect/", http.StatusBadRequest},
-		{"/absolute-redirect/-1", http.StatusBadRequest},
-		{"/absolute-redirect/3.14", http.StatusBadRequest},
-		{"/absolute-redirect/foo", http.StatusBadRequest},
-		{"/absolute-redirect/10/foo", http.StatusNotFound},
-	}
-
-	for _, test := range errorTests {
-		test := test
-		t.Run("error"+test.requestURL, func(t *testing.T) {
-			t.Parallel()
-			req := newTestRequest(t, "GET", test.requestURL)
-			resp := must.DoReq(t, client, req)
-			defer consumeAndCloseBody(resp)
-			assert.StatusCode(t, resp, test.expectedStatus)
-		})
+		{"%s/redirect", http.StatusNotFound},
+		{"%s/redirect/", http.StatusBadRequest},
+		{"%s/redirect/-1", http.StatusBadRequest},
+		{"%s/redirect/3.14", http.StatusBadRequest},
+		{"%s/redirect/foo", http.StatusBadRequest},
+		{"%s/redirect/10/foo", http.StatusNotFound},
+
+		{"%s/relative-redirect", http.StatusNotFound},
+		{"%s/relative-redirect/", http.StatusBadRequest},
+		{"%s/relative-redirect/-1", http.StatusBadRequest},
+		{"%s/relative-redirect/3.14", http.StatusBadRequest},
+		{"%s/relative-redirect/foo", http.StatusBadRequest},
+		{"%s/relative-redirect/10/foo", http.StatusNotFound},
+
+		{"%s/absolute-redirect", http.StatusNotFound},
+		{"%s/absolute-redirect/", http.StatusBadRequest},
+		{"%s/absolute-redirect/-1", http.StatusBadRequest},
+		{"%s/absolute-redirect/3.14", http.StatusBadRequest},
+		{"%s/absolute-redirect/foo", http.StatusBadRequest},
+		{"%s/absolute-redirect/10/foo", http.StatusNotFound},
+	}
+
+	for _, env := range envs {
+		for _, test := range errorTests {
+			env := env
+			test := test
+			requestURL := fmt.Sprintf(test.requestURL, env.prefix)
+			t.Run("error"+requestURL, func(t *testing.T) {
+				t.Parallel()
+				req := newTestRequest(t, "GET", requestURL, env)
+				resp := must.DoReq(t, env.client, req)
+				defer consumeAndCloseBody(resp)
+				assert.StatusCode(t, resp, test.expectedStatus)
+			})
+		}
 	}
 }
 
@@ -1346,108 +1384,111 @@ func TestRedirectTo(t *testing.T) {
 }
 
 func TestCookies(t *testing.T) {
-	t.Run("get", func(t *testing.T) {
-		testCases := map[string]struct {
-			cookies cookiesResponse
-		}{
-			"ok/no cookies": {
-				cookies: cookiesResponse{},
-			},
-			"ok/one cookie": {
-				cookies: cookiesResponse{
-					"k1": "v1",
+	for _, env := range envs {
+		env := env
+		t.Run("get"+env.prefix, func(t *testing.T) {
+			testCases := map[string]struct {
+				cookies cookiesResponse
+			}{
+				"ok/no cookies": {
+					cookies: cookiesResponse{},
 				},
-			},
-			"ok/many cookies": {
-				cookies: cookiesResponse{
-					"k1": "v1",
-					"k2": "v2",
-					"k3": "v3",
+				"ok/one cookie": {
+					cookies: cookiesResponse{
+						"k1": "v1",
+					},
 				},
-			},
-		}
-
-		for name, tc := range testCases {
-			tc := tc
-			t.Run(name, func(t *testing.T) {
-				t.Parallel()
-
-				req := newTestRequest(t, "GET", "/cookies")
-				for k, v := range tc.cookies {
-					req.AddCookie(&http.Cookie{
-						Name:  k,
-						Value: v,
-					})
-				}
-
-				resp := must.DoReq(t, client, req)
-				defer consumeAndCloseBody(resp)
+				"ok/many cookies": {
+					cookies: cookiesResponse{
+						"k1": "v1",
+						"k2": "v2",
+						"k3": "v3",
+					},
+				},
+			}
 
-				result := mustParseResponse[cookiesResponse](t, resp)
-				assert.DeepEqual(t, result, tc.cookies, "cookies mismatch")
-			})
-		}
-	})
+			for name, tc := range testCases {
+				tc := tc
+				t.Run(name, func(t *testing.T) {
+					t.Parallel()
+
+					req := newTestRequest(t, "GET", env.prefix+"/cookies", env)
+					for k, v := range tc.cookies {
+						req.AddCookie(&http.Cookie{
+							Name:  k,
+							Value: v,
+						})
+					}
+
+					resp := must.DoReq(t, env.client, req)
+					defer consumeAndCloseBody(resp)
+
+					result := mustParseResponse[cookiesResponse](t, resp)
+					assert.DeepEqual(t, result, tc.cookies, "cookies mismatch")
+				})
+			}
+		})
 
-	t.Run("set", func(t *testing.T) {
-		t.Parallel()
+		t.Run("set"+env.prefix, func(t *testing.T) {
+			t.Parallel()
 
-		cookies := cookiesResponse{
-			"k1": "v1",
-			"k2": "v2",
-		}
-		params := &url.Values{}
-		for k, v := range cookies {
-			params.Set(k, v)
-		}
+			cookies := cookiesResponse{
+				"k1": "v1",
+				"k2": "v2",
+			}
+			params := &url.Values{}
+			for k, v := range cookies {
+				params.Set(k, v)
+			}
 
-		req := newTestRequest(t, "GET", "/cookies/set?"+params.Encode())
-		resp := must.DoReq(t, client, req)
+			req := newTestRequest(t, "GET", env.prefix+"/cookies/set?"+params.Encode(), env)
+			resp := must.DoReq(t, client, req)
 
-		assert.StatusCode(t, resp, http.StatusFound)
-		assert.Header(t, resp, "Location", "/cookies")
+			assert.StatusCode(t, resp, http.StatusFound)
+			assert.Header(t, resp, "Location", env.prefix+"/cookies")
 
-		for _, c := range resp.Cookies() {
-			v, ok := cookies[c.Name]
-			if !ok {
-				t.Fatalf("got unexpected cookie %s=%s", c.Name, c.Value)
+			for _, c := range resp.Cookies() {
+				v, ok := cookies[c.Name]
+				if !ok {
+					t.Fatalf("got unexpected cookie %s=%s", c.Name, c.Value)
+				}
+				assert.Equal(t, v, c.Value, "value mismatch for cookie %q", c.Name)
 			}
-			assert.Equal(t, v, c.Value, "value mismatch for cookie %q", c.Name)
-		}
-	})
+		})
 
-	t.Run("delete", func(t *testing.T) {
-		t.Parallel()
+		t.Run("delete"+env.prefix, func(t *testing.T) {
+			t.Parallel()
 
-		cookies := cookiesResponse{
-			"k1": "v1",
-			"k2": "v2",
-		}
+			cookies := cookiesResponse{
+				"k1": "v1",
+				"k2": "v2",
+			}
 
-		toDelete := "k2"
-		params := &url.Values{}
-		params.Set(toDelete, "")
+			toDelete := "k2"
+			params := &url.Values{}
+			params.Set(toDelete, "")
 
-		req := newTestRequest(t, "GET", "/cookies/delete?"+params.Encode())
-		for k, v := range cookies {
-			req.AddCookie(&http.Cookie{
-				Name:  k,
-				Value: v,
-			})
-		}
+			req := newTestRequest(t, "GET", env.prefix+"/cookies/delete?"+params.Encode(), env)
+			for k, v := range cookies {
+				req.AddCookie(&http.Cookie{
+					Name:  k,
+					Value: v,
+				})
+			}
 
-		resp := must.DoReq(t, client, req)
-		assert.StatusCode(t, resp, http.StatusFound)
-		assert.Header(t, resp, "Location", "/cookies")
+			resp := must.DoReq(t, env.client, req)
+			assert.StatusCode(t, resp, http.StatusFound)
+			assert.Header(t, resp, "Location", env.prefix+"/cookies")
 
-		for _, c := range resp.Cookies() {
-			if c.Name == toDelete {
-				if time.Since(c.Expires) < (24*365-1)*time.Hour {
-					t.Fatalf("expected cookie %s to be deleted; got %#v", toDelete, c)
+			for _, c := range resp.Cookies() {
+				if c.Name == toDelete {
+					if time.Since(c.Expires) < (24*365-1)*time.Hour {
+						t.Fatalf("expected cookie %s to be deleted; got %#v", toDelete, c)
+					}
 				}
 			}
-		}
-	})
+		})
+	}
 }
 
 func TestBasicAuth(t *testing.T) {
@@ -2521,76 +2562,81 @@ func TestStreamBytes(t *testing.T) {
 }
 
 func TestLinks(t *testing.T) {
-	redirectTests := []struct {
-		url              string
-		expectedLocation string
-	}{
-		{"/links/1", "/links/1/0"},
-		{"/links/100", "/links/100/0"},
-	}
+	for _, env := range envs {
+		env := env
 
-	for _, test := range redirectTests {
-		test := test
-		t.Run("ok"+test.url, func(t *testing.T) {
-			t.Parallel()
-			req := newTestRequest(t, "GET", test.url)
-			resp := must.DoReq(t, client, req)
-			defer consumeAndCloseBody(resp)
-			assert.StatusCode(t, resp, http.StatusFound)
-			assert.Header(t, resp, "Location", test.expectedLocation)
-		})
-	}
+		redirectTests := []struct {
+			url              string
+			expectedLocation string
+		}{
+			{"/links/1", "/links/1/0"},
+			{"/links/100", "/links/100/0"},
+		}
 
-	errorTests := []struct {
-		url            string
-		expectedStatus int
-	}{
-		{"/links/10/1/foo", http.StatusNotFound},
+		for _, test := range redirectTests {
+			test := test
+			t.Run("ok"+env.prefix+test.url, func(t *testing.T) {
+				t.Parallel()
+				req := newTestRequest(t, "GET", env.prefix+test.url, env)
+				resp := must.DoReq(t, env.client, req)
+				defer consumeAndCloseBody(resp)
+				assert.StatusCode(t, resp, http.StatusFound)
+				assert.Header(t, resp, "Location", env.prefix+test.expectedLocation)
+			})
+		}
 
-		// invalid N
-		{"/links/3.14", http.StatusBadRequest},
-		{"/links/-1", http.StatusBadRequest},
-		{"/links/257", http.StatusBadRequest},
+		errorTests := []struct {
+			url            string
+			expectedStatus int
+		}{
+			{"/links/10/1/foo", http.StatusNotFound},
 
-		// invalid offset
-		{"/links/1/3.14", http.StatusBadRequest},
-		{"/links/1/foo", http.StatusBadRequest},
-	}
+			// invalid N
+			{"/links/3.14", http.StatusBadRequest},
+			{"/links/-1", http.StatusBadRequest},
+			{"/links/257", http.StatusBadRequest},
 
-	for _, test := range errorTests {
-		test := test
-		t.Run("error"+test.url, func(t *testing.T) {
-			t.Parallel()
-			req := newTestRequest(t, "GET", test.url)
-			resp := must.DoReq(t, client, req)
-			defer consumeAndCloseBody(resp)
-			assert.StatusCode(t, resp, test.expectedStatus)
-		})
-	}
+			// invalid offset
+			{"/links/1/3.14", http.StatusBadRequest},
+			{"/links/1/foo", http.StatusBadRequest},
+		}
 
-	linksPageTests := []struct {
-		url             string
-		expectedContent string
-	}{
-		{"/links/2/0", `<html><head><title>Links</title></head><body>0 <a href="/links/2/1">1</a> </body></html>`},
-		{"/links/2/1", `<html><head><title>Links</title></head><body><a href="/links/2/0">0</a> 1 </body></html>`},
+		for _, test := range errorTests {
+			test := test
+			t.Run("error"+env.prefix+test.url, func(t *testing.T) {
+				t.Parallel()
+				req := newTestRequest(t, "GET", env.prefix+test.url, env)
+				resp := must.DoReq(t, env.client, req)
+				defer consumeAndCloseBody(resp)
+				assert.StatusCode(t, resp, test.expectedStatus)
+			})
+		}
 
-		// offsets too large and too small are ignored
-		{"/links/2/2", `<html><head><title>Links</title></head><body><a href="/links/2/0">0</a> <a href="/links/2/1">1</a> </body></html>`},
-		{"/links/2/10", `<html><head><title>Links</title></head><body><a href="/links/2/0">0</a> <a href="/links/2/1">1</a> </body></html>`},
-		{"/links/2/-1", `<html><head><title>Links</title></head><body><a href="/links/2/0">0</a> <a href="/links/2/1">1</a> </body></html>`},
-	}
-	for _, test := range linksPageTests {
-		test := test
-		t.Run("ok"+test.url, func(t *testing.T) {
-			t.Parallel()
-			req := newTestRequest(t, "GET", test.url)
-			resp := must.DoReq(t, client, req)
-			defer consumeAndCloseBody(resp)
-			assert.StatusCode(t, resp, http.StatusOK)
-			assert.ContentType(t, resp, htmlContentType)
-			assert.BodyEquals(t, resp, test.expectedContent)
-		})
+		linksPageTests := []struct {
+			url             string
+			expectedContent string
+		}{
+			{"/links/2/0", `<html><head><title>Links</title></head><body>0 <a href="%[1]s/links/2/1">1</a> </body></html>`},
+			{"/links/2/1", `<html><head><title>Links</title></head><body><a href="%[1]s/links/2/0">0</a> 1 </body></html>`},
+
+			// offsets too large and too small are ignored
+			{"/links/2/2", `<html><head><title>Links</title></head><body><a href="%[1]s/links/2/0">0</a> <a href="%[1]s/links/2/1">1</a> </body></html>`},
+			{"/links/2/10", `<html><head><title>Links</title></head><body><a href="%[1]s/links/2/0">0</a> <a href="%[1]s/links/2/1">1</a> </body></html>`},
+			{"/links/2/-1", `<html><head><title>Links</title></head><body><a href="%[1]s/links/2/0">0</a> <a href="%[1]s/links/2/1">1</a> </body></html>`},
+		}
+		for _, test := range linksPageTests {
+			test := test
+			t.Run("ok"+env.prefix+test.url, func(t *testing.T) {
+				t.Parallel()
+				req := newTestRequest(t, "GET", env.prefix+test.url, env)
+				resp := must.DoReq(t, env.client, req)
+				defer consumeAndCloseBody(resp)
+				assert.StatusCode(t, resp, http.StatusOK)
+				assert.ContentType(t, resp, htmlContentType)
+				expectedContent := fmt.Sprintf(test.expectedContent, env.prefix)
+				assert.BodyEquals(t, resp, expectedContent)
+			})
+		}
 	}
 }
 
@@ -2982,7 +3028,6 @@ func TestWebSocketEcho(t *testing.T) {
 		})
 	}
 }
-
 func newTestServer(handler http.Handler) (*httptest.Server, *http.Client) {
 	srv := httptest.NewServer(handler)
 	client := srv.Client()
@@ -2993,14 +3038,30 @@ func newTestServer(handler http.Handler) (*httptest.Server, *http.Client) {
 	return srv, client
 }
 
-func newTestRequest(t *testing.T, verb, path string) *http.Request {
+func newTestEnvironment(app *HTTPBin) (env *environment) {
+	env = new(environment)
+	env.srv, env.client = newTestServer(app)
+	env.prefix = app.prefix
+	return
+}
+
+func newTestRequest(t *testing.T, verb, path string, envs ...*environment) *http.Request {
 	t.Helper()
-	return newTestRequestWithBody(t, verb, path, nil)
+	return newTestRequestWithBody(t, verb, path, nil, envs...)
 }
 
-func newTestRequestWithBody(t *testing.T, verb, path string, body io.Reader) *http.Request {
+func newTestRequestWithBody(t *testing.T, verb, path string, body io.Reader, envs ...*environment) *http.Request {
 	t.Helper()
-	req, err := http.NewRequest(verb, srv.URL+path, body)
+
+	var env *environment
+	if len(envs) == 0 {
+		env = defaultEnv
+	} else if len(envs) == 1 {
+		env = envs[0]
+	} else {
+		t.Fatal("Only zero or one environment are allowed")
+	}
+	req, err := http.NewRequest(verb, env.srv.URL+path, body)
 	assert.NilError(t, err)
 	return req
 }
diff --git a/internal/testing/assert/assert.go b/internal/testing/assert/assert.go
index 673773b..9f3064c 100644
--- a/internal/testing/assert/assert.go
+++ b/internal/testing/assert/assert.go
@@ -87,13 +87,19 @@ func ContentType(t *testing.T, resp *http.Response, contentType string) {
 	Header(t, resp, "Content-Type", contentType)
 }
 
+// expects needle in s
+func Contains(t *testing.T, s string, needle string, description string) {
+	t.Helper()
+	if !strings.Contains(s, needle) {
+		t.Fatalf("expected string %q in %s %q", needle, description, s)
+	}
+}
+
 // BodyContains asserts that a response body contains a specific substring.
 func BodyContains(t *testing.T, resp *http.Response, needle string) {
 	t.Helper()
 	body := must.ReadAll(t, resp.Body)
-	if !strings.Contains(body, needle) {
-		t.Fatalf("expected string %q in body %q", needle, body)
-	}
+	Contains(t, body, needle, "body")
 }
 
 // BodyEquals asserts that a response body is equal to a specific string.