diff --git a/cmd/go-httpbin/main.go b/cmd/go-httpbin/main.go
index cb188be4ff7d0be55b2c8619e72fb4a7c209e3ea..6cdcf927fdff62543cf4848f5bbc1202eee20fa5 100644
--- a/cmd/go-httpbin/main.go
+++ b/cmd/go-httpbin/main.go
@@ -1,188 +1,11 @@
 package main
 
 import (
-	"context"
-	"flag"
-	"fmt"
-	"log"
-	"net"
-	"net/http"
 	"os"
-	"os/signal"
-	"strconv"
-	"strings"
-	"syscall"
-	"time"
 
-	"github.com/mccutchen/go-httpbin/v2/httpbin"
-)
-
-const (
-	defaultHost = "0.0.0.0"
-	defaultPort = 8080
-)
-
-var (
-	allowedRedirectDomains string
-	host                   string
-	httpsCertFile          string
-	httpsKeyFile           string
-	maxBodySize            int64
-	maxDuration            time.Duration
-	port                   int
-	useRealHostname        bool
+	"github.com/mccutchen/go-httpbin/v2/httpbin/cmd"
 )
 
 func main() {
-	flag.BoolVar(&useRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value")
-	flag.DurationVar(&maxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take")
-	flag.Int64Var(&maxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes")
-	flag.IntVar(&port, "port", defaultPort, "Port to listen on")
-	flag.StringVar(&allowedRedirectDomains, "allowed-redirect-domains", "", "Comma-separated list of domains the /redirect-to endpoint will allow")
-	flag.StringVar(&host, "host", defaultHost, "Host to listen on")
-	flag.StringVar(&httpsCertFile, "https-cert-file", "", "HTTPS Server certificate file")
-	flag.StringVar(&httpsKeyFile, "https-key-file", "", "HTTPS Server private key file")
-	flag.Parse()
-
-	// Command line flags take precedence over environment vars, so we only
-	// check for environment vars if we have default values for our command
-	// line flags.
-	var err error
-	if maxBodySize == httpbin.DefaultMaxBodySize && os.Getenv("MAX_BODY_SIZE") != "" {
-		maxBodySize, err = strconv.ParseInt(os.Getenv("MAX_BODY_SIZE"), 10, 64)
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_BODY_SIZE: %s\n\n", os.Getenv("MAX_BODY_SIZE"), err)
-			flag.Usage()
-			os.Exit(1)
-		}
-	}
-	if maxDuration == httpbin.DefaultMaxDuration && os.Getenv("MAX_DURATION") != "" {
-		maxDuration, err = time.ParseDuration(os.Getenv("MAX_DURATION"))
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_DURATION: %s\n\n", os.Getenv("MAX_DURATION"), err)
-			flag.Usage()
-			os.Exit(1)
-		}
-	}
-	if host == defaultHost && os.Getenv("HOST") != "" {
-		host = os.Getenv("HOST")
-	}
-	if port == defaultPort && os.Getenv("PORT") != "" {
-		port, err = strconv.Atoi(os.Getenv("PORT"))
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var PORT: %s\n\n", os.Getenv("PORT"), err)
-			flag.Usage()
-			os.Exit(1)
-		}
-	}
-
-	if httpsCertFile == "" && os.Getenv("HTTPS_CERT_FILE") != "" {
-		httpsCertFile = os.Getenv("HTTPS_CERT_FILE")
-	}
-	if httpsKeyFile == "" && os.Getenv("HTTPS_KEY_FILE") != "" {
-		httpsKeyFile = os.Getenv("HTTPS_KEY_FILE")
-	}
-
-	var serveTLS bool
-	if httpsCertFile != "" || httpsKeyFile != "" {
-		serveTLS = true
-		if httpsCertFile == "" || httpsKeyFile == "" {
-			fmt.Fprintf(os.Stderr, "Error: https cert and key must both be provided\n\n")
-			flag.Usage()
-			os.Exit(1)
-		}
-	}
-
-	// useRealHostname will be true if either the `-use-real-hostname`
-	// arg is given on the command line or if the USE_REAL_HOSTNAME env var
-	// is one of "1" or "true".
-	if useRealHostnameEnv := os.Getenv("USE_REAL_HOSTNAME"); useRealHostnameEnv == "1" || useRealHostnameEnv == "true" {
-		useRealHostname = true
-	}
-
-	var allowedRedirectDomainsList []string
-	if allowedRedirectDomains == "" && os.Getenv("ALLOWED_REDIRECT_DOMAINS") != "" {
-		allowedRedirectDomains = os.Getenv("ALLOWED_REDIRECT_DOMAINS")
-	}
-	for _, domain := range strings.Split(allowedRedirectDomains, ",") {
-		if strings.TrimSpace(domain) != "" {
-			allowedRedirectDomainsList = append(allowedRedirectDomainsList, strings.TrimSpace(domain))
-		}
-	}
-
-	logger := log.New(os.Stderr, "", 0)
-
-	// A hacky log helper function to ensure that shutdown messages are
-	// formatted the same as other messages.  See StdLogObserver in
-	// httpbin/middleware.go for the format we're matching here.
-	serverLog := func(msg string, args ...interface{}) {
-		const (
-			logFmt  = "time=%q msg=%q"
-			dateFmt = "2006-01-02T15:04:05.9999"
-		)
-		logger.Printf(logFmt, time.Now().Format(dateFmt), fmt.Sprintf(msg, args...))
-	}
-
-	opts := []httpbin.OptionFunc{
-		httpbin.WithMaxBodySize(maxBodySize),
-		httpbin.WithMaxDuration(maxDuration),
-		httpbin.WithObserver(httpbin.StdLogObserver(logger)),
-	}
-	if useRealHostname {
-		hostname, err := os.Hostname()
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "Error: use-real-hostname=true but hostname lookup failed: %s\n", err)
-			os.Exit(1)
-		}
-		opts = append(opts, httpbin.WithHostname(hostname))
-	}
-	if len(allowedRedirectDomainsList) > 0 {
-		opts = append(opts, httpbin.WithAllowedRedirectDomains(allowedRedirectDomainsList))
-	}
-	h := httpbin.New(opts...)
-
-	listenAddr := net.JoinHostPort(host, strconv.Itoa(port))
-
-	server := &http.Server{
-		Addr:    listenAddr,
-		Handler: h.Handler(),
-	}
-
-	// shutdownCh triggers graceful shutdown on SIGINT or SIGTERM
-	shutdownCh := make(chan os.Signal, 1)
-	signal.Notify(shutdownCh, syscall.SIGINT, syscall.SIGTERM)
-
-	// exitCh will be closed when it is safe to exit, after graceful shutdown
-	exitCh := make(chan struct{})
-
-	go func() {
-		sig := <-shutdownCh
-		serverLog("shutdown started by signal: %s", sig)
-
-		shutdownTimeout := maxDuration + 1*time.Second
-		ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
-		defer cancel()
-
-		server.SetKeepAlivesEnabled(false)
-		if err := server.Shutdown(ctx); err != nil {
-			serverLog("shutdown error: %s", err)
-		}
-
-		close(exitCh)
-	}()
-
-	var listenErr error
-	if serveTLS {
-		serverLog("go-httpbin listening on https://%s", listenAddr)
-		listenErr = server.ListenAndServeTLS(httpsCertFile, httpsKeyFile)
-	} else {
-		serverLog("go-httpbin listening on http://%s", listenAddr)
-		listenErr = server.ListenAndServe()
-	}
-	if listenErr != nil && listenErr != http.ErrServerClosed {
-		logger.Fatalf("failed to listen: %s", listenErr)
-	}
-
-	<-exitCh
-	serverLog("shutdown finished")
+	os.Exit(cmd.Main())
 }
diff --git a/httpbin/cmd/cmd.go b/httpbin/cmd/cmd.go
new file mode 100644
index 0000000000000000000000000000000000000000..97d6736586c35484c25149932d5635b84fe66780
--- /dev/null
+++ b/httpbin/cmd/cmd.go
@@ -0,0 +1,267 @@
+package cmd
+
+import (
+	"bytes"
+	"context"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"net/http"
+	"os"
+	"os/signal"
+	"strconv"
+	"strings"
+	"syscall"
+	"time"
+
+	"github.com/mccutchen/go-httpbin/v2/httpbin"
+)
+
+const (
+	defaultListenHost = "0.0.0.0"
+	defaultListenPort = 8080
+
+	// Reasonable defaults for our http server
+	srvReadTimeout       = 5 * time.Second
+	srvReadHeaderTimeout = 1 * time.Second
+	srvMaxHeaderBytes    = 16 * 1024 // 16kb
+)
+
+// Main is the main entrypoint for the go-httpbin binary. See loadConfig() for
+// command line argument parsing.
+func Main() int {
+	return mainImpl(os.Args[1:], os.Getenv, os.Hostname, os.Stderr)
+}
+
+// mainImpl is the real implementation of Main(), extracted for better
+// testability.
+func mainImpl(args []string, getEnv func(string) string, getHostname func() (string, error), out io.Writer) int {
+	logger := log.New(out, "", 0)
+
+	cfg, err := loadConfig(args, getEnv, getHostname)
+	if err != nil {
+		if cfgErr, ok := err.(ConfigError); ok {
+			// for -h/-help, just print usage and exit without error
+			if cfgErr.Err == flag.ErrHelp {
+				fmt.Fprint(out, cfgErr.Usage)
+				return 0
+			}
+
+			// anything else indicates a problem with CLI arguments and/or
+			// environment vars, so print error and usage and exit with an
+			// error status.
+			//
+			// note: seems like there's consensus that an exit code of 2 is
+			// often used to indicate a problem with the way a command was
+			// called, e.g.:
+			// https://stackoverflow.com/a/40484670/151221
+			// https://linuxconfig.org/list-of-exit-codes-on-linux
+			fmt.Fprintf(out, "error: %s\n\n%s", cfgErr.Err, cfgErr.Usage)
+			return 2
+		}
+		fmt.Fprintf(out, "error: %s", err)
+		return 1
+	}
+
+	opts := []httpbin.OptionFunc{
+		httpbin.WithMaxBodySize(cfg.MaxBodySize),
+		httpbin.WithMaxDuration(cfg.MaxDuration),
+		httpbin.WithObserver(httpbin.StdLogObserver(logger)),
+	}
+	if cfg.RealHostname != "" {
+		opts = append(opts, httpbin.WithHostname(cfg.RealHostname))
+	}
+	if len(cfg.AllowedRedirectDomains) > 0 {
+		opts = append(opts, httpbin.WithAllowedRedirectDomains(cfg.AllowedRedirectDomains))
+	}
+	app := httpbin.New(opts...)
+
+	srv := &http.Server{
+		Addr:              net.JoinHostPort(cfg.ListenHost, strconv.Itoa(cfg.ListenPort)),
+		Handler:           app.Handler(),
+		MaxHeaderBytes:    srvMaxHeaderBytes,
+		ReadHeaderTimeout: srvReadHeaderTimeout,
+		ReadTimeout:       srvReadTimeout,
+	}
+
+	if err := listenAndServeGracefully(srv, cfg, logger); err != nil {
+		logger.Printf("error: %s", err)
+		return 1
+	}
+
+	return 0
+}
+
+// config holds the configuration needed to initialize and run go-httpbin as a
+// standalone server.
+type config struct {
+	AllowedRedirectDomains []string
+	ListenHost             string
+	ListenPort             int
+	MaxBodySize            int64
+	MaxDuration            time.Duration
+	RealHostname           string
+	TLSCertFile            string
+	TLSKeyFile             string
+
+	// temporary placeholders for arguments that need extra processing
+	rawAllowedRedirectDomains string
+	rawUseRealHostname        bool
+}
+
+// ConfigError is used to signal an error with a command line argument or
+// environmment variable.
+//
+// It carries the command's usage output, so that we can decouple configuration
+// parsing from error reporting for better testability.
+type ConfigError struct {
+	Err   error
+	Usage string
+}
+
+// Error implements the error interface.
+func (e ConfigError) Error() string {
+	return e.Err.Error()
+}
+
+// loadConfig parses command line arguments and env vars into a fully resolved
+// Config struct. Command line arguments take precedence over env vars.
+func loadConfig(args []string, getEnv func(string) string, getHostname func() (string, error)) (*config, error) {
+	cfg := &config{}
+
+	fs := flag.NewFlagSet("", flag.ContinueOnError)
+	fs.BoolVar(&cfg.rawUseRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value")
+	fs.DurationVar(&cfg.MaxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take")
+	fs.Int64Var(&cfg.MaxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes")
+	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.TLSCertFile, "https-cert-file", "", "HTTPS Server certificate file")
+	fs.StringVar(&cfg.TLSKeyFile, "https-key-file", "", "HTTPS Server private key file")
+
+	// in order to fully control error output whether CLI arguments or env vars
+	// are used to configure the app, we need to take control away from the
+	// flagset, which by defaults prints errors automatically.
+	//
+	// so, we capture the "usage" output it would generate and then trick it
+	// into generating no output on errors, since they'll be handled by the
+	// caller.
+	//
+	// yes, this is goofy, but it makes the CLI testable!
+	buf := &bytes.Buffer{}
+	fs.SetOutput(buf)
+	fs.Usage()
+	usage := buf.String()
+	fs.SetOutput(io.Discard)
+
+	if err := fs.Parse(args); err != nil {
+		return nil, ConfigError{err, usage}
+	}
+
+	// helper to generate a new ConfigError to return
+	configErr := func(format string, a ...interface{}) error {
+		return ConfigError{
+			Err:   fmt.Errorf(format, a...),
+			Usage: usage,
+		}
+	}
+
+	var err error
+
+	// Command line flags take precedence over environment vars, so we only
+	// check for environment vars if we have default values for our command
+	// line flags.
+	if cfg.MaxBodySize == httpbin.DefaultMaxBodySize && getEnv("MAX_BODY_SIZE") != "" {
+		cfg.MaxBodySize, err = strconv.ParseInt(getEnv("MAX_BODY_SIZE"), 10, 64)
+		if err != nil {
+			return nil, configErr("invalid value %#v for env var MAX_BODY_SIZE: parse error", getEnv("MAX_BODY_SIZE"))
+		}
+	}
+
+	if cfg.MaxDuration == httpbin.DefaultMaxDuration && getEnv("MAX_DURATION") != "" {
+		cfg.MaxDuration, err = time.ParseDuration(getEnv("MAX_DURATION"))
+		if err != nil {
+			return nil, configErr("invalid value %#v for env var MAX_DURATION: parse error", getEnv("MAX_DURATION"))
+		}
+	}
+	if cfg.ListenHost == defaultListenHost && getEnv("HOST") != "" {
+		cfg.ListenHost = getEnv("HOST")
+	}
+	if cfg.ListenPort == defaultListenPort && getEnv("PORT") != "" {
+		cfg.ListenPort, err = strconv.Atoi(getEnv("PORT"))
+		if err != nil {
+			return nil, configErr("invalid value %#v for env var PORT: parse error", getEnv("PORT"))
+		}
+	}
+
+	if cfg.TLSCertFile == "" && getEnv("HTTPS_CERT_FILE") != "" {
+		cfg.TLSCertFile = getEnv("HTTPS_CERT_FILE")
+	}
+	if cfg.TLSKeyFile == "" && getEnv("HTTPS_KEY_FILE") != "" {
+		cfg.TLSKeyFile = getEnv("HTTPS_KEY_FILE")
+	}
+	if cfg.TLSCertFile != "" || cfg.TLSKeyFile != "" {
+		if cfg.TLSCertFile == "" || cfg.TLSKeyFile == "" {
+			return nil, configErr("https cert and key must both be provided")
+		}
+	}
+
+	// useRealHostname will be true if either the `-use-real-hostname`
+	// arg is given on the command line or if the USE_REAL_HOSTNAME env var
+	// is one of "1" or "true".
+	if useRealHostnameEnv := getEnv("USE_REAL_HOSTNAME"); useRealHostnameEnv == "1" || useRealHostnameEnv == "true" {
+		cfg.rawUseRealHostname = true
+	}
+	if cfg.rawUseRealHostname {
+		cfg.RealHostname, err = getHostname()
+		if err != nil {
+			return nil, fmt.Errorf("could not look up real hostname: %w", err)
+		}
+	}
+
+	// split comma-separated list of domains into a slice, if given
+	if cfg.rawAllowedRedirectDomains == "" && getEnv("ALLOWED_REDIRECT_DOMAINS") != "" {
+		cfg.rawAllowedRedirectDomains = getEnv("ALLOWED_REDIRECT_DOMAINS")
+	}
+	for _, domain := range strings.Split(cfg.rawAllowedRedirectDomains, ",") {
+		if strings.TrimSpace(domain) != "" {
+			cfg.AllowedRedirectDomains = append(cfg.AllowedRedirectDomains, strings.TrimSpace(domain))
+		}
+	}
+
+	// reset temporary fields to their zero values
+	cfg.rawAllowedRedirectDomains = ""
+	cfg.rawUseRealHostname = false
+	return cfg, nil
+}
+
+func listenAndServeGracefully(srv *http.Server, cfg *config, logger *log.Logger) error {
+	doneCh := make(chan error, 1)
+
+	go func() {
+		sigCh := make(chan os.Signal, 1)
+		signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
+		<-sigCh
+
+		logger.Printf("shutting down ...")
+		ctx, cancel := context.WithTimeout(context.Background(), cfg.MaxDuration+1*time.Second)
+		defer cancel()
+		doneCh <- srv.Shutdown(ctx)
+	}()
+
+	var err error
+	if cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" {
+		logger.Printf("go-httpbin listening on https://%s", srv.Addr)
+		err = srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile)
+	} else {
+		logger.Printf("go-httpbin listening on http://%s", srv.Addr)
+		err = srv.ListenAndServe()
+	}
+	if err != nil && err != http.ErrServerClosed {
+		return err
+	}
+
+	return <-doneCh
+}
diff --git a/httpbin/cmd/cmd_test.go b/httpbin/cmd/cmd_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..146cfe2f2978785bf86d79f2c8ecbe8ba8e9627b
--- /dev/null
+++ b/httpbin/cmd/cmd_test.go
@@ -0,0 +1,498 @@
+package cmd
+
+import (
+	"bytes"
+	"errors"
+	"flag"
+	"os"
+	"reflect"
+	"testing"
+	"time"
+
+	"github.com/mccutchen/go-httpbin/v2/httpbin"
+)
+
+// To update, run:
+// make && ./dist/go-httpbin -h 2>&1 | pbcopy
+const usage = `Usage:
+  -allowed-redirect-domains string
+    	Comma-separated list of domains the /redirect-to endpoint will allow
+  -host string
+    	Host to listen on (default "0.0.0.0")
+  -https-cert-file string
+    	HTTPS Server certificate file
+  -https-key-file string
+    	HTTPS Server private key file
+  -max-body-size int
+    	Maximum size of request or response, in bytes (default 1048576)
+  -max-duration duration
+    	Maximum duration a response may take (default 10s)
+  -port int
+    	Port to listen on (default 8080)
+  -use-real-hostname
+    	Expose value of os.Hostname() in the /hostname endpoint instead of dummy value
+`
+
+func TestLoadConfig(t *testing.T) {
+	t.Parallel()
+
+	testDefaultRealHostname := "real-hostname.test"
+	getHostnameDefault := func() (string, error) {
+		return testDefaultRealHostname, nil
+	}
+
+	testCases := map[string]struct {
+		args        []string
+		env         map[string]string
+		getHostname func() (string, error)
+		wantCfg     *config
+		wantErr     error
+		wantOut     string
+	}{
+		"defaults": {
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+		"-h": {
+			args:    []string{"-h"},
+			wantErr: flag.ErrHelp,
+		},
+		"-help": {
+			args:    []string{"-help"},
+			wantErr: flag.ErrHelp,
+		},
+
+		// max body size
+		"invalid -max-body-size": {
+			args:    []string{"-max-body-size", "foo"},
+			wantErr: errors.New("invalid value \"foo\" for flag -max-body-size: parse error"),
+		},
+		"invalid MAX_BODY_SIZE": {
+			env:     map[string]string{"MAX_BODY_SIZE": "foo"},
+			wantErr: errors.New("invalid value \"foo\" for env var MAX_BODY_SIZE: parse error"),
+		},
+		"ok -max-body-size": {
+			args: []string{"-max-body-size", "99"},
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: 99,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+		"ok MAX_BODY_SIZE": {
+			env: map[string]string{"MAX_BODY_SIZE": "9999"},
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: 9999,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+		"ok max body size CLI takes precedence over env": {
+			args: []string{"-max-body-size", "1234"},
+			env:  map[string]string{"MAX_BODY_SIZE": "5678"},
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: 1234,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+
+		// max duration
+		"invalid -max-duration": {
+			args:    []string{"-max-duration", "foo"},
+			wantErr: errors.New("invalid value \"foo\" for flag -max-duration: parse error"),
+		},
+		"invalid MAX_DURATION": {
+			env:     map[string]string{"MAX_DURATION": "foo"},
+			wantErr: errors.New("invalid value \"foo\" for env var MAX_DURATION: parse error"),
+		},
+		"ok -max-duration": {
+			args: []string{"-max-duration", "99s"},
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: 99 * time.Second,
+			},
+		},
+		"ok MAX_DURATION": {
+			env: map[string]string{"MAX_DURATION": "9999s"},
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: 9999 * time.Second,
+			},
+		},
+		"ok max duration size CLI takes precedence over env": {
+			args: []string{"-max-duration", "1234s"},
+			env:  map[string]string{"MAX_DURATION": "5678s"},
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: 1234 * time.Second,
+			},
+		},
+
+		// host
+		"ok -host": {
+			args: []string{"-host", "192.0.0.1"},
+			wantCfg: &config{
+				ListenHost:  "192.0.0.1",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+		"ok HOST": {
+			env: map[string]string{"HOST": "192.0.0.2"},
+			wantCfg: &config{
+				ListenHost:  "192.0.0.2",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+		"ok host cli takes precedence over end": {
+			args: []string{"-host", "99.99.99.99"},
+			env:  map[string]string{"HOST": "11.11.11.11"},
+			wantCfg: &config{
+				ListenHost:  "99.99.99.99",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+
+		// port
+		"invalid -port": {
+			args:    []string{"-port", "foo"},
+			wantErr: errors.New("invalid value \"foo\" for flag -port: parse error"),
+		},
+		"invalid PORT": {
+			env:     map[string]string{"PORT": "foo"},
+			wantErr: errors.New("invalid value \"foo\" for env var PORT: parse error"),
+		},
+		"ok -port": {
+			args: []string{"-port", "99"},
+			wantCfg: &config{
+				ListenHost:  defaultListenHost,
+				ListenPort:  99,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+		"ok PORT": {
+			env: map[string]string{"PORT": "9999"},
+			wantCfg: &config{
+				ListenHost:  defaultListenHost,
+				ListenPort:  9999,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+		"ok port CLI takes precedence over env": {
+			args: []string{"-port", "1234"},
+			env:  map[string]string{"PORT": "5678"},
+			wantCfg: &config{
+				ListenHost:  defaultListenHost,
+				ListenPort:  1234,
+				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"},
+			wantErr: errors.New("https cert and key must both be provided"),
+		},
+		"https cert and key must both be provided, key only": {
+			args:    []string{"-https-key-file", "/tmp/test.crt"},
+			wantErr: errors.New("https cert and key must both be provided"),
+		},
+		"ok https CLI": {
+			args: []string{
+				"-https-cert-file", "/tmp/test.crt",
+				"-https-key-file", "/tmp/test.key",
+			},
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+				TLSCertFile: "/tmp/test.crt",
+				TLSKeyFile:  "/tmp/test.key",
+			},
+		},
+		"ok https env": {
+			env: map[string]string{
+				"HTTPS_CERT_FILE": "/tmp/test.crt",
+				"HTTPS_KEY_FILE":  "/tmp/test.key",
+			},
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+				TLSCertFile: "/tmp/test.crt",
+				TLSKeyFile:  "/tmp/test.key",
+			},
+		},
+		"ok https CLI takes precedence over env": {
+			args: []string{
+				"-https-cert-file", "/tmp/cli.crt",
+				"-https-key-file", "/tmp/cli.key",
+			},
+			env: map[string]string{
+				"HTTPS_CERT_FILE": "/tmp/env.crt",
+				"HTTPS_KEY_FILE":  "/tmp/env.key",
+			},
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+				TLSCertFile: "/tmp/cli.crt",
+				TLSKeyFile:  "/tmp/cli.key",
+			},
+		},
+
+		// use-real-hostname
+		"ok -use-real-hostname": {
+			args: []string{"-use-real-hostname"},
+			wantCfg: &config{
+				ListenHost:   "0.0.0.0",
+				ListenPort:   8080,
+				MaxBodySize:  httpbin.DefaultMaxBodySize,
+				MaxDuration:  httpbin.DefaultMaxDuration,
+				RealHostname: testDefaultRealHostname,
+			},
+		},
+		"ok -use-real-hostname=1": {
+			args: []string{"-use-real-hostname", "1"},
+			wantCfg: &config{
+				ListenHost:   "0.0.0.0",
+				ListenPort:   8080,
+				MaxBodySize:  httpbin.DefaultMaxBodySize,
+				MaxDuration:  httpbin.DefaultMaxDuration,
+				RealHostname: testDefaultRealHostname,
+			},
+		},
+		"ok -use-real-hostname=true": {
+			args: []string{"-use-real-hostname", "true"},
+			wantCfg: &config{
+				ListenHost:   "0.0.0.0",
+				ListenPort:   8080,
+				MaxBodySize:  httpbin.DefaultMaxBodySize,
+				MaxDuration:  httpbin.DefaultMaxDuration,
+				RealHostname: testDefaultRealHostname,
+			},
+		},
+		// any value for the argument is interpreted as true
+		"ok -use-real-hostname=0": {
+			args: []string{"-use-real-hostname", "0"},
+			wantCfg: &config{
+				ListenHost:   "0.0.0.0",
+				ListenPort:   8080,
+				MaxBodySize:  httpbin.DefaultMaxBodySize,
+				MaxDuration:  httpbin.DefaultMaxDuration,
+				RealHostname: testDefaultRealHostname,
+			},
+		},
+		"ok USE_REAL_HOSTNAME=1": {
+			env: map[string]string{"USE_REAL_HOSTNAME": "1"},
+			wantCfg: &config{
+				ListenHost:   "0.0.0.0",
+				ListenPort:   8080,
+				MaxBodySize:  httpbin.DefaultMaxBodySize,
+				MaxDuration:  httpbin.DefaultMaxDuration,
+				RealHostname: testDefaultRealHostname,
+			},
+		},
+		"ok USE_REAL_HOSTNAME=true": {
+			env: map[string]string{"USE_REAL_HOSTNAME": "true"},
+			wantCfg: &config{
+				ListenHost:   "0.0.0.0",
+				ListenPort:   8080,
+				MaxBodySize:  httpbin.DefaultMaxBodySize,
+				MaxDuration:  httpbin.DefaultMaxDuration,
+				RealHostname: testDefaultRealHostname,
+			},
+		},
+		// case sensitive
+		"ok USE_REAL_HOSTNAME=TRUE": {
+			env: map[string]string{"USE_REAL_HOSTNAME": "TRUE"},
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+		"ok USE_REAL_HOSTNAME=false": {
+			env: map[string]string{"USE_REAL_HOSTNAME": "false"},
+			wantCfg: &config{
+				ListenHost:  "0.0.0.0",
+				ListenPort:  8080,
+				MaxBodySize: httpbin.DefaultMaxBodySize,
+				MaxDuration: httpbin.DefaultMaxDuration,
+			},
+		},
+		"err real hostname error": {
+			env:         map[string]string{"USE_REAL_HOSTNAME": "true"},
+			getHostname: func() (string, error) { return "", errors.New("hostname error") },
+			wantErr:     errors.New("could not look up real hostname: hostname error"),
+		},
+
+		// allowed-redirect-domains
+		"ok -allowed-redirect-domains": {
+			args: []string{"-allowed-redirect-domains", "foo,bar"},
+			wantCfg: &config{
+				ListenHost:             "0.0.0.0",
+				ListenPort:             8080,
+				MaxBodySize:            httpbin.DefaultMaxBodySize,
+				MaxDuration:            httpbin.DefaultMaxDuration,
+				AllowedRedirectDomains: []string{"foo", "bar"},
+			},
+		},
+		"ok ALLOWED_REDIRECT_DOMAINS": {
+			env: map[string]string{"ALLOWED_REDIRECT_DOMAINS": "foo,bar"},
+			wantCfg: &config{
+				ListenHost:             "0.0.0.0",
+				ListenPort:             8080,
+				MaxBodySize:            httpbin.DefaultMaxBodySize,
+				MaxDuration:            httpbin.DefaultMaxDuration,
+				AllowedRedirectDomains: []string{"foo", "bar"},
+			},
+		},
+		"ok allowed redirect domains CLI takes precedence over env": {
+			args: []string{"-allowed-redirect-domains", "foo.cli,bar.cli"},
+			env:  map[string]string{"ALLOWED_REDIRECT_DOMAINS": "foo.env,bar.env"},
+			wantCfg: &config{
+				ListenHost:             "0.0.0.0",
+				ListenPort:             8080,
+				MaxBodySize:            httpbin.DefaultMaxBodySize,
+				MaxDuration:            httpbin.DefaultMaxDuration,
+				AllowedRedirectDomains: []string{"foo.cli", "bar.cli"},
+			},
+		},
+		"ok allowed redirect domains are normalized": {
+			args: []string{"-allowed-redirect-domains", "foo, bar  ,, baz   "},
+			wantCfg: &config{
+				ListenHost:             "0.0.0.0",
+				ListenPort:             8080,
+				MaxBodySize:            httpbin.DefaultMaxBodySize,
+				MaxDuration:            httpbin.DefaultMaxDuration,
+				AllowedRedirectDomains: []string{"foo", "bar", "baz"},
+			},
+		},
+	}
+
+	for name, tc := range testCases {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			if tc.getHostname == nil {
+				tc.getHostname = getHostnameDefault
+			}
+			cfg, err := loadConfig(tc.args, func(key string) string { return tc.env[key] }, tc.getHostname)
+
+			switch {
+			case tc.wantErr != nil && err != nil:
+				if tc.wantErr.Error() != err.Error() {
+					t.Fatalf("incorrect error\nwant: %q\ngot:  %q", tc.wantErr, err)
+				}
+			case tc.wantErr != nil:
+				t.Fatalf("want error %q, got nil", tc.wantErr)
+			case err != nil:
+				t.Fatalf("got unexpected error: %q", err)
+			}
+
+			if !reflect.DeepEqual(tc.wantCfg, cfg) {
+				t.Fatalf("bad config\nwant: %#v\ngot:  %#v", tc.wantCfg, cfg)
+			}
+		})
+	}
+}
+
+func TestMainImpl(t *testing.T) {
+	t.Parallel()
+
+	testCases := map[string]struct {
+		args        []string
+		env         map[string]string
+		getHostname func() (string, error)
+		wantCode    int
+		wantOut     string
+	}{
+		"help": {
+			args:     []string{"-h"},
+			wantCode: 0,
+			wantOut:  usage,
+		},
+		"cli error": {
+			args:     []string{"-max-body-size", "foo"},
+			wantCode: 2,
+			wantOut:  "error: invalid value \"foo\" for flag -max-body-size: parse error\n\n" + usage,
+		},
+		"unknown argument": {
+			args:     []string{"-zzz"},
+			wantCode: 2,
+			wantOut:  "error: flag provided but not defined: -zzz\n\n" + usage,
+		},
+		"real hostname error": {
+			args:        []string{"-use-real-hostname"},
+			getHostname: func() (string, error) { return "", errors.New("hostname failure") },
+			wantCode:    1,
+			wantOut:     "error: could not look up real hostname: hostname failure",
+		},
+		"server error": {
+			args:     []string{"-port", "-256"},
+			wantCode: 1,
+			wantOut:  "go-httpbin listening on http://0.0.0.0:-256\nerror: listen tcp: address -256: invalid port\n",
+		},
+		"tls cert error": {
+			args: []string{
+				"-port", "0",
+				"-https-cert-file", "./https-cert-does-not-exist",
+				"-https-key-file", "./https-key-does-not-exist",
+			},
+			wantCode: 1,
+			wantOut:  "go-httpbin listening on https://0.0.0.0:0\nerror: open ./https-cert-does-not-exist: no such file or directory\n",
+		},
+	}
+
+	for name, tc := range testCases {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			if tc.getHostname == nil {
+				tc.getHostname = os.Hostname
+			}
+
+			buf := &bytes.Buffer{}
+			gotCode := mainImpl(tc.args, func(key string) string { return tc.env[key] }, tc.getHostname, buf)
+			out := buf.String()
+
+			if gotCode != tc.wantCode {
+				t.Logf("unexpected error: output:\n%s", out)
+				t.Fatalf("expected return code %d, got %d", tc.wantCode, gotCode)
+			}
+
+			if out != tc.wantOut {
+				t.Fatalf("output mismatch error:\nwant: %q\ngot:  %q", tc.wantOut, out)
+			}
+		})
+	}
+}