internal/config: separate config initialization into serverconfig

This change creates a new package that does config initialization and
other GCP-specific operations that were previously done in package
config, so that config can have no cloud dependencies.

For golang/go#61399

Change-Id: I8d78294834e325b47d838892a1cef87003a4b90a
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/522516
Run-TryBot: Michael Matloob <matloob@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Robert Findley <rfindley@google.com>
kokoro-CI: kokoro <noreply+kokoro@google.com>
diff --git a/cmd/frontend/main.go b/cmd/frontend/main.go
index f931d85..2a05e14 100644
--- a/cmd/frontend/main.go
+++ b/cmd/frontend/main.go
@@ -18,6 +18,7 @@
 	"golang.org/x/pkgsite/cmd/internal/cmdconfig"
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/config"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/dcensus"
 	"golang.org/x/pkgsite/internal/fetch"
 	"golang.org/x/pkgsite/internal/fetchdatasource"
@@ -35,7 +36,7 @@
 )
 
 var (
-	queueName      = config.GetEnv("GO_DISCOVERY_FRONTEND_TASK_QUEUE", "")
+	queueName      = serverconfig.GetEnv("GO_DISCOVERY_FRONTEND_TASK_QUEUE", "")
 	workers        = flag.Int("workers", 10, "number of concurrent requests to the fetch service, when running locally")
 	staticFlag     = flag.String("static", "static", "path to folder containing static files served")
 	thirdPartyPath = flag.String("third_party", "third_party", "path to folder containing third-party libraries")
@@ -53,7 +54,7 @@
 func main() {
 	flag.Parse()
 	ctx := context.Background()
-	cfg, err := config.Init(ctx)
+	cfg, err := serverconfig.Init(ctx)
 	if err != nil {
 		log.Fatal(ctx, err)
 	}
@@ -169,7 +170,7 @@
 	}
 	// We are not currently forwarding any ports on AppEngine, so serving debug
 	// information is broken.
-	if !cfg.OnAppEngine() {
+	if !serverconfig.OnAppEngine() {
 		dcensusServer, err := dcensus.NewServer()
 		if err != nil {
 			log.Fatal(ctx, err)
diff --git a/cmd/internal/cmdconfig/cmdconfig.go b/cmd/internal/cmdconfig/cmdconfig.go
index 3b5d4d5..6d46e09 100644
--- a/cmd/internal/cmdconfig/cmdconfig.go
+++ b/cmd/internal/cmdconfig/cmdconfig.go
@@ -19,6 +19,7 @@
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/config"
 	"golang.org/x/pkgsite/internal/config/dynconfig"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/database"
 	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/log"
@@ -30,12 +31,12 @@
 
 // Logger configures a middleware.Logger.
 func Logger(ctx context.Context, cfg *config.Config, logName string) middleware.Logger {
-	if cfg.OnGCP() {
+	if serverconfig.OnGCP() {
 		opts := []logging.LoggerOption{logging.CommonResource(&mrpb.MonitoredResource{
 			Type:   cfg.MonitoredResource.Type,
 			Labels: cfg.MonitoredResource.Labels,
 		})}
-		if cfg.OnGKE() {
+		if serverconfig.OnGKE() {
 			opts = append(opts, logging.CommonLabels(map[string]string{
 				"k8s-pod/env": cfg.DeploymentEnvironment(),
 				"k8s-pod/app": cfg.Application(),
@@ -53,7 +54,7 @@
 
 // Reporter configures an Error Reporting client.
 func Reporter(ctx context.Context, cfg *config.Config) derrors.Reporter {
-	if !cfg.OnGCP() || cfg.DisableErrorReporting {
+	if !serverconfig.OnGCP() || cfg.DisableErrorReporting {
 		return nil
 	}
 	reportingClient, err := errorreporting.NewClient(ctx, cfg.ProjectID, errorreporting.Config{
diff --git a/cmd/worker/main.go b/cmd/worker/main.go
index d500159..495d1b3 100644
--- a/cmd/worker/main.go
+++ b/cmd/worker/main.go
@@ -19,6 +19,7 @@
 	_ "github.com/jackc/pgx/v4/stdlib" // for pgx driver
 	"golang.org/x/pkgsite/cmd/internal/cmdconfig"
 	"golang.org/x/pkgsite/internal/config"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/dcensus"
 	"golang.org/x/pkgsite/internal/index"
 	"golang.org/x/pkgsite/internal/log"
@@ -31,8 +32,8 @@
 )
 
 var (
-	timeout   = config.GetEnvInt(context.Background(), "GO_DISCOVERY_WORKER_TIMEOUT_MINUTES", 10)
-	queueName = config.GetEnv("GO_DISCOVERY_WORKER_TASK_QUEUE", "")
+	timeout   = serverconfig.GetEnvInt(context.Background(), "GO_DISCOVERY_WORKER_TIMEOUT_MINUTES", 10)
+	queueName = serverconfig.GetEnv("GO_DISCOVERY_WORKER_TASK_QUEUE", "")
 	workers   = flag.Int("workers", 10, "number of concurrent requests to the fetch service, when running locally")
 	// flag used in call to safehtml/template.TrustedSourceFromFlag
 	_                  = flag.String("static", "static", "path to folder containing static files served")
@@ -44,7 +45,7 @@
 
 	ctx := context.Background()
 
-	cfg, err := config.Init(ctx)
+	cfg, err := serverconfig.Init(ctx)
 	if err != nil {
 		log.Fatal(ctx, err)
 	}
@@ -128,7 +129,7 @@
 	}
 	// We are not currently forwarding any ports on AppEngine, so serving debug
 	// information is broken.
-	if !cfg.OnAppEngine() {
+	if !serverconfig.OnAppEngine() {
 		dcensusServer, err := dcensus.NewServer()
 		if err != nil {
 			log.Fatal(ctx, err)
diff --git a/devtools/cmd/db/main.go b/devtools/cmd/db/main.go
index 848a491..d2e5a03 100644
--- a/devtools/cmd/db/main.go
+++ b/devtools/cmd/db/main.go
@@ -14,7 +14,7 @@
 	"strings"
 
 	_ "github.com/jackc/pgx/v4/stdlib" // for pgx driver
-	"golang.org/x/pkgsite/internal/config"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/database"
 	"golang.org/x/pkgsite/internal/log"
 )
@@ -39,12 +39,12 @@
 	}
 
 	ctx := context.Background()
-	cfg, err := config.Init(ctx)
+	cfg, err := serverconfig.Init(ctx)
 	if err != nil {
 		log.Fatal(ctx, err)
 	}
 
-	dbName := config.GetEnv("GO_DISCOVERY_DATABASE_NAME", "discovery-db")
+	dbName := serverconfig.GetEnv("GO_DISCOVERY_DATABASE_NAME", "discovery-db")
 	if err := run(ctx, flag.Args()[0], dbName, cfg.DBConnInfo()); err != nil {
 		log.Fatal(ctx, err)
 	}
diff --git a/devtools/cmd/seeddb/main.go b/devtools/cmd/seeddb/main.go
index d729ca6..5fd7b06 100644
--- a/devtools/cmd/seeddb/main.go
+++ b/devtools/cmd/seeddb/main.go
@@ -21,6 +21,7 @@
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/config"
 	"golang.org/x/pkgsite/internal/config/dynconfig"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/database"
 	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/experiment"
@@ -45,7 +46,7 @@
 	flag.Parse()
 
 	ctx := context.Background()
-	cfg, err := config.Init(ctx)
+	cfg, err := serverconfig.Init(ctx)
 	if err != nil {
 		log.Fatal(ctx, err)
 	}
diff --git a/internal/config/config.go b/internal/config/config.go
index 5c07106..2b8f045 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -2,96 +2,21 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// Package config resolves shared configuration for Go Discovery services, and
-// provides functions to access this configuration.
-//
-// The Init function should be called before using any of the configuration accessors.
+// Package config provides the definition of the configuration for the
+// frontend.
 package config
 
 import (
-	"context"
-	"encoding/hex"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"io"
-	"math/rand"
-	"net/http"
-	"os"
-	"path"
-	"strconv"
 	"strings"
 	"time"
-
-	"cloud.google.com/go/storage"
-	"golang.org/x/net/context/ctxhttp"
-	"golang.org/x/pkgsite/internal/derrors"
-	"golang.org/x/pkgsite/internal/log"
-	"golang.org/x/pkgsite/internal/secrets"
-	"gopkg.in/yaml.v3"
 )
 
-// GetEnv looks up the given key from the environment, returning its value if
-// it exists, and otherwise returning the given fallback value.
-func GetEnv(key, fallback string) string {
-	if value, ok := os.LookupEnv(key); ok {
-		return value
-	}
-	return fallback
-}
-
-// GetEnvInt looks up the given key from the environment and expects an integer,
-// returning the integer value if it exists, and otherwise returning the given
-// fallback value.
-// If the environment variable has a value but it can't be parsed as an integer,
-// GetEnvInt terminates the program.
-func GetEnvInt(ctx context.Context, key string, fallback int) int {
-	if s, ok := os.LookupEnv(key); ok {
-		v, err := strconv.Atoi(s)
-		if err != nil {
-			log.Fatalf(ctx, "bad value %q for %s: %v", s, key, err)
-		}
-		return v
-	}
-	return fallback
-}
-
-// GetEnvFloat64 looks up the given key from the environment and expects a
-// float64, returning the float64 value if it exists, and otherwise returning
-// the given fallback value.
-func GetEnvFloat64(key string, fallback float64) float64 {
-	if valueStr, ok := os.LookupEnv(key); ok {
-		if value, err := strconv.ParseFloat(valueStr, 64); err == nil {
-			return value
-		}
-	}
-	return fallback
-}
-
 // AppVersionFormat is the expected format of the app version timestamp.
 const AppVersionFormat = "20060102t150405"
 
-// ValidateAppVersion validates that appVersion follows the expected format
-// defined by AppVersionFormat.
-func ValidateAppVersion(appVersion string) error {
-	// Accept GKE versions, which start with the docker image name.
-	if strings.HasPrefix(appVersion, "gcr.io/") {
-		return nil
-	}
-	if _, err := time.Parse(AppVersionFormat, appVersion); err != nil {
-		// Accept alternative version, used by our AppEngine deployment script.
-		const altDateFormat = "2006-01-02t15-04"
-		if len(appVersion) > len(altDateFormat) {
-			appVersion = appVersion[:len(altDateFormat)]
-		}
-		if _, err := time.Parse(altDateFormat, appVersion); err != nil {
-			return fmt.Errorf("app version %q does not match time formats %q or %q: %v",
-				appVersion, AppVersionFormat, altDateFormat, err)
-		}
-	}
-	return nil
-}
-
 const (
 	// BypassQuotaAuthHeader is the header key used by the frontend server to know
 	// that a request can bypass the quota server.
@@ -215,35 +140,6 @@
 	return c.FallbackVersionLabel
 }
 
-// OnAppEngine reports if the current process is running in an AppEngine
-// environment.
-func (c *Config) OnAppEngine() bool {
-	return os.Getenv("GAE_ENV") == "standard"
-}
-
-// OnGKE reports whether the current process is running on GKE.
-func (c *Config) OnGKE() bool {
-	return os.Getenv("GO_DISCOVERY_ON_GKE") == "true"
-}
-
-// OnCloudRun reports whether the current process is running on Cloud Run.
-func (c *Config) OnCloudRun() bool {
-	// Use the presence of the environment variables provided by Cloud Run.
-	// See https://cloud.google.com/run/docs/reference/container-contract.
-	for _, ev := range []string{"K_SERVICE", "K_REVISION", "K_CONFIGURATION"} {
-		if os.Getenv(ev) == "" {
-			return false
-		}
-	}
-	return true
-}
-
-// OnGCP reports whether the current process is running on Google Cloud
-// Platform.
-func (c *Config) OnGCP() bool {
-	return c.OnAppEngine() || c.OnGKE() || c.OnCloudRun()
-}
-
 // StatementTimeout is the value of the Postgres statement_timeout parameter.
 // Statements that run longer than this are terminated.
 // 10 minutes is the App Engine standard request timeout,
@@ -341,14 +237,6 @@
 	}
 }
 
-// configOverride holds selected config settings that can be dynamically overridden.
-type configOverride struct {
-	DBHost          string        `yaml:"DBHost"`
-	DBSecondaryHost string        `yaml:"DBSecondaryHost"`
-	DBName          string        `yaml:"DBName"`
-	Quota           QuotaSettings `yaml:"Quota"`
-}
-
 // QuotaSettings is config for internal/middleware/quota.go
 type QuotaSettings struct {
 	Enable     bool `yaml:"Enable"`
@@ -364,229 +252,6 @@
 	HMACKey    []byte   `json:"-" yaml:"-"` // key for obfuscating IPs
 }
 
-// Init resolves all configuration values provided by the config package. It
-// must be called before any configuration values are used.
-func Init(ctx context.Context) (_ *Config, err error) {
-	defer derrors.Add(&err, "config.Init(ctx)")
-	// Build a Config from the execution environment, loading some values
-	// from envvars and others from remote services.
-	cfg := &Config{
-		AuthValues: parseCommaList(os.Getenv("GO_DISCOVERY_AUTH_VALUES")),
-		IndexURL:   GetEnv("GO_MODULE_INDEX_URL", "https://index.golang.org/index"),
-		ProxyURL:   GetEnv("GO_MODULE_PROXY_URL", "https://proxy.golang.org"),
-		Port:       os.Getenv("PORT"),
-		DebugPort:  os.Getenv("DEBUG_PORT"),
-		// Resolve AppEngine identifiers
-		ProjectID: os.Getenv("GOOGLE_CLOUD_PROJECT"),
-		ServiceID: GetEnv("GAE_SERVICE", os.Getenv("GO_DISCOVERY_SERVICE")),
-		// Version ID from either AppEngine, Cloud Run (see
-		// https://cloud.google.com/run/docs/reference/container-contract) or
-		// GKE (set by our own config).
-		VersionID:          GetEnv("GAE_VERSION", GetEnv("K_REVISION", os.Getenv("DOCKER_IMAGE"))),
-		InstanceID:         GetEnv("GAE_INSTANCE", os.Getenv("GO_DISCOVERY_INSTANCE")),
-		GoogleTagManagerID: os.Getenv("GO_DISCOVERY_GOOGLE_TAG_MANAGER_ID"),
-		QueueURL:           os.Getenv("GO_DISCOVERY_QUEUE_URL"),
-		QueueAudience:      os.Getenv("GO_DISCOVERY_QUEUE_AUDIENCE"),
-
-		// LocationID is essentially hard-coded until we figure out a good way to
-		// determine it programmatically, but we check an environment variable in
-		// case it needs to be overridden.
-		LocationID: GetEnv("GO_DISCOVERY_GAE_LOCATION_ID", "us-central1"),
-		// This fallback should only be used when developing locally.
-		FallbackVersionLabel: time.Now().Format(AppVersionFormat),
-		DBHost:               chooseOne(GetEnv("GO_DISCOVERY_DATABASE_HOST", "localhost")),
-		DBUser:               GetEnv("GO_DISCOVERY_DATABASE_USER", "postgres"),
-		DBPassword:           os.Getenv("GO_DISCOVERY_DATABASE_PASSWORD"),
-		DBSecondaryHost:      chooseOne(os.Getenv("GO_DISCOVERY_DATABASE_SECONDARY_HOST")),
-		DBPort:               GetEnv("GO_DISCOVERY_DATABASE_PORT", "5432"),
-		DBName:               GetEnv("GO_DISCOVERY_DATABASE_NAME", "discovery-db"),
-		DBSecret:             os.Getenv("GO_DISCOVERY_DATABASE_SECRET"),
-		DBSSL:                GetEnv("GO_DISCOVERY_DATABASE_SSL", "disable"),
-		RedisCacheHost:       os.Getenv("GO_DISCOVERY_REDIS_HOST"),
-		RedisBetaCacheHost:   os.Getenv("GO_DISCOVERY_REDIS_BETA_HOST"),
-		RedisCachePort:       GetEnv("GO_DISCOVERY_REDIS_PORT", "6379"),
-		Quota: QuotaSettings{
-			Enable:     os.Getenv("GO_DISCOVERY_ENABLE_QUOTA") == "true",
-			QPS:        GetEnvInt(ctx, "GO_DISCOVERY_QUOTA_QPS", 10),
-			Burst:      20,   // ignored in redis-based quota implementation
-			MaxEntries: 1000, // ignored in redis-based quota implementation
-			RecordOnly: func() *bool {
-				t := (os.Getenv("GO_DISCOVERY_QUOTA_RECORD_ONLY") != "false")
-				return &t
-			}(),
-			AuthValues: parseCommaList(os.Getenv("GO_DISCOVERY_AUTH_VALUES")),
-		},
-		UseProfiler:           os.Getenv("GO_DISCOVERY_USE_PROFILER") == "true",
-		LogLevel:              os.Getenv("GO_DISCOVERY_LOG_LEVEL"),
-		ServeStats:            os.Getenv("GO_DISCOVERY_SERVE_STATS") == "true",
-		DisableErrorReporting: os.Getenv("GO_DISCOVERY_DISABLE_ERROR_REPORTING") == "true",
-		VulnDB:                GetEnv("GO_DISCOVERY_VULN_DB", "https://storage.googleapis.com/go-vulndb"),
-	}
-	log.SetLevel(cfg.LogLevel)
-
-	bucket := os.Getenv("GO_DISCOVERY_CONFIG_BUCKET")
-	config := os.Getenv("GO_DISCOVERY_CONFIG_DYNAMIC")
-	exclude := os.Getenv("GO_DISCOVERY_EXCLUDED_FILENAME")
-	if bucket != "" {
-		if config == "" {
-			return nil, errors.New("GO_DISCOVERY_CONFIG_DYNAMIC must be set if GO_DISCOVERY_CONFIG_BUCKET is")
-		}
-		cfg.DynamicConfigLocation = fmt.Sprintf("gs://%s/%s", bucket, config)
-		if exclude != "" {
-			cfg.DynamicExcludeLocation = fmt.Sprintf("gs://%s/%s", bucket, exclude)
-		}
-	} else {
-		cfg.DynamicConfigLocation = config
-		cfg.DynamicExcludeLocation = exclude
-	}
-	if cfg.OnGCP() {
-		// Zone is not available in the environment but can be queried via the metadata API.
-		zone, err := gceMetadata(ctx, "instance/zone")
-		if err != nil {
-			return nil, err
-		}
-		cfg.ZoneID = zone
-		sa, err := gceMetadata(ctx, "instance/service-accounts/default/email")
-		if err != nil {
-			return nil, err
-		}
-		cfg.ServiceAccount = sa
-		switch {
-		case cfg.OnAppEngine():
-			// Use the gae_app monitored resource. It would be better to use the
-			// gae_instance monitored resource, but that's not currently supported:
-			// https://cloud.google.com/logging/docs/api/v2/resource-list#resource-types
-			cfg.MonitoredResource = &MonitoredResource{
-				Type: "gae_app",
-				Labels: map[string]string{
-					"project_id": cfg.ProjectID,
-					"module_id":  cfg.ServiceID,
-					"version_id": cfg.VersionID,
-					"zone":       cfg.ZoneID,
-				},
-			}
-		case cfg.OnCloudRun():
-			cfg.MonitoredResource = &MonitoredResource{
-				Type: "cloud_run_revision",
-				Labels: map[string]string{
-					"project_id":         cfg.ProjectID,
-					"service_name":       cfg.ServiceID,
-					"revision_name":      cfg.VersionID,
-					"configuration_name": os.Getenv("K_CONFIGURATION"),
-				},
-			}
-		case cfg.OnGKE():
-			cfg.MonitoredResource = &MonitoredResource{
-				Type: "k8s_container",
-				Labels: map[string]string{
-					"project_id":     cfg.ProjectID,
-					"location":       path.Base(cfg.ZoneID),
-					"cluster_name":   cfg.DeploymentEnvironment() + "-pkgsite",
-					"namespace_name": "default",
-					"pod_name":       os.Getenv("HOSTNAME"),
-					"container_name": cfg.Application(),
-				},
-			}
-		default:
-			return nil, errors.New("on GCP but using an unknown product")
-		}
-		if cfg.InstanceID == "" {
-			id, err := gceMetadata(ctx, "instance/id")
-			if err != nil {
-				return nil, fmt.Errorf("getting instance ID: %v", err)
-			}
-			cfg.InstanceID = id
-		}
-	} else { // running locally, perhaps
-		cfg.MonitoredResource = &MonitoredResource{
-			Type:   "global",
-			Labels: map[string]string{"project_id": cfg.ProjectID},
-		}
-	}
-	if cfg.DBHost == "" {
-		panic("DBHost is empty; impossible")
-	}
-	if cfg.DBSecret != "" {
-		var err error
-		cfg.DBPassword, err = secrets.Get(ctx, cfg.DBSecret)
-		if err != nil {
-			return nil, fmt.Errorf("could not get database password secret: %v", err)
-		}
-	}
-	if cfg.Quota.Enable {
-		s, err := secrets.Get(ctx, "quota-hmac-key")
-		if err != nil {
-			return nil, err
-		}
-		hmacKey, err := hex.DecodeString(s)
-		if err != nil {
-			return nil, err
-		}
-		if len(hmacKey) < 16 {
-			return nil, errors.New("HMAC secret must be at least 16 bytes")
-		}
-		cfg.Quota.HMACKey = hmacKey
-		log.Debugf(ctx, "quota enforcement enabled: qps=%d burst=%d maxentry=%d", cfg.Quota.QPS, cfg.Quota.Burst, cfg.Quota.MaxEntries)
-	} else {
-		log.Debugf(ctx, "quota enforcement disabled")
-	}
-
-	// If the <env>-override.yaml file exists in the configured bucket, it
-	// should provide overrides for selected configuration.
-	// Use this when you want to fix something in prod quickly, without waiting
-	// to re-deploy. (Otherwise, do not use it.)
-	if cfg.DeploymentEnvironment() != "local" {
-		overrideObj := fmt.Sprintf("%s-override.yaml", cfg.DeploymentEnvironment())
-		overrideBytes, err := readOverrideFile(ctx, bucket, overrideObj)
-		if err != nil {
-			log.Error(ctx, err)
-		} else {
-			log.Infof(ctx, "processing overrides from gs://%s/%s", bucket, overrideObj)
-			processOverrides(ctx, cfg, overrideBytes)
-		}
-	}
-	return cfg, nil
-}
-
-func readOverrideFile(ctx context.Context, bucketName, objName string) (_ []byte, err error) {
-	defer derrors.Wrap(&err, "readOverrideFile(ctx, %q)", objName)
-
-	client, err := storage.NewClient(ctx)
-	if err != nil {
-		return nil, err
-	}
-	defer client.Close()
-	r, err := client.Bucket(bucketName).Object(objName).NewReader(ctx)
-	if err != nil {
-		return nil, err
-	}
-	defer r.Close()
-	return io.ReadAll(r)
-}
-
-func processOverrides(ctx context.Context, cfg *Config, bytes []byte) {
-	var ov configOverride
-	if err := yaml.Unmarshal(bytes, &ov); err != nil {
-		log.Errorf(ctx, "processOverrides: yaml.Unmarshal: %v", err)
-		return
-	}
-	override(ctx, "DBHost", &cfg.DBHost, ov.DBHost)
-	override(ctx, "DBSecondaryHost", &cfg.DBSecondaryHost, ov.DBSecondaryHost)
-	override(ctx, "DBName", &cfg.DBName, ov.DBName)
-	override(ctx, "Quota.QPS", &cfg.Quota.QPS, ov.Quota.QPS)
-	override(ctx, "Quota.Burst", &cfg.Quota.Burst, ov.Quota.Burst)
-	override(ctx, "Quota.MaxEntries", &cfg.Quota.MaxEntries, ov.Quota.MaxEntries)
-	override(ctx, "Quota.RecordOnly", &cfg.Quota.RecordOnly, ov.Quota.RecordOnly)
-}
-
-func override[T comparable](ctx context.Context, name string, field *T, val T) {
-	var zero T
-	if val != zero {
-		*field = val
-		log.Infof(ctx, "overriding %s with %v", name, val)
-	}
-}
-
 // Dump outputs the current config information to the given Writer.
 func (c *Config) Dump(w io.Writer) error {
 	fmt.Fprint(w, "config: ")
@@ -594,55 +259,3 @@
 	enc.SetIndent("", "    ")
 	return enc.Encode(c)
 }
-
-// chooseOne selects one entry at random from a whitespace-separated
-// string. It returns the empty string if there are no elements.
-func chooseOne(configVar string) string {
-	fields := strings.Fields(configVar)
-	if len(fields) == 0 {
-		return ""
-	}
-	src := rand.NewSource(time.Now().UnixNano())
-	rng := rand.New(src)
-	return fields[rng.Intn(len(fields))]
-}
-
-// gceMetadata reads a metadata value from GCE.
-// For the possible values of name, see
-// https://cloud.google.com/appengine/docs/standard/java/accessing-instance-metadata.
-func gceMetadata(ctx context.Context, name string) (_ string, err error) {
-	// See https://cloud.google.com/appengine/docs/standard/java/accessing-instance-metadata.
-	// (This documentation doesn't exist for Golang, but it seems to work).
-	defer derrors.Wrap(&err, "gceMetadata(ctx, %q)", name)
-
-	const metadataURL = "http://metadata.google.internal/computeMetadata/v1/"
-	req, err := http.NewRequest("GET", metadataURL+name, nil)
-	if err != nil {
-		return "", fmt.Errorf("http.NewRequest: %v", err)
-	}
-	req.Header.Set("Metadata-Flavor", "Google")
-	resp, err := ctxhttp.Do(ctx, nil, req)
-	if err != nil {
-		return "", fmt.Errorf("ctxhttp.Do: %v", err)
-	}
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		return "", fmt.Errorf("bad status: %s", resp.Status)
-	}
-	bytes, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return "", fmt.Errorf("io.ReadAll: %v", err)
-	}
-	return string(bytes), nil
-}
-
-func parseCommaList(s string) []string {
-	var a []string
-	for _, p := range strings.Split(s, ",") {
-		p = strings.TrimSpace(p)
-		if p != "" {
-			a = append(a, p)
-		}
-	}
-	return a
-}
diff --git a/internal/config/serverconfig/config.go b/internal/config/serverconfig/config.go
new file mode 100644
index 0000000..7b37a70
--- /dev/null
+++ b/internal/config/serverconfig/config.go
@@ -0,0 +1,387 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package serverconfig resolves shared configuration for Go Discovery services.
+package serverconfig
+
+import (
+	"context"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"io"
+	"math/rand"
+	"net/http"
+	"os"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	"cloud.google.com/go/storage"
+	"golang.org/x/net/context/ctxhttp"
+	"golang.org/x/pkgsite/internal/config"
+	"golang.org/x/pkgsite/internal/derrors"
+	"golang.org/x/pkgsite/internal/log"
+	"golang.org/x/pkgsite/internal/secrets"
+	"gopkg.in/yaml.v3"
+)
+
+// GetEnv looks up the given key from the environment, returning its value if
+// it exists, and otherwise returning the given fallback value.
+func GetEnv(key, fallback string) string {
+	if value, ok := os.LookupEnv(key); ok {
+		return value
+	}
+	return fallback
+}
+
+// GetEnvInt looks up the given key from the environment and expects an integer,
+// returning the integer value if it exists, and otherwise returning the given
+// fallback value.
+// If the environment variable has a value but it can't be parsed as an integer,
+// GetEnvInt terminates the program.
+func GetEnvInt(ctx context.Context, key string, fallback int) int {
+	if s, ok := os.LookupEnv(key); ok {
+		v, err := strconv.Atoi(s)
+		if err != nil {
+			log.Fatalf(ctx, "bad value %q for %s: %v", s, key, err)
+		}
+		return v
+	}
+	return fallback
+}
+
+// ValidateAppVersion validates that appVersion follows the expected format
+// defined by AppVersionFormat.
+func ValidateAppVersion(appVersion string) error {
+	// Accept GKE versions, which start with the docker image name.
+	if strings.HasPrefix(appVersion, "gcr.io/") {
+		return nil
+	}
+	if _, err := time.Parse(config.AppVersionFormat, appVersion); err != nil {
+		// Accept alternative version, used by our AppEngine deployment script.
+		const altDateFormat = "2006-01-02t15-04"
+		if len(appVersion) > len(altDateFormat) {
+			appVersion = appVersion[:len(altDateFormat)]
+		}
+		if _, err := time.Parse(altDateFormat, appVersion); err != nil {
+			return fmt.Errorf("app version %q does not match time formats %q or %q: %v",
+				appVersion, config.AppVersionFormat, altDateFormat, err)
+		}
+	}
+	return nil
+}
+
+// OnAppEngine reports if the current process is running in an AppEngine
+// environment.
+func OnAppEngine() bool {
+	return os.Getenv("GAE_ENV") == "standard"
+}
+
+// OnGKE reports whether the current process is running on GKE.
+func OnGKE() bool {
+	return os.Getenv("GO_DISCOVERY_ON_GKE") == "true"
+}
+
+// onCloudRun reports whether the current process is running on Cloud Run.
+func onCloudRun() bool {
+	// Use the presence of the environment variables provided by Cloud Run.
+	// See https://cloud.google.com/run/docs/reference/container-contract.
+	for _, ev := range []string{"K_SERVICE", "K_REVISION", "K_CONFIGURATION"} {
+		if os.Getenv(ev) == "" {
+			return false
+		}
+	}
+	return true
+}
+
+// OnGCP reports whether the current process is running on Google Cloud
+// Platform.
+func OnGCP() bool {
+	return OnAppEngine() || OnGKE() || onCloudRun()
+}
+
+// configOverride holds selected config settings that can be dynamically overridden.
+type configOverride struct {
+	DBHost          string               `yaml:"DBHost"`
+	DBSecondaryHost string               `yaml:"DBSecondaryHost"`
+	DBName          string               `yaml:"DBName"`
+	Quota           config.QuotaSettings `yaml:"Quota"`
+}
+
+// Init resolves all configuration values provided by the config package. It
+// must be called before any configuration values are used.
+func Init(ctx context.Context) (_ *config.Config, err error) {
+	defer derrors.Add(&err, "config.Init(ctx)")
+	// Build a Config from the execution environment, loading some values
+	// from envvars and others from remote services.
+	cfg := &config.Config{
+		AuthValues: parseCommaList(os.Getenv("GO_DISCOVERY_AUTH_VALUES")),
+		IndexURL:   GetEnv("GO_MODULE_INDEX_URL", "https://index.golang.org/index"),
+		ProxyURL:   GetEnv("GO_MODULE_PROXY_URL", "https://proxy.golang.org"),
+		Port:       os.Getenv("PORT"),
+		DebugPort:  os.Getenv("DEBUG_PORT"),
+		// Resolve AppEngine identifiers
+		ProjectID: os.Getenv("GOOGLE_CLOUD_PROJECT"),
+		ServiceID: GetEnv("GAE_SERVICE", os.Getenv("GO_DISCOVERY_SERVICE")),
+		// Version ID from either AppEngine, Cloud Run (see
+		// https://cloud.google.com/run/docs/reference/container-contract) or
+		// GKE (set by our own config).
+		VersionID:          GetEnv("GAE_VERSION", GetEnv("K_REVISION", os.Getenv("DOCKER_IMAGE"))),
+		InstanceID:         GetEnv("GAE_INSTANCE", os.Getenv("GO_DISCOVERY_INSTANCE")),
+		GoogleTagManagerID: os.Getenv("GO_DISCOVERY_GOOGLE_TAG_MANAGER_ID"),
+		QueueURL:           os.Getenv("GO_DISCOVERY_QUEUE_URL"),
+		QueueAudience:      os.Getenv("GO_DISCOVERY_QUEUE_AUDIENCE"),
+
+		// LocationID is essentially hard-coded until we figure out a good way to
+		// determine it programmatically, but we check an environment variable in
+		// case it needs to be overridden.
+		LocationID: GetEnv("GO_DISCOVERY_GAE_LOCATION_ID", "us-central1"),
+		// This fallback should only be used when developing locally.
+		FallbackVersionLabel: time.Now().Format(config.AppVersionFormat),
+		DBHost:               chooseOne(GetEnv("GO_DISCOVERY_DATABASE_HOST", "localhost")),
+		DBUser:               GetEnv("GO_DISCOVERY_DATABASE_USER", "postgres"),
+		DBPassword:           os.Getenv("GO_DISCOVERY_DATABASE_PASSWORD"),
+		DBSecondaryHost:      chooseOne(os.Getenv("GO_DISCOVERY_DATABASE_SECONDARY_HOST")),
+		DBPort:               GetEnv("GO_DISCOVERY_DATABASE_PORT", "5432"),
+		DBName:               GetEnv("GO_DISCOVERY_DATABASE_NAME", "discovery-db"),
+		DBSecret:             os.Getenv("GO_DISCOVERY_DATABASE_SECRET"),
+		DBSSL:                GetEnv("GO_DISCOVERY_DATABASE_SSL", "disable"),
+		RedisCacheHost:       os.Getenv("GO_DISCOVERY_REDIS_HOST"),
+		RedisBetaCacheHost:   os.Getenv("GO_DISCOVERY_REDIS_BETA_HOST"),
+		RedisCachePort:       GetEnv("GO_DISCOVERY_REDIS_PORT", "6379"),
+		Quota: config.QuotaSettings{
+			Enable:     os.Getenv("GO_DISCOVERY_ENABLE_QUOTA") == "true",
+			QPS:        GetEnvInt(ctx, "GO_DISCOVERY_QUOTA_QPS", 10),
+			Burst:      20,   // ignored in redis-based quota implementation
+			MaxEntries: 1000, // ignored in redis-based quota implementation
+			RecordOnly: func() *bool {
+				t := (os.Getenv("GO_DISCOVERY_QUOTA_RECORD_ONLY") != "false")
+				return &t
+			}(),
+			AuthValues: parseCommaList(os.Getenv("GO_DISCOVERY_AUTH_VALUES")),
+		},
+		UseProfiler:           os.Getenv("GO_DISCOVERY_USE_PROFILER") == "true",
+		LogLevel:              os.Getenv("GO_DISCOVERY_LOG_LEVEL"),
+		ServeStats:            os.Getenv("GO_DISCOVERY_SERVE_STATS") == "true",
+		DisableErrorReporting: os.Getenv("GO_DISCOVERY_DISABLE_ERROR_REPORTING") == "true",
+		VulnDB:                GetEnv("GO_DISCOVERY_VULN_DB", "https://storage.googleapis.com/go-vulndb"),
+	}
+	log.SetLevel(cfg.LogLevel)
+
+	bucket := os.Getenv("GO_DISCOVERY_CONFIG_BUCKET")
+	configDynamic := os.Getenv("GO_DISCOVERY_CONFIG_DYNAMIC")
+	exclude := os.Getenv("GO_DISCOVERY_EXCLUDED_FILENAME")
+	if bucket != "" {
+		if configDynamic == "" {
+			return nil, errors.New("GO_DISCOVERY_CONFIG_DYNAMIC must be set if GO_DISCOVERY_CONFIG_BUCKET is")
+		}
+		cfg.DynamicConfigLocation = fmt.Sprintf("gs://%s/%s", bucket, configDynamic)
+		if exclude != "" {
+			cfg.DynamicExcludeLocation = fmt.Sprintf("gs://%s/%s", bucket, exclude)
+		}
+	} else {
+		cfg.DynamicConfigLocation = configDynamic
+		cfg.DynamicExcludeLocation = exclude
+	}
+	if OnGCP() {
+		// Zone is not available in the environment but can be queried via the metadata API.
+		zone, err := gceMetadata(ctx, "instance/zone")
+		if err != nil {
+			return nil, err
+		}
+		cfg.ZoneID = zone
+		sa, err := gceMetadata(ctx, "instance/service-accounts/default/email")
+		if err != nil {
+			return nil, err
+		}
+		cfg.ServiceAccount = sa
+		switch {
+		case OnAppEngine():
+			// Use the gae_app monitored resource. It would be better to use the
+			// gae_instance monitored resource, but that's not currently supported:
+			// https://cloud.google.com/logging/docs/api/v2/resource-list#resource-types
+			cfg.MonitoredResource = &config.MonitoredResource{
+				Type: "gae_app",
+				Labels: map[string]string{
+					"project_id": cfg.ProjectID,
+					"module_id":  cfg.ServiceID,
+					"version_id": cfg.VersionID,
+					"zone":       cfg.ZoneID,
+				},
+			}
+		case onCloudRun():
+			cfg.MonitoredResource = &config.MonitoredResource{
+				Type: "cloud_run_revision",
+				Labels: map[string]string{
+					"project_id":         cfg.ProjectID,
+					"service_name":       cfg.ServiceID,
+					"revision_name":      cfg.VersionID,
+					"configuration_name": os.Getenv("K_CONFIGURATION"),
+				},
+			}
+		case OnGKE():
+			cfg.MonitoredResource = &config.MonitoredResource{
+				Type: "k8s_container",
+				Labels: map[string]string{
+					"project_id":     cfg.ProjectID,
+					"location":       path.Base(cfg.ZoneID),
+					"cluster_name":   cfg.DeploymentEnvironment() + "-pkgsite",
+					"namespace_name": "default",
+					"pod_name":       os.Getenv("HOSTNAME"),
+					"container_name": cfg.Application(),
+				},
+			}
+		default:
+			return nil, errors.New("on GCP but using an unknown product")
+		}
+		if cfg.InstanceID == "" {
+			id, err := gceMetadata(ctx, "instance/id")
+			if err != nil {
+				return nil, fmt.Errorf("getting instance ID: %v", err)
+			}
+			cfg.InstanceID = id
+		}
+	} else { // running locally, perhaps
+		cfg.MonitoredResource = &config.MonitoredResource{
+			Type:   "global",
+			Labels: map[string]string{"project_id": cfg.ProjectID},
+		}
+	}
+	if cfg.DBHost == "" {
+		panic("DBHost is empty; impossible")
+	}
+	if cfg.DBSecret != "" {
+		var err error
+		cfg.DBPassword, err = secrets.Get(ctx, cfg.DBSecret)
+		if err != nil {
+			return nil, fmt.Errorf("could not get database password secret: %v", err)
+		}
+	}
+	if cfg.Quota.Enable {
+		s, err := secrets.Get(ctx, "quota-hmac-key")
+		if err != nil {
+			return nil, err
+		}
+		hmacKey, err := hex.DecodeString(s)
+		if err != nil {
+			return nil, err
+		}
+		if len(hmacKey) < 16 {
+			return nil, errors.New("HMAC secret must be at least 16 bytes")
+		}
+		cfg.Quota.HMACKey = hmacKey
+		log.Debugf(ctx, "quota enforcement enabled: qps=%d burst=%d maxentry=%d", cfg.Quota.QPS, cfg.Quota.Burst, cfg.Quota.MaxEntries)
+	} else {
+		log.Debugf(ctx, "quota enforcement disabled")
+	}
+
+	// If the <env>-override.yaml file exists in the configured bucket, it
+	// should provide overrides for selected configuration.
+	// Use this when you want to fix something in prod quickly, without waiting
+	// to re-deploy. (Otherwise, do not use it.)
+	if cfg.DeploymentEnvironment() != "local" {
+		overrideObj := fmt.Sprintf("%s-override.yaml", cfg.DeploymentEnvironment())
+		overrideBytes, err := readOverrideFile(ctx, bucket, overrideObj)
+		if err != nil {
+			log.Error(ctx, err)
+		} else {
+			log.Infof(ctx, "processing overrides from gs://%s/%s", bucket, overrideObj)
+			processOverrides(ctx, cfg, overrideBytes)
+		}
+	}
+	return cfg, nil
+}
+
+func readOverrideFile(ctx context.Context, bucketName, objName string) (_ []byte, err error) {
+	defer derrors.Wrap(&err, "readOverrideFile(ctx, %q)", objName)
+
+	client, err := storage.NewClient(ctx)
+	if err != nil {
+		return nil, err
+	}
+	defer client.Close()
+	r, err := client.Bucket(bucketName).Object(objName).NewReader(ctx)
+	if err != nil {
+		return nil, err
+	}
+	defer r.Close()
+	return io.ReadAll(r)
+}
+
+func processOverrides(ctx context.Context, cfg *config.Config, bytes []byte) {
+	var ov configOverride
+	if err := yaml.Unmarshal(bytes, &ov); err != nil {
+		log.Errorf(ctx, "processOverrides: yaml.Unmarshal: %v", err)
+		return
+	}
+	override(ctx, "DBHost", &cfg.DBHost, ov.DBHost)
+	override(ctx, "DBSecondaryHost", &cfg.DBSecondaryHost, ov.DBSecondaryHost)
+	override(ctx, "DBName", &cfg.DBName, ov.DBName)
+	override(ctx, "Quota.QPS", &cfg.Quota.QPS, ov.Quota.QPS)
+	override(ctx, "Quota.Burst", &cfg.Quota.Burst, ov.Quota.Burst)
+	override(ctx, "Quota.MaxEntries", &cfg.Quota.MaxEntries, ov.Quota.MaxEntries)
+	override(ctx, "Quota.RecordOnly", &cfg.Quota.RecordOnly, ov.Quota.RecordOnly)
+}
+
+func override[T comparable](ctx context.Context, name string, field *T, val T) {
+	var zero T
+	if val != zero {
+		*field = val
+		log.Infof(ctx, "overriding %s with %v", name, val)
+	}
+}
+
+// chooseOne selects one entry at random from a whitespace-separated
+// string. It returns the empty string if there are no elements.
+func chooseOne(configVar string) string {
+	fields := strings.Fields(configVar)
+	if len(fields) == 0 {
+		return ""
+	}
+	src := rand.NewSource(time.Now().UnixNano())
+	rng := rand.New(src)
+	return fields[rng.Intn(len(fields))]
+}
+
+// gceMetadata reads a metadata value from GCE.
+// For the possible values of name, see
+// https://cloud.google.com/appengine/docs/standard/java/accessing-instance-metadata.
+func gceMetadata(ctx context.Context, name string) (_ string, err error) {
+	// See https://cloud.google.com/appengine/docs/standard/java/accessing-instance-metadata.
+	// (This documentation doesn't exist for Golang, but it seems to work).
+	defer derrors.Wrap(&err, "gceMetadata(ctx, %q)", name)
+
+	const metadataURL = "http://metadata.google.internal/computeMetadata/v1/"
+	req, err := http.NewRequest("GET", metadataURL+name, nil)
+	if err != nil {
+		return "", fmt.Errorf("http.NewRequest: %v", err)
+	}
+	req.Header.Set("Metadata-Flavor", "Google")
+	resp, err := ctxhttp.Do(ctx, nil, req)
+	if err != nil {
+		return "", fmt.Errorf("ctxhttp.Do: %v", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("bad status: %s", resp.Status)
+	}
+	bytes, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", fmt.Errorf("io.ReadAll: %v", err)
+	}
+	return string(bytes), nil
+}
+
+func parseCommaList(s string) []string {
+	var a []string
+	for _, p := range strings.Split(s, ",") {
+		p = strings.TrimSpace(p)
+		if p != "" {
+			a = append(a, p)
+		}
+	}
+	return a
+}
diff --git a/internal/config/config_test.go b/internal/config/serverconfig/config_test.go
similarity index 86%
rename from internal/config/config_test.go
rename to internal/config/serverconfig/config_test.go
index dd141fd..c45f6a0 100644
--- a/internal/config/config_test.go
+++ b/internal/config/serverconfig/config_test.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package config
+package serverconfig
 
 import (
 	"context"
@@ -10,6 +10,7 @@
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
+	"golang.org/x/pkgsite/internal/config"
 )
 
 func TestValidateAppVersion(t *testing.T) {
@@ -56,10 +57,10 @@
 func TestProcessOverrides(t *testing.T) {
 	tr := true
 	f := false
-	cfg := Config{
+	cfg := config.Config{
 		DBHost: "origHost",
 		DBName: "origName",
-		Quota:  QuotaSettings{QPS: 1, Burst: 2, MaxEntries: 3, RecordOnly: &tr},
+		Quota:  config.QuotaSettings{QPS: 1, Burst: 2, MaxEntries: 3, RecordOnly: &tr},
 	}
 	ov := `
         DBHost: newHost
@@ -69,12 +70,12 @@
     `
 	processOverrides(context.Background(), &cfg, []byte(ov))
 	got := cfg
-	want := Config{
+	want := config.Config{
 		DBHost: "newHost",
 		DBName: "origName",
-		Quota:  QuotaSettings{QPS: 1, Burst: 2, MaxEntries: 17, RecordOnly: &f},
+		Quota:  config.QuotaSettings{QPS: 1, Burst: 2, MaxEntries: 17, RecordOnly: &f},
 	}
-	if diff := cmp.Diff(want, got, cmp.AllowUnexported(Config{})); diff != "" {
+	if diff := cmp.Diff(want, got, cmp.AllowUnexported(config.Config{})); diff != "" {
 		t.Errorf("mismatch (-want, +got):\n%s", diff)
 	}
 }
@@ -108,7 +109,7 @@
 		{"-foo-bar", "unknownEnv", "foo-bar"},
 		{"", "local", "unknownApp"},
 	} {
-		cfg := &Config{ServiceID: test.serviceID}
+		cfg := &config.Config{ServiceID: test.serviceID}
 		gotEnv := cfg.DeploymentEnvironment()
 		if gotEnv != test.wantEnv {
 			t.Errorf("%q: got %q, want %q", test.serviceID, gotEnv, test.wantEnv)
diff --git a/internal/database/dbutil.go b/internal/database/dbutil.go
index d750fba..db6eab6 100644
--- a/internal/database/dbutil.go
+++ b/internal/database/dbutil.go
@@ -13,7 +13,7 @@
 	"path/filepath"
 	"strings"
 
-	"golang.org/x/pkgsite/internal/config"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/testing/testhelper"
 
@@ -31,10 +31,10 @@
 // necessary as migrate expects a URI.
 func DBConnURI(dbName string) string {
 	var (
-		user     = config.GetEnv("GO_DISCOVERY_DATABASE_USER", "postgres")
-		password = config.GetEnv("GO_DISCOVERY_DATABASE_PASSWORD", "")
-		host     = config.GetEnv("GO_DISCOVERY_DATABASE_HOST", "localhost")
-		port     = config.GetEnv("GO_DISCOVERY_DATABASE_PORT", "5432")
+		user     = serverconfig.GetEnv("GO_DISCOVERY_DATABASE_USER", "postgres")
+		password = serverconfig.GetEnv("GO_DISCOVERY_DATABASE_PASSWORD", "")
+		host     = serverconfig.GetEnv("GO_DISCOVERY_DATABASE_HOST", "localhost")
+		port     = serverconfig.GetEnv("GO_DISCOVERY_DATABASE_PORT", "5432")
 	)
 	cs := fmt.Sprintf("postgres://%s/%s?sslmode=disable&user=%s&password=%s&port=%s&timezone=UTC",
 		host, dbName, url.QueryEscape(user), url.QueryEscape(password), url.QueryEscape(port))
diff --git a/internal/dcensus/dcensus.go b/internal/dcensus/dcensus.go
index 3b2f76e..f67de36 100644
--- a/internal/dcensus/dcensus.go
+++ b/internal/dcensus/dcensus.go
@@ -21,6 +21,7 @@
 	"go.opencensus.io/trace"
 	"go.opencensus.io/zpages"
 	"golang.org/x/pkgsite/internal/config"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/log"
 )
@@ -169,7 +170,7 @@
 			"task_id":    cfg.InstanceID,
 		},
 	}
-	if cfg.OnGKE() {
+	if serverconfig.OnGKE() {
 		mr = (*monitoredResource)(cfg.MonitoredResource)
 	}
 	log.Debugf(context.Background(), "monitored resource for monitoring: Type %q, Labels %v",
diff --git a/internal/postgres/benchmarks_test.go b/internal/postgres/benchmarks_test.go
index 57f2cf2..766e9b0 100644
--- a/internal/postgres/benchmarks_test.go
+++ b/internal/postgres/benchmarks_test.go
@@ -8,7 +8,7 @@
 	"context"
 	"testing"
 
-	"golang.org/x/pkgsite/internal/config"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/database"
 )
 
@@ -33,7 +33,7 @@
 
 func BenchmarkSearch(b *testing.B) {
 	ctx := context.Background()
-	cfg, err := config.Init(ctx)
+	cfg, err := serverconfig.Init(ctx)
 	if err != nil {
 		b.Fatal(err)
 	}
diff --git a/internal/postgres/requeue.go b/internal/postgres/requeue.go
index 618ad0e..fec8002 100644
--- a/internal/postgres/requeue.go
+++ b/internal/postgres/requeue.go
@@ -12,7 +12,7 @@
 	"strconv"
 
 	"golang.org/x/pkgsite/internal"
-	"golang.org/x/pkgsite/internal/config"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/log"
 )
@@ -156,7 +156,7 @@
 // largeModulesLimit represents the number of large modules that we are
 // willing to enqueue at a given time.
 // var for testing.
-var largeModulesLimit = config.GetEnvInt(context.Background(), "GO_DISCOVERY_LARGE_MODULES_LIMIT", 100)
+var largeModulesLimit = serverconfig.GetEnvInt(context.Background(), "GO_DISCOVERY_LARGE_MODULES_LIMIT", 100)
 
 // GetNextModulesToFetch returns the next batch of modules that need to be
 // processed. We prioritize modules based on (1) whether it has status zero
diff --git a/internal/queue/gcpqueue/queue.go b/internal/queue/gcpqueue/queue.go
index 23e4ec6..bd56852 100644
--- a/internal/queue/gcpqueue/queue.go
+++ b/internal/queue/gcpqueue/queue.go
@@ -17,6 +17,7 @@
 	"time"
 
 	"cloud.google.com/go/cloudtasks/apiv2"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	taskspb "google.golang.org/genproto/googleapis/cloud/tasks/v2"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
@@ -33,7 +34,7 @@
 // New creates a new Queue with name queueName based on the configuration
 // in cfg. When running locally, Queue uses numWorkers concurrent workers.
 func New(ctx context.Context, cfg *config.Config, queueName string, numWorkers int, expGetter middleware.ExperimentGetter, processFunc queue.InMemoryProcessFunc) (queue.Queue, error) {
-	if !cfg.OnGCP() {
+	if !serverconfig.OnGCP() {
 		experiments, err := expGetter(ctx)
 		if err != nil {
 			return nil, err
diff --git a/internal/worker/pages.go b/internal/worker/pages.go
index 8e6d4b7..f35a326 100644
--- a/internal/worker/pages.go
+++ b/internal/worker/pages.go
@@ -19,6 +19,7 @@
 	"github.com/google/safehtml/template"
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/config"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/log"
 	"golang.org/x/pkgsite/internal/memory"
@@ -75,7 +76,7 @@
 		log.Warningf(ctx, "could not get cgroup stats: %v", err)
 	}
 	var logsURL string
-	if s.cfg.OnGKE() {
+	if serverconfig.OnGKE() {
 		env := s.cfg.DeploymentEnvironment()
 		cluster := env + "-" + "pkgsite"
 		logsURL = `https://pantheon.corp.google.com/logs/query;query=resource.type%3D%22k8s_container%22%20resource.labels.cluster_name%3D%22` +
diff --git a/internal/worker/server.go b/internal/worker/server.go
index be0ac6a..a7283b6 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -27,6 +27,7 @@
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/cache"
 	"golang.org/x/pkgsite/internal/config"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/godoc/dochtml"
 	"golang.org/x/pkgsite/internal/index"
@@ -605,7 +606,7 @@
 	if appVersion == "" {
 		return &serverError{http.StatusBadRequest, errors.New("app_version was not specified")}
 	}
-	if err := config.ValidateAppVersion(appVersion); err != nil {
+	if err := serverconfig.ValidateAppVersion(appVersion); err != nil {
 		return &serverError{http.StatusBadRequest, fmt.Errorf("config.ValidateAppVersion(%q): %v", appVersion, err)}
 	}
 
@@ -874,14 +875,14 @@
 var maxModuleZipSize int64 = math.MaxInt64
 
 func init() {
-	v := config.GetEnvInt(context.Background(), "GO_DISCOVERY_MAX_MODULE_ZIP_MI", -1)
+	v := serverconfig.GetEnvInt(context.Background(), "GO_DISCOVERY_MAX_MODULE_ZIP_MI", -1)
 	if v > 0 {
 		maxModuleZipSize = int64(v) * mib
 	}
 }
 
 func (s *Server) setLoadShedder(ctx context.Context) {
-	mebis := config.GetEnvInt(ctx, "GO_DISCOVERY_MAX_IN_FLIGHT_ZIP_MI", -1)
+	mebis := serverconfig.GetEnvInt(ctx, "GO_DISCOVERY_MAX_IN_FLIGHT_ZIP_MI", -1)
 	if mebis > 0 {
 		log.Infof(ctx, "shedding load over %dMi", mebis)
 		s.loadShedder = &loadShedder{
diff --git a/tests/search/main.go b/tests/search/main.go
index 508f4a3..9e950af 100755
--- a/tests/search/main.go
+++ b/tests/search/main.go
@@ -18,7 +18,7 @@
 	"strings"
 
 	_ "github.com/jackc/pgx/v4/stdlib" // for pgx driver
-	"golang.org/x/pkgsite/internal/config"
+	"golang.org/x/pkgsite/internal/config/serverconfig"
 	"golang.org/x/pkgsite/internal/database"
 	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/frontend"
@@ -33,7 +33,7 @@
 	flag.Parse()
 
 	ctx := context.Background()
-	cfg, err := config.Init(ctx)
+	cfg, err := serverconfig.Init(ctx)
 	if err != nil {
 		log.Fatal(ctx, err)
 	}