Repository: google/exposure-notifications-server
Pull request #521 (2020-06-01 23:39:37)

Removing time window used by worker to query for exposure batches available for export.

google-prow-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/521#" title="Author self-approved">mgulimonov</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **sethvargo**
You can assign the PR to them by writing `/assign @sethvargo` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/google/exposure-notifications-server/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["sethvargo"]} -->
mgulimonov: /assign @sethvargo
mgulimonov: I'm open to suggestions of a better name for this configuration parameter.
cjwagner: /test all
cjwagner: /test all
google-oss-robot: @mgulimonov: The following test **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pull-en-server-release-unit | e7a1b5e9acefc140b6ba85683eb74057863d86be | [link](https://oss-prow.knative.dev/view/gcs/oss-prow/pr-logs/pull/google_exposure-notifications-server/521/pull-en-server-release-unit/1267913297370288128) | `/test pull-en-server-release-unit`

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
google-oss-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/521#" title="Author self-approved">mgulimonov</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **sethvargo**
You can assign the PR to them by writing `/assign @sethvargo` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/google/exposure-notifications-server/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["sethvargo"]} -->
mgulimonov: /retest

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5f79bb0..e13ce52 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -100,13 +100,6 @@ Docker is installed and available in your `$PATH`.
     $ go run ./cmd/exposure/...
     ```
 
-    Alternatively, you can run all the server components as a single binary with
-    each component at a different path:
-
-    ```text
-    $ go run ./cmd/monolith/...
-    ```
-
 1.  When you're done developing, you can stop the database.
 
     ```text
diff --git a/cmd/cleanup-export/main.go b/cmd/cleanup-export/main.go
index 496c150..3368150 100644
--- a/cmd/cleanup-export/main.go
+++ b/cmd/cleanup-export/main.go
@@ -17,35 +17,50 @@ package main
 
 import (
 	"context"
-	"log"
+	"fmt"
 	"net/http"
 
-	"go.opencensus.io/plugin/ochttp"
-
 	"github.com/google/exposure-notifications-server/internal/cleanup"
+	"github.com/google/exposure-notifications-server/internal/interrupt"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	_ "github.com/google/exposure-notifications-server/internal/observability"
+	"github.com/google/exposure-notifications-server/internal/server"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 func main() {
-	ctx := context.Background()
+	ctx, done := interrupt.Context()
+	defer done()
+
+	if err := realMain(ctx); err != nil {
+		logger := logging.FromContext(ctx)
+		logger.Fatal(err)
+	}
+}
+
+func realMain(ctx context.Context) error {
 	logger := logging.FromContext(ctx)
 
 	var config cleanup.Config
-	env, closer, err := setup.Setup(ctx, &config)
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
-		logger.Fatalf("setup.Setup: %v", err)
+		return fmt.Errorf("setup.Setup: %w", err)
 	}
-	defer closer()
+	defer env.Close(ctx)
 
 	handler, err := cleanup.NewExportHandler(&config, env)
 	if err != nil {
-		logger.Fatalf("cleanup.NewExportHandler: %v", err)
+		return fmt.Errorf("cleanup.NewExportHandler: %w", err)
 	}
+
 	mux := http.NewServeMux()
 	mux.Handle("/", handler)
-	logger.Infof("starting export cleanup server on :%s", config.Port)
-	instrumentedHandler := &ochttp.Handler{Handler: mux}
-	log.Fatal(http.ListenAndServe(":"+config.Port, instrumentedHandler))
+
+	srv, err := server.New(config.Port)
+	if err != nil {
+		return fmt.Errorf("server.New: %w", err)
+	}
+	logger.Infof("listening on :%s", config.Port)
+
+	return srv.ServeHTTPHandler(ctx, mux)
 }
diff --git a/cmd/cleanup-exposure/main.go b/cmd/cleanup-exposure/main.go
index 49b5997..54c94f3 100644
--- a/cmd/cleanup-exposure/main.go
+++ b/cmd/cleanup-exposure/main.go
@@ -17,35 +17,50 @@ package main
 
 import (
 	"context"
-	"log"
+	"fmt"
 	"net/http"
 
-	"go.opencensus.io/plugin/ochttp"
-
 	"github.com/google/exposure-notifications-server/internal/cleanup"
+	"github.com/google/exposure-notifications-server/internal/interrupt"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	_ "github.com/google/exposure-notifications-server/internal/observability"
+	"github.com/google/exposure-notifications-server/internal/server"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 func main() {
-	ctx := context.Background()
+	ctx, done := interrupt.Context()
+	defer done()
+
+	if err := realMain(ctx); err != nil {
+		logger := logging.FromContext(ctx)
+		logger.Fatal(err)
+	}
+}
+
+func realMain(ctx context.Context) error {
 	logger := logging.FromContext(ctx)
 
 	var config cleanup.Config
-	env, closer, err := setup.Setup(ctx, &config)
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
-		logger.Fatalf("setup.Setup: %v", err)
+		return fmt.Errorf("setup.Setup: %w", err)
 	}
-	defer closer()
+	defer env.Close(ctx)
 
 	handler, err := cleanup.NewExposureHandler(&config, env)
 	if err != nil {
-		logger.Fatalf("cleanup.NewExposureHandler: %v", err)
+		return fmt.Errorf("cleanup.NewExposureHandler: %w", err)
 	}
+
 	mux := http.NewServeMux()
 	mux.Handle("/", handler)
-	logger.Infof("starting cleanup server on :%s", config.Port)
-	instrumentedHandler := &ochttp.Handler{Handler: mux}
-	log.Fatal(http.ListenAndServe(":"+config.Port, instrumentedHandler))
+
+	srv, err := server.New(config.Port)
+	if err != nil {
+		return fmt.Errorf("server.New: %w", err)
+	}
+	logger.Infof("listening on :%s", config.Port)
+
+	return srv.ServeHTTPHandler(ctx, mux)
 }
diff --git a/cmd/export/main.go b/cmd/export/main.go
index 1a27a65..ac43c1b 100644
--- a/cmd/export/main.go
+++ b/cmd/export/main.go
@@ -17,37 +17,51 @@ package main
 
 import (
 	"context"
-	"log"
+	"fmt"
 	"net/http"
 
-	"go.opencensus.io/plugin/ochttp"
-
 	"github.com/google/exposure-notifications-server/internal/export"
+	"github.com/google/exposure-notifications-server/internal/interrupt"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	_ "github.com/google/exposure-notifications-server/internal/observability"
+	"github.com/google/exposure-notifications-server/internal/server"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 func main() {
-	ctx := context.Background()
+	ctx, done := interrupt.Context()
+	defer done()
+
+	if err := realMain(ctx); err != nil {
+		logger := logging.FromContext(ctx)
+		logger.Fatal(err)
+	}
+}
+
+func realMain(ctx context.Context) error {
 	logger := logging.FromContext(ctx)
 
 	var config export.Config
-	env, closer, err := setup.Setup(ctx, &config)
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
-		logger.Fatalf("setup.Setup: %v", err)
+		return fmt.Errorf("setup.Setup: %w", err)
 	}
-	defer closer()
+	defer env.Close(ctx)
 
 	batchServer, err := export.NewServer(&config, env)
 	if err != nil {
-		logger.Fatalf("unable to create server: %v", err)
+		return fmt.Errorf("export.NewServer: %w", err)
 	}
+
 	mux := http.NewServeMux()
-	mux.HandleFunc("/create-batches", batchServer.CreateBatchesHandler) // controller that creates work items
-	mux.HandleFunc("/do-work", batchServer.WorkerHandler)               // worker that executes work
+	mux.HandleFunc("/create-batches", batchServer.CreateBatchesHandler)
+	mux.HandleFunc("/do-work", batchServer.WorkerHandler)
+
+	srv, err := server.New(config.Port)
+	if err != nil {
+		return fmt.Errorf("server.New: %w", err)
+	}
+	logger.Infof("listening on :%s", config.Port)
 
-	logger.Infof("starting exposure export server on :%s", config.Port)
-	instrumentedHandler := &ochttp.Handler{Handler: mux}
-	log.Fatal(http.ListenAndServe(":"+config.Port, instrumentedHandler))
+	return srv.ServeHTTPHandler(ctx, mux)
 }
diff --git a/cmd/exposure/main.go b/cmd/exposure/main.go
index 5445864..fe3e62b 100644
--- a/cmd/exposure/main.go
+++ b/cmd/exposure/main.go
@@ -17,36 +17,51 @@ package main
 
 import (
 	"context"
-	"log"
+	"fmt"
 	"net/http"
 
-	"go.opencensus.io/plugin/ochttp"
-
 	"github.com/google/exposure-notifications-server/internal/handlers"
+	"github.com/google/exposure-notifications-server/internal/interrupt"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	_ "github.com/google/exposure-notifications-server/internal/observability"
 	"github.com/google/exposure-notifications-server/internal/publish"
+	"github.com/google/exposure-notifications-server/internal/server"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 func main() {
-	ctx := context.Background()
+	ctx, done := interrupt.Context()
+	defer done()
+
+	if err := realMain(ctx); err != nil {
+		logger := logging.FromContext(ctx)
+		logger.Fatal(err)
+	}
+}
+
+func realMain(ctx context.Context) error {
 	logger := logging.FromContext(ctx)
 
 	var config publish.Config
-	env, closer, err := setup.Setup(ctx, &config)
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
-		logger.Fatalf("setup.Setup: %v", err)
+		return fmt.Errorf("setup.Setup: %w", err)
 	}
-	defer closer()
+	defer env.Close(ctx)
 
 	handler, err := publish.NewHandler(ctx, &config, env)
 	if err != nil {
-		logger.Fatalf("unable to create publish handler: %v", err)
+		return fmt.Errorf("publish.NewHandler: %w", err)
 	}
+
 	mux := http.NewServeMux()
 	mux.Handle("/", handlers.WithMinimumLatency(config.MinRequestDuration, handler))
-	logger.Infof("starting exposure server on :%s", config.Port)
-	instrumentedHandler := &ochttp.Handler{Handler: mux}
-	log.Fatal(http.ListenAndServe(":"+config.Port, instrumentedHandler))
+
+	srv, err := server.New(config.Port)
+	if err != nil {
+		return fmt.Errorf("server.New: %w", err)
+	}
+	logger.Infof("listening on :%s", config.Port)
+
+	return srv.ServeHTTPHandler(ctx, mux)
 }
diff --git a/cmd/federationin/main.go b/cmd/federationin/main.go
index b82b2ba..a566b67 100644
--- a/cmd/federationin/main.go
+++ b/cmd/federationin/main.go
@@ -18,31 +18,47 @@ package main
 
 import (
 	"context"
-	"log"
+	"fmt"
 	"net/http"
 
-	"go.opencensus.io/plugin/ochttp"
-
 	"github.com/google/exposure-notifications-server/internal/federationin"
+	"github.com/google/exposure-notifications-server/internal/interrupt"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	_ "github.com/google/exposure-notifications-server/internal/observability"
+	"github.com/google/exposure-notifications-server/internal/server"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 func main() {
-	ctx := context.Background()
+	ctx, done := interrupt.Context()
+	defer done()
+
+	if err := realMain(ctx); err != nil {
+		logger := logging.FromContext(ctx)
+		logger.Fatal(err)
+	}
+}
+
+func realMain(ctx context.Context) error {
 	logger := logging.FromContext(ctx)
 
 	var config federationin.Config
-	env, closer, err := setup.Setup(ctx, &config)
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
-		logger.Fatalf("setup.Setup: %v", err)
+		return fmt.Errorf("setup.Setup: %w", err)
 	}
-	defer closer()
+	defer env.Close(ctx)
+
+	handler := federationin.NewHandler(env, &config)
 
 	mux := http.NewServeMux()
-	mux.Handle("/", federationin.NewHandler(env, &config))
-	logger.Infof("Starting federationin server on port %s", config.Port)
-	instrumentedHandler := &ochttp.Handler{Handler: mux}
-	log.Fatal(http.ListenAndServe(":"+config.Port, instrumentedHandler))
+	mux.Handle("/", handler)
+
+	srv, err := server.New(config.Port)
+	if err != nil {
+		return fmt.Errorf("server.New: %w", err)
+	}
+	logger.Infof("listening on :%s", config.Port)
+
+	return srv.ServeHTTPHandler(ctx, mux)
 }
diff --git a/cmd/federationout/main.go b/cmd/federationout/main.go
index d34450f..7d769c5 100644
--- a/cmd/federationout/main.go
+++ b/cmd/federationout/main.go
@@ -17,8 +17,7 @@ package main
 
 import (
 	"context"
-	"log"
-	"net"
+	"fmt"
 
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/credentials"
@@ -26,47 +25,58 @@ import (
 	"go.opencensus.io/plugin/ocgrpc"
 
 	"github.com/google/exposure-notifications-server/internal/federationout"
+	"github.com/google/exposure-notifications-server/internal/interrupt"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	_ "github.com/google/exposure-notifications-server/internal/observability"
 	"github.com/google/exposure-notifications-server/internal/pb"
+	"github.com/google/exposure-notifications-server/internal/server"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 func main() {
-	ctx := context.Background()
+	ctx, done := interrupt.Context()
+	defer done()
+
+	if err := realMain(ctx); err != nil {
+		logger := logging.FromContext(ctx)
+		logger.Fatal(err)
+	}
+}
+
+func realMain(ctx context.Context) error {
 	logger := logging.FromContext(ctx)
 
 	var config federationout.Config
-	env, closer, err := setup.Setup(ctx, &config)
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
-		logger.Fatalf("setup.Setup: %v", err)
+		return fmt.Errorf("setup.Setup: %w", err)
 	}
-	defer closer()
+	defer env.Close(ctx)
 
-	server := federationout.NewServer(env, &config)
+	federationServer := federationout.NewServer(env, &config)
 
 	var sopts []grpc.ServerOption
 	if config.TLSCertFile != "" && config.TLSKeyFile != "" {
 		creds, err := credentials.NewServerTLSFromFile(config.TLSCertFile, config.TLSKeyFile)
 		if err != nil {
-			log.Fatalf("Failed to generate credentials: %v", err)
+			return fmt.Errorf("failed to create credentials: %w", err)
 		}
 		sopts = append(sopts, grpc.Creds(creds))
 	}
 
 	if !config.AllowAnyClient {
-		sopts = append(sopts, grpc.UnaryInterceptor(server.(*federationout.Server).AuthInterceptor))
+		sopts = append(sopts, grpc.UnaryInterceptor(federationServer.(*federationout.Server).AuthInterceptor))
 	}
 
 	sopts = append(sopts, grpc.StatsHandler(&ocgrpc.ServerHandler{}))
 	grpcServer := grpc.NewServer(sopts...)
-	pb.RegisterFederationServer(grpcServer, server)
+	pb.RegisterFederationServer(grpcServer, federationServer)
 
-	grpcEndpoint := ":" + config.Port
-	listen, err := net.Listen("tcp", grpcEndpoint)
+	srv, err := server.New(config.Port)
 	if err != nil {
-		logger.Fatalf("Failed to start server: %v", err)
+		return fmt.Errorf("server.New: %w", err)
 	}
-	logger.Infof("Starting federationout gRPC listener [%s]", grpcEndpoint)
-	log.Fatal(grpcServer.Serve(listen))
+	logger.Infof("listening on :%s", config.Port)
+
+	return srv.ServeGRPC(ctx, grpcServer)
 }
diff --git a/cmd/generate/main.go b/cmd/generate/main.go
index 0c8e31d..2e78631 100644
--- a/cmd/generate/main.go
+++ b/cmd/generate/main.go
@@ -18,35 +18,50 @@ package main
 
 import (
 	"context"
-	"log"
+	"fmt"
 	"net/http"
 
-	"go.opencensus.io/plugin/ochttp"
-
 	"github.com/google/exposure-notifications-server/internal/generate"
+	"github.com/google/exposure-notifications-server/internal/interrupt"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	_ "github.com/google/exposure-notifications-server/internal/observability"
+	"github.com/google/exposure-notifications-server/internal/server"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 func main() {
-	ctx := context.Background()
+	ctx, done := interrupt.Context()
+	defer done()
+
+	if err := realMain(ctx); err != nil {
+		logger := logging.FromContext(ctx)
+		logger.Fatal(err)
+	}
+}
+
+func realMain(ctx context.Context) error {
 	logger := logging.FromContext(ctx)
 
 	var config generate.Config
-	env, closer, err := setup.Setup(ctx, &config)
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
-		logger.Fatalf("setup.Setup: %v", err)
+		return fmt.Errorf("setup.Setup: %w", err)
 	}
-	defer closer()
+	defer env.Close(ctx)
 
 	handler, err := generate.NewHandler(ctx, &config, env)
 	if err != nil {
-		logger.Fatalf("unable to create generate handler: %v", err)
+		return fmt.Errorf("generate.NewHandler: %w", err)
 	}
+
 	mux := http.NewServeMux()
 	mux.Handle("/", handler)
-	logger.Infof("starting generate server on :%s", config.Port)
-	instrumentedHandler := &ochttp.Handler{Handler: mux}
-	log.Fatal(http.ListenAndServe(":"+config.Port, instrumentedHandler))
+
+	srv, err := server.New(config.Port)
+	if err != nil {
+		return fmt.Errorf("server.New: %w", err)
+	}
+	logger.Infof("listening on :%s", config.Port)
+
+	return srv.ServeHTTPHandler(ctx, mux)
 }
diff --git a/docs/index.md b/docs/index.md
index 0a152cf..77c8d20 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -23,7 +23,7 @@ The Exposure Notification Server is responsible for the following functions:
 * Accepting the temporary exposure keys of affected users from mobile devices.
 
 * Validating the temporary exposure keys using a [configurable third-party
-  verification service](docs/design/verification_protocol.md).
+  verification service](design/verification_protocol.md).
 
 * Storing the temporary exposure keys in a database.
 
diff --git a/go.mod b/go.mod
index 89c3cfc..89dd60f 100644
--- a/go.mod
+++ b/go.mod
@@ -40,7 +40,6 @@ require (
 	github.com/hashicorp/yamux v0.0.0-20190923154419-df201c70410d // indirect
 	github.com/jackc/pgx/v4 v4.6.0
 	github.com/jefferai/jsonx v1.0.1 // indirect
-	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/keybase/go-crypto v0.0.0-20200123153347-de78d2cb44f4 // indirect
 	github.com/kr/text v0.2.0 // indirect
 	github.com/lib/pq v1.5.2 // indirect
@@ -58,6 +57,7 @@ require (
 	github.com/posener/complete v1.2.3 // indirect
 	github.com/prometheus/client_golang v1.6.0 // indirect
 	github.com/prometheus/common v0.10.0 // indirect
+	github.com/sethvargo/go-envconfig v0.1.0
 	github.com/sethvargo/go-gcpkms v0.0.0-20200417004547-e50d0c7083d9
 	github.com/shirou/gopsutil v2.20.4+incompatible // indirect
 	github.com/shopspring/decimal v0.0.0-20200419222939-1884f454f8ea // indirect
@@ -66,7 +66,7 @@ require (
 	go.opencensus.io v0.22.3
 	go.uber.org/zap v1.14.1
 	golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect
-	golang.org/x/tools v0.0.0-20200502202811-ed308ab3e770 // indirect
+	golang.org/x/tools v0.0.0-20200513154647-78b527d18275 // indirect
 	google.golang.org/api v0.25.0
 	google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece
 	google.golang.org/grpc v1.29.1
diff --git a/go.sum b/go.sum
index e0e4366..73e5a0c 100644
--- a/go.sum
+++ b/go.sum
@@ -804,9 +804,8 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
+github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
 github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
-github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
-github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
 github.com/keybase/go-crypto v0.0.0-20190403132359-d65b6b94177f h1:Gsc9mVHLRqBjMgdQCghN9NObCcRncDqxJvBvEaIIQEo=
 github.com/keybase/go-crypto v0.0.0-20190403132359-d65b6b94177f/go.mod h1:ghbZscTyKdM07+Fw3KSi0hcJm+AlEUWj8QLlPtijN/M=
 github.com/keybase/go-crypto v0.0.0-20200123153347-de78d2cb44f4 h1:cTxwSmnaqLoo+4tLukHoB9iqHOu3LmLhRmgUxZo6Vp4=
@@ -1074,6 +1073,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUt
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/securego/gosec v0.0.0-20191002120514-e680875ea14d h1:BzRvVq1EHuIjxpijCEKpAxzKUUMurOQ4sknehIATRh8=
 github.com/securego/gosec v0.0.0-20191002120514-e680875ea14d/go.mod h1:w5+eXa0mYznDkHaMCXA4XYffjlH+cy1oyKbfzJXa2Do=
+github.com/sethvargo/go-envconfig v0.1.0 h1:Mu2lgEHe12szerlqm4n58KsP5iJljo+o3jALYKjc4Dc=
+github.com/sethvargo/go-envconfig v0.1.0/go.mod h1:XZ2JRR7vhlBEO5zMmOpLgUhgYltqYqq4d4tKagtPUv0=
 github.com/sethvargo/go-gcpkms v0.0.0-20200417004547-e50d0c7083d9 h1:RhVXqKbTmy4akECFXjhvw+5E/h6woQXzTaj0Yt2mzjM=
 github.com/sethvargo/go-gcpkms v0.0.0-20200417004547-e50d0c7083d9/go.mod h1:C7JiMBZbJnohGWYmx0vi6mQNGgLxhQX/SWxe0wYxf18=
 github.com/shirou/gopsutil v0.0.0-20190901111213-e4ec7b275ada/go.mod h1:WWnYX4lzhCH5h/3YBfyVA3VbLYjlMZZAQcW9ojMexNc=
@@ -1456,8 +1457,8 @@ golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWc
 golang.org/x/tools v0.0.0-20200409170454-77362c5149f0/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200416214402-fc959738d646/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200502202811-ed308ab3e770 h1:M9Fif0OxNji8w+HvmhVQ8KJtiZOsjU9RgslJGhn95XE=
-golang.org/x/tools v0.0.0-20200502202811-ed308ab3e770/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200513154647-78b527d18275 h1:e7nYe9s94RHunFJ7b+mmPxiQMOKMVSqYASToWb1EcHs=
+golang.org/x/tools v0.0.0-20200513154647-78b527d18275/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/internal/admin/config.go b/internal/admin/config.go
index 0b81ec5..3c50996 100644
--- a/internal/admin/config.go
+++ b/internal/admin/config.go
@@ -29,10 +29,10 @@ import (
 	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
+var _ setup.BlobstoreConfigProvider = (*Config)(nil)
 var _ setup.DatabaseConfigProvider = (*Config)(nil)
 var _ setup.KeyManagerConfigProvider = (*Config)(nil)
 var _ setup.SecretManagerConfigProvider = (*Config)(nil)
-var _ setup.BlobstoreConfigProvider = (*Config)(nil)
 
 type Config struct {
 	Database      database.Config
@@ -40,10 +40,10 @@ type Config struct {
 	SecretManager secrets.Config
 	Storage       storage.Config
 
-	Port         string `envconfig:"PORT" default:"8080"`
-	TemplatePath string `envconfig:"TEMPLATE_DIR" default:"./tools/admin-console/templates"`
-	TopFile      string `envconfig:"TOP_FILE" default:"top"`
-	BotFile      string `envconfig:"BOTTOM_FILE" default:"bottom"`
+	Port         string `env:"PORT, default=8080"`
+	TemplatePath string `env:"TEMPLATE_DIR, default=./tools/admin-console/templates"`
+	TopFile      string `env:"TOP_FILE, default=top"`
+	BotFile      string `env:"BOTTOM_FILE, default=bottom"`
 }
 
 func (c *Config) DatabaseConfig() *database.Config {
diff --git a/internal/authorizedapp/config.go b/internal/authorizedapp/config.go
index 75fbac6..41edfbc 100644
--- a/internal/authorizedapp/config.go
+++ b/internal/authorizedapp/config.go
@@ -21,7 +21,7 @@ import (
 type Config struct {
 	// CacheDuration is the amount of time AuthorizedApp should be cached before
 	// being re-read from their provider.
-	CacheDuration time.Duration `envconfig:"AUTHORIZED_APP_CACHE_DURATION" default:"5m"`
+	CacheDuration time.Duration `env:"AUTHORIZED_APP_CACHE_DURATION,default=5m"`
 }
 
 // AuthorizedApp implements an interface for setup.
diff --git a/internal/authorizedapp/database_provider.go b/internal/authorizedapp/database_provider.go
index 76e50e0..752905d 100644
--- a/internal/authorizedapp/database_provider.go
+++ b/internal/authorizedapp/database_provider.go
@@ -17,12 +17,13 @@ package authorizedapp
 import (
 	"context"
 	"fmt"
+	"log"
 	"strings"
-	"sync"
 	"time"
 
 	authorizedappdb "github.com/google/exposure-notifications-server/internal/authorizedapp/database"
 	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	"github.com/google/exposure-notifications-server/internal/cache"
 	"github.com/google/exposure-notifications-server/internal/database"
 
 	"github.com/google/exposure-notifications-server/internal/logging"
@@ -39,13 +40,7 @@ type DatabaseProvider struct {
 	secretManager secrets.SecretManager
 	cacheDuration time.Duration
 
-	cache     map[string]*cacheItem
-	cacheLock sync.RWMutex
-}
-
-type cacheItem struct {
-	value    *model.AuthorizedApp
-	cachedAt time.Time
+	cache *cache.Cache
 }
 
 // DatabaseProviderOption is used as input to the database provider.
@@ -61,10 +56,14 @@ func WithSecretManager(sm secrets.SecretManager) DatabaseProviderOption {
 
 // NewDatabaseProvider creates a new Provider that reads from a database.
 func NewDatabaseProvider(ctx context.Context, db *database.DB, config *Config, opts ...DatabaseProviderOption) (Provider, error) {
+	cache, err := cache.New(config.CacheDuration)
+	if err != nil {
+		return nil, fmt.Errorf("cache.New: %w", err)
+	}
 	provider := &DatabaseProvider{
 		database:      db,
 		cacheDuration: config.CacheDuration,
-		cache:         make(map[string]*cacheItem),
+		cache:         cache,
 	}
 
 	// Apply options.
@@ -75,25 +74,6 @@ func NewDatabaseProvider(ctx context.Context, db *database.DB, config *Config, o
 	return provider, nil
 }
 
-// checkCache checks the local cache within a read lock.
-// The bool on return is true if there was a hit (And an error is a valid hit)
-// or false if there was a miss (or expiry) and the data source should be queried again.
-func (p *DatabaseProvider) checkCache(name string) (*model.AuthorizedApp, bool, error) {
-	// Acquire a read lock first, which allows concurrent readers, to check if
-	// there's an item in the cache.
-	p.cacheLock.RLock()
-	defer p.cacheLock.RUnlock()
-
-	item, ok := p.cache[name]
-	if ok && time.Since(item.cachedAt) <= p.cacheDuration {
-		if item.value == nil {
-			return nil, true, ErrAppNotFound
-		}
-		return item.value, true, nil
-	}
-	return nil, false, nil
-}
-
 // AppConfig returns the config for the given app package name.
 func (p *DatabaseProvider) AppConfig(ctx context.Context, name string) (*model.AuthorizedApp, error) {
 	logger := logging.FromContext(ctx)
@@ -102,42 +82,30 @@ func (p *DatabaseProvider) AppConfig(ctx context.Context, name string) (*model.A
 	// cacher does not. To maximize cache hits, convert to lowercase.
 	name = strings.ToLower(name)
 
-	data, cacheHit, error := p.checkCache(name)
-	if cacheHit {
-		return data, error
-	}
-
-	// Acquire a more aggressive lock now because we're about to mutate. However,
-	// it's possible that a concurrent routine has already mutated between our
-	// read and write locks, so we have to check again.
-	p.cacheLock.Lock()
-	defer p.cacheLock.Unlock()
-	item, ok := p.cache[name]
-	if ok && time.Since(item.cachedAt) <= p.cacheDuration {
-		if item.value == nil {
-			return nil, ErrAppNotFound
+	lookup := func() (interface{}, error) {
+		// Load config.
+		config, err := p.loadAuthorizedAppFromDatabase(ctx, name)
+		if err != nil {
+			return nil, fmt.Errorf("authorizedapp: %w", err)
 		}
-		return item.value, nil
+		logger.Infof("authorizedapp: loaded %v, caching for %s", name, p.cacheDuration)
+		return config, nil
 	}
+	cached, err := p.cache.WriteThruLookup(name, lookup)
 
-	// Load config.
-	config, err := p.loadAuthorizedAppFromDatabase(ctx, name)
+	// Indicates an error on the write thru lookup.
 	if err != nil {
-		return nil, fmt.Errorf("authorizedapp: %w", err)
-	}
-
-	// Cache configs.
-	logger.Infof("authorizedapp: loaded %v, caching for %s", name, p.cacheDuration)
-	p.cache[name] = &cacheItem{
-		value:    config,
-		cachedAt: time.Now(),
+		return nil, err
 	}
 
 	// Handle not found.
+	config := cached.(*model.AuthorizedApp)
 	if config == nil {
 		return nil, ErrAppNotFound
 	}
 
+	log.Printf("AppConfig: %+v %v", config, err)
+
 	// Returned config.
 	return config, nil
 }
@@ -154,3 +122,8 @@ func (p *DatabaseProvider) loadAuthorizedAppFromDatabase(ctx context.Context, na
 	}
 	return config, nil
 }
+
+// Add adds a new authorized app to the system.
+func (p *DatabaseProvider) Add(ctx context.Context, app *model.AuthorizedApp) error {
+	return authorizedappdb.New(p.database).InsertAuthorizedApp(ctx, app)
+}
diff --git a/internal/authorizedapp/memory_provider.go b/internal/authorizedapp/memory_provider.go
index cd11d2e..b6d6ff7 100644
--- a/internal/authorizedapp/memory_provider.go
+++ b/internal/authorizedapp/memory_provider.go
@@ -16,7 +16,9 @@ package authorizedapp
 
 import (
 	"context"
+	"fmt"
 	"strings"
+	"sync"
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
 )
@@ -27,26 +29,44 @@ var _ Provider = (*MemoryProvider)(nil)
 // MemoryProvider is an Provider that stores values in-memory. It is primarily
 // used for testing.
 type MemoryProvider struct {
-	Data map[string]*model.AuthorizedApp
+	lock sync.RWMutex
+	data map[string]*model.AuthorizedApp
 }
 
-// NewMemoryProvider creates a new Provider that reads from a database.
-func NewMemoryProvider(ctx context.Context, _ *Config) (Provider, error) {
+// NewMemoryProvider creates a new Provider that is in memory.
+func NewMemoryProvider(_ context.Context, _ *Config) (Provider, error) {
 	provider := &MemoryProvider{
-		Data: make(map[string]*model.AuthorizedApp),
+		data: make(map[string]*model.AuthorizedApp),
 	}
-
 	return provider, nil
 }
 
 // AppConfig returns the config for the given app package name.
-func (p *MemoryProvider) AppConfig(ctx context.Context, name string) (*model.AuthorizedApp, error) {
+func (p *MemoryProvider) AppConfig(_ context.Context, name string) (*model.AuthorizedApp, error) {
+	p.lock.RLock()
+	defer p.lock.RUnlock()
+
 	// Match case-insensitivy of the database.
 	name = strings.ToLower(name)
 
-	val, ok := p.Data[name]
+	val, ok := p.data[name]
 	if !ok {
 		return nil, ErrAppNotFound
 	}
 	return val, nil
 }
+
+// Add inserts the app. It returns an error if the app already exists.
+func (p *MemoryProvider) Add(_ context.Context, app *model.AuthorizedApp) error {
+	p.lock.Lock()
+	defer p.lock.Unlock()
+
+	name := strings.ToLower(app.AppPackageName)
+
+	if _, ok := p.data[name]; ok {
+		return fmt.Errorf("%v already exists", name)
+	}
+
+	p.data[name] = app
+	return nil
+}
diff --git a/internal/authorizedapp/provider.go b/internal/authorizedapp/provider.go
index 8706585..dcf1c89 100644
--- a/internal/authorizedapp/provider.go
+++ b/internal/authorizedapp/provider.go
@@ -33,5 +33,8 @@ type Provider interface {
 	//
 	// The name field is case-insensitive. Implementers should adjust accordingly
 	// to handle mixed case. com.MyApp is the same as com.myapp.
-	AppConfig(ctx context.Context, name string) (*model.AuthorizedApp, error)
+	AppConfig(context.Context, string) (*model.AuthorizedApp, error)
+
+	// Add inserts a model into the provider.
+	Add(context.Context, *model.AuthorizedApp) error
 }
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
new file mode 100644
index 0000000..976f951
--- /dev/null
+++ b/internal/cache/cache.go
@@ -0,0 +1,141 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package cache implements an inmemory cache for any interface{} object.
+package cache
+
+import (
+	"errors"
+	"sync"
+	"time"
+)
+
+var ErrInvalidDuration = errors.New("expireAfter duration cannot be negative")
+
+type Func func() (interface{}, error)
+
+type Cache struct {
+	data        map[string]item
+	expireAfter time.Duration
+	mu          sync.RWMutex
+}
+
+type item struct {
+	object    interface{}
+	expiresAt int64
+}
+
+func (i *item) expired() bool {
+	return i.expiresAt < time.Now().UnixNano()
+}
+
+// New creates a new in memory cache.
+func New(expireAfter time.Duration) (*Cache, error) {
+	if expireAfter < 0 {
+		return nil, ErrInvalidDuration
+	}
+
+	return &Cache{
+		data:        make(map[string]item),
+		expireAfter: expireAfter,
+	}, nil
+}
+
+// Removes an item by name and expiry time when the purge was scheduled.
+// If there is a race, and the item has been refreshed, it will not be purged.
+func (c *Cache) purgeExpired(name string, expectedExpiryTime int64) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	if item, ok := c.data[name]; ok && item.expiresAt == expectedExpiryTime {
+		// found, and the expiry time is still the same as when the purge was requested.
+		delete(c.data, name)
+	}
+}
+
+// Size returns the number of items in the cache.
+func (c *Cache) Size() int {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return len(c.data)
+}
+
+// WriteThruLookup checks the cache for the value associated with name,
+// and if not found or expired, invokes the provided primaryLookup function
+// to local the value.
+func (c *Cache) WriteThruLookup(name string, primaryLookup Func) (interface{}, error) {
+	// This call takes a read lock.
+	val, hit := c.Lookup(name)
+	if hit {
+		return val, nil
+	}
+
+	// Escalate the lock to a RW lock.
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	// double check that the value hasn't been set by another goroutine.
+	if val, hit := c.data[name]; hit && !val.expired() {
+		return val, nil
+	}
+	// Either a miss, or hit w/ expired value.
+
+	// Value does indeed need to be refreshed. Used the provided fucntion.
+	newData, err := primaryLookup()
+	if err != nil {
+		return nil, err
+	}
+
+	// save the newData in the cache. newData may be nil, if that's what the WriteThruFunction provided.
+	c.data[name] = item{
+		object:    newData,
+		expiresAt: time.Now().Add(c.expireAfter).UnixNano(),
+	}
+	return newData, nil
+
+}
+
+// Lookup checks the cache for a non-expired object by the supplied key name.
+// The bool return informs the caller if there was a cache hit or not.
+// A return of nil, true means that nil is in the cache.
+// Where nil, false indicates a cache miss or that the value is expired and should
+// be refreshed.
+func (c *Cache) Lookup(name string) (interface{}, bool) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	if item, ok := c.data[name]; ok && item.expired() {
+		// Cache hit, but expired. The removal from the cache is deferred.
+		go c.purgeExpired(name, item.expiresAt)
+		return nil, false
+	} else if ok {
+		// Cache hit, not expired.
+		return item.object, true
+	}
+	// Cache miss.
+	return nil, false
+}
+
+// Set saves the current value of an object in the cache, with the supplied
+// durintion until the object expires.
+func (c *Cache) Set(name string, object interface{}) error {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	c.data[name] = item{
+		object:    object,
+		expiresAt: time.Now().Add(c.expireAfter).UnixNano(),
+	}
+
+	return nil
+}
diff --git a/internal/cleanup/config.go b/internal/cleanup/config.go
index 99bf3e7..55646f1 100644
--- a/internal/cleanup/config.go
+++ b/internal/cleanup/config.go
@@ -31,13 +31,13 @@ var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 // Config represents the configuration and associated environment variables for
 // the cleanup components.
 type Config struct {
-	Storage       storage.Config
 	Database      database.Config
 	SecretManager secrets.Config
+	Storage       storage.Config
 
-	Port    string        `envconfig:"PORT" default:"8080"`
-	Timeout time.Duration `envconfig:"CLEANUP_TIMEOUT" default:"10m"`
-	TTL     time.Duration `envconfig:"CLEANUP_TTL" default:"336h"`
+	Port    string        `env:"PORT, default=8080"`
+	Timeout time.Duration `env:"CLEANUP_TIMEOUT, default=10m"`
+	TTL     time.Duration `env:"CLEANUP_TTL, default=336h"`
 }
 
 func (c *Config) BlobstoreConfig() *storage.Config {
diff --git a/internal/database/config.go b/internal/database/config.go
index 83f2af1..4cb9fd1 100644
--- a/internal/database/config.go
+++ b/internal/database/config.go
@@ -19,23 +19,23 @@ import (
 )
 
 type Config struct {
-	Name               string        `envconfig:"DB_NAME"`
-	User               string        `envconfig:"DB_USER"`
-	Host               string        `envconfig:"DB_HOST" default:"localhost"`
-	Port               string        `envconfig:"DB_PORT" default:"5432"`
-	SSLMode            string        `envconfig:"DB_SSLMODE" default:"require"`
-	ConnectionTimeout  int           `envconfig:"DB_CONNECT_TIMEOUT"`
-	Password           string        `envconfig:"DB_PASSWORD"`
-	SSLCertPath        string        `envconfig:"DB_SSLCERT"`
-	SSLKeyPath         string        `envconfig:"DB_SSLKEY"`
-	SSLRootCertPath    string        `envconfig:"DB_SSLROOTCERT"`
-	PoolMinConnections string        `envconfig:"DB_POOL_MIN_CONNS"`
-	PoolMaxConnections string        `envconfig:"DB_POOL_MAX_CONNS"`
-	PoolMaxConnLife    time.Duration `envconfig:"DB_POOL_MAX_CONN_LIFETIME"`
-	PoolMaxConnIdle    time.Duration `envconfig:"DB_POOL_MAX_CONN_IDLE_TIME"`
-	PoolHealthCheck    time.Duration `envconfig:"DB_POOL_HEALTH_CHECK_PERIOD"`
+	Name               string        `env:"DB_NAME"`
+	User               string        `env:"DB_USER"`
+	Host               string        `env:"DB_HOST, default=localhost"`
+	Port               string        `env:"DB_PORT, default=5432"`
+	SSLMode            string        `env:"DB_SSLMODE, default=require"`
+	ConnectionTimeout  int           `env:"DB_CONNECT_TIMEOUT"`
+	Password           string        `env:"DB_PASSWORD"`
+	SSLCertPath        string        `env:"DB_SSLCERT"`
+	SSLKeyPath         string        `env:"DB_SSLKEY"`
+	SSLRootCertPath    string        `env:"DB_SSLROOTCERT"`
+	PoolMinConnections string        `env:"DB_POOL_MIN_CONNS"`
+	PoolMaxConnections string        `env:"DB_POOL_MAX_CONNS"`
+	PoolMaxConnLife    time.Duration `env:"DB_POOL_MAX_CONN_LIFETIME"`
+	PoolMaxConnIdle    time.Duration `env:"DB_POOL_MAX_CONN_IDLE_TIME"`
+	PoolHealthCheck    time.Duration `env:"DB_POOL_HEALTH_CHECK_PERIOD"`
 }
 
-func (c *Config) DB() *Config {
+func (c *Config) DatabaseConfig() *Config {
 	return c
 }
diff --git a/internal/database/database_util.go b/internal/database/database_util.go
index 7a7df1c..5e9488f 100644
--- a/internal/database/database_util.go
+++ b/internal/database/database_util.go
@@ -18,7 +18,6 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"log"
 	"net"
 	"net/url"
 	"os"
@@ -40,9 +39,9 @@ import (
 	// imported to register the "postgres" database driver for migrate
 )
 
-// NewTestDatabase creates a new database suitable for use in testing. This
-// should not be used outside of testing, but it is exposed in the main package
-// so it can be shared with other packages.
+// NewTestDatabaseWithConfig creates a new database suitable for use in testing.
+// This should not be used outside of testing, but it is exposed in the main
+// package so it can be shared with other packages.
 //
 // All database tests can be skipped by running `go test -short` or by setting
 // the `SKIP_DATABASE_TESTS` environment variable.
@@ -106,6 +105,10 @@ func NewTestDatabaseWithConfig(tb testing.TB) (*DB, *Config) {
 	q.Add("sslmode", "disable")
 	connURL.RawQuery = q.Encode()
 
+	// Wait for the container to start - we'll retry connections in a loop below,
+	// but there's no point in trying immediately.
+	time.Sleep(1 * time.Second)
+
 	// Establish a connection to the database. Use a Fibonacci backoff instead of
 	// exponential so wait times scale appropriately.
 	var dbpool *pgxpool.Pool
@@ -113,7 +116,7 @@ func NewTestDatabaseWithConfig(tb testing.TB) (*DB, *Config) {
 		var err error
 		dbpool, err = pgxpool.Connect(ctx, connURL.String())
 		if err != nil {
-			log.Printf("connector: %d: %s\n\n", time.Now().Unix(), err)
+			tb.Logf("retrying error: %v", err)
 			return retry.RetryableError(err)
 		}
 		return nil
diff --git a/internal/envconfig/envconfig.go b/internal/envconfig/envconfig.go
deleted file mode 100644
index 5e08336..0000000
--- a/internal/envconfig/envconfig.go
+++ /dev/null
@@ -1,179 +0,0 @@
-// Copyright 2020 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// Package envconfig resolves configuration and secret values from environment
-// variables. This works by transforming the environment variables and then
-// invoking github.com/kelseyhightower/envconfig to map the environment
-// variables into a provided struct.
-//
-// If an environment variable begins with "secret://", the remaining string bits
-// are used to resolve the value in the provided SecretManager. For example:
-//
-//     FOO=secret://foo/bar/baz => secretmanager.GetSecretValue("foo/bar/baz")
-//
-// The environment variables are rewritten to be the secret value, but this is
-// only visible within the running process and any child processes.
-//
-// If an environment variable secret ends with "?target=file" then the resulting
-// secret value is written to SECRETS_DIR and the environment variable is
-// updated to be the local path to that file.
-//
-// This can be used with any secret manager that implements the
-// 'secrets.SecretManager' interface.
-package envconfig
-
-import (
-	"context"
-	"crypto/sha1"
-	"fmt"
-	"io/ioutil"
-	"os"
-	"path/filepath"
-	"strings"
-
-	"github.com/google/exposure-notifications-server/internal/logging"
-	"github.com/google/exposure-notifications-server/internal/secrets"
-	kenvconfig "github.com/kelseyhightower/envconfig"
-)
-
-const (
-	// SecretPrefix is the prefix, that if the value of an env var starts with
-	// will be resolved through the configured secret store.
-	SecretPrefix = "secret://"
-
-	// FileSuffix is the suffix to use, if this secret path should be written to a file.
-	// only interpreted on environment variable values that start w/ secret://
-	FileSuffix = "?target=file"
-)
-
-// BaseConfig is the default base configuration.
-type BaseConfig struct {
-	// SecretsDir is the base directory where secrets are stored.
-	SecretsDir string `envconfig:"SECRETS_DIR" default:"/var/run/secrets"`
-}
-
-// Process processes the provided spec, resolving any values that match the
-// given struct tags for `envconfig`. If any values start with "secret://",
-// those values are resolved using the provided SecretManager. SecretManager can
-// be nil if no secrets are being resolved.
-func Process(ctx context.Context, spec interface{}, sm secrets.SecretManager) error {
-	logger := logging.FromContext(ctx)
-
-	// First resolve the base configuration to get the configured secrets
-	// directory and any other base values.
-	var config BaseConfig
-	if err := kenvconfig.Process("", &config); err != nil {
-		return fmt.Errorf("failed to process base config: %w", err)
-	}
-
-	// Now resolve any secrets in the environment.
-	if err := resolveSecrets(ctx, sm, config.SecretsDir); err != nil {
-		return err
-	}
-
-	// Now process the updated environment into the spec interface.
-	if err := kenvconfig.Process("", spec); err != nil {
-		return fmt.Errorf("failed to process given config: %w", err)
-	}
-	logger.Infof("loaded environment")
-	return nil
-}
-
-// resolveSecrets resolves individual secrets in the environment.
-func resolveSecrets(ctx context.Context, sm secrets.SecretManager, dir string) error {
-	logger := logging.FromContext(ctx)
-	for _, e := range os.Environ() {
-		parts := strings.SplitN(e, "=", 2)
-		if len(parts) != 2 {
-			logger.Errorf("environment variable unexpected format: %v", e)
-			continue
-		}
-
-		envName, secretRef := parts[0], parts[1]
-		if strings.HasPrefix(secretRef, SecretPrefix) {
-			// Short circuit if no secret manager was configured.
-			if sm == nil {
-				return fmt.Errorf("environment requests secrets, but no secret manager is configured")
-			}
-
-			// Remove the prefix.
-			secretRef = strings.TrimPrefix(secretRef, SecretPrefix)
-
-			// Check if the value should be written to a file.
-			toFile := false
-			if strings.HasSuffix(secretRef, FileSuffix) {
-				toFile = true
-				secretRef = strings.TrimSuffix(secretRef, FileSuffix)
-			}
-
-			logger.Infof("resolving secret value for %q (toFile=%t)", envName, toFile)
-
-			secretVal, err := sm.GetSecretValue(ctx, secretRef)
-			if err != nil {
-				return fmt.Errorf("failed to resolve %q: %w", secretRef, err)
-			}
-
-			if toFile {
-				if err := ensureSecureDir(dir); err != nil {
-					return err
-				}
-
-				secretFileName := filenameForSecret(envName + "." + secretRef)
-				secretFilePath := filepath.Join(dir, secretFileName)
-				if err := ioutil.WriteFile(secretFilePath, []byte(secretVal), 0600); err != nil {
-					return fmt.Errorf("failed to write secret file for %q: %w", envName, err)
-				}
-
-				logger.Infof("wrote secret file for %v", envName)
-				secretVal = secretFilePath
-			}
-
-			// Replace the value of the environment variable with the either the resolved secret value
-			// or the file path to the secret value saved on the filesystem.
-			//
-			// This takes effect for this process and any child processes only.
-			// When envconfig is used to load a variable if
-			//   DB_PASS was "secret://pathto/databasepassword"
-			//   DB_PASS will not be the actual database password for the application to consume.
-			os.Setenv(envName, secretVal)
-		}
-	}
-
-	return nil
-}
-
-// filenameForSecret returns the sha1 of the secret name.
-func filenameForSecret(name string) string {
-	return fmt.Sprintf("%x", sha1.Sum([]byte(name)))
-}
-
-// ensureSecureDir creates the parent secretsDir with minimal permissions. If
-// the directory does not exist, it is created with 0700 permissions. If the
-// directory exists but has broader permissions that 0700, an error is returned.
-func ensureSecureDir(dir string) error {
-	stat, err := os.Stat(dir)
-	if err != nil && !os.IsNotExist(err) {
-		return fmt.Errorf("failed to check if secure directory %q exists: %w", dir, err)
-	}
-	if os.IsNotExist(err) {
-		if err := os.MkdirAll(dir, 0700); err != nil {
-			return fmt.Errorf("failed to create secure directory %q: %w", dir, err)
-		}
-	} else {
-		if stat.Mode().Perm() != os.FileMode(0700) {
-			return fmt.Errorf("secure directory %q exists and is not restricted %v", dir, stat.Mode())
-		}
-	}
-	return nil
-}
diff --git a/internal/export/config.go b/internal/export/config.go
index 4def66e..a976ebc 100644
--- a/internal/export/config.go
+++ b/internal/export/config.go
@@ -33,20 +33,20 @@ var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 // Config represents the configuration and associated environment variables for
 // the export components.
 type Config struct {
-	Storage       storage.Config
 	Database      database.Config
 	KeyManager    signing.Config
 	SecretManager secrets.Config
+	Storage       storage.Config
 
-	Port           string        `envconfig:"PORT" default:"8080"`
-	CreateTimeout  time.Duration `envconfig:"CREATE_BATCHES_TIMEOUT" default:"5m"`
-	WorkerTimeout  time.Duration `envconfig:"WORKER_TIMEOUT" default:"5m"`
-	MinRecords     int           `envconfig:"EXPORT_FILE_MIN_RECORDS" default:"1000"`
-	PaddingRange   int           `envconfig:"EXPORT_FILE_PADDING_RANGE" default:"100"`
-	MaxRecords     int           `envconfig:"EXPORT_FILE_MAX_RECORDS" default:"30000"`
-	TruncateWindow time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
-	MinWindowAge   time.Duration `envconfig:"MIN_WINDOW_AGE" default:"2h"`
-	TTL            time.Duration `envconfig:"CLEANUP_TTL" default:"336h"`
+	Port           string        `env:"PORT, default=8080"`
+	CreateTimeout  time.Duration `env:"CREATE_BATCHES_TIMEOUT, default=5m"`
+	WorkerTimeout  time.Duration `env:"WORKER_TIMEOUT, default=5m"`
+	MinRecords     int           `env:"EXPORT_FILE_MIN_RECORDS, default=1000"`
+	PaddingRange   int           `env:"EXPORT_FILE_PADDING_RANGE, default=100"`
+	MaxRecords     int           `env:"EXPORT_FILE_MAX_RECORDS, default=30000"`
+	TruncateWindow time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
+	MinWindowAge   time.Duration `env:"MIN_WINDOW_AGE, default=2h"`
+	TTL            time.Duration `env:"CLEANUP_TTL, default=336h"`
 }
 
 func (c *Config) BlobstoreConfig() *storage.Config {
diff --git a/internal/export/database/export.go b/internal/export/database/export.go
index c66bf0b..6d922f5 100644
--- a/internal/export/database/export.go
+++ b/internal/export/database/export.go
@@ -401,7 +401,7 @@ func (db *ExportDB) AddExportBatches(ctx context.Context, batches []*model.Expor
 }
 
 // LeaseBatch returns a leased ExportBatch for the worker to process. If no work to do, nil will be returned.
-func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.Time) (*model.ExportBatch, error) {
+func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, batchMaxCloseTime time.Time) (*model.ExportBatch, error) {
 	// Lookup a set of candidate batch IDs.
 	var openBatchIDs []int64
 	err := func() error { // Use a func to allow defer conn.Release() to work.
@@ -427,7 +427,7 @@ func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.
 			AND
 				end_timestamp < $3
 			LIMIT 100
-		`, model.ExportBatchOpen, model.ExportBatchPending, now)
+		`, model.ExportBatchOpen, model.ExportBatchPending, batchMaxCloseTime)
 		if err != nil {
 			return err
 		}
@@ -475,7 +475,7 @@ func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.
 				return err
 			}
 
-			if status == model.ExportBatchComplete || (expires != nil && status == model.ExportBatchPending && now.Before(*expires)) {
+			if status == model.ExportBatchComplete || (expires != nil && status == model.ExportBatchPending && batchMaxCloseTime.Before(*expires)) {
 				// Something beat us to this batch, it's no longer available.
 				return nil
 			}
@@ -487,7 +487,7 @@ func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.
 					status = $1, lease_expires = $2
 				WHERE
 				    batch_id = $3
-				`, model.ExportBatchPending, now.Add(ttl), bid)
+				`, model.ExportBatchPending, batchMaxCloseTime.Add(ttl), bid)
 			if err != nil {
 				return err
 			}
diff --git a/internal/export/worker.go b/internal/export/worker.go
index 22663fa..87c1966 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -60,12 +60,8 @@ func (s *Server) WorkerHandler(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		// Only consider batches that closed a few minutes ago to allow the publish
-		// windows to close properly.
-		minutesAgo := time.Now().Add(-5 * time.Minute)
-
 		// Check for a batch and obtain a lease for it.
-		batch, err := exportDB.LeaseBatch(ctx, s.config.WorkerTimeout, minutesAgo)
+		batch, err := exportDB.LeaseBatch(ctx, s.config.WorkerTimeout, time.Now())
 		if err != nil {
 			logger.Errorf("Failed to lease batch: %v", err)
 			continue
diff --git a/internal/federationin/config.go b/internal/federationin/config.go
index e221826..328a525 100644
--- a/internal/federationin/config.go
+++ b/internal/federationin/config.go
@@ -44,20 +44,23 @@ type Config struct {
 	Database      database.Config
 	SecretManager secrets.Config
 
-	Port           string        `envconfig:"PORT" default:"8080"`
-	Timeout        time.Duration `envconfig:"RPC_TIMEOUT" default:"10m"`
-	TruncateWindow time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
+	Port           string        `env:"PORT, default=8080"`
+	Timeout        time.Duration `env:"RPC_TIMEOUT, default=10m"`
+	TruncateWindow time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
 
-	// TLSSkipVerify, if set to true, causes the server certificate to not be verified.
-	// This is typically used when testing locally with self-signed certificates.
-	TLSSkipVerify bool `envconfig:"TLS_SKIP_VERIFY" default:"false"`
+	// TLSSkipVerify, if set to true, causes the server certificate to not be
+	// verified. This is typically used when testing locally with self-signed
+	// certificates.
+	TLSSkipVerify bool `env:"TLS_SKIP_VERIFY"`
 
-	// TLSCertFile points to an optional cert file that will be appended to the system certificates.
-	TLSCertFile string `envconfig:"TLS_CERT_FILE"`
+	// TLSCertFile points to an optional cert file that will be appended to the
+	// system certificates.
+	TLSCertFile string `env:"TLS_CERT_FILE"`
 
-	// CredentialsFile points to a JSON credentials file. If running on Managed Cloud Run,
-	// or if using $GOOGLE_APPLICATION_CREDENTIALS, leave this value empty.
-	CredentialsFile string `envconfig:"CREDENTIALS_FILE"`
+	// CredentialsFile points to a JSON credentials file. If running on Managed
+	// Cloud Run, or if using $GOOGLE_APPLICATION_CREDENTIALS, leave this value
+	// empty.
+	CredentialsFile string `env:"CREDENTIALS_FILE"`
 }
 
 func (c *Config) DatabaseConfig() *database.Config {
diff --git a/internal/federationout/config.go b/internal/federationout/config.go
index 48aa0cd..9b74dea 100644
--- a/internal/federationout/config.go
+++ b/internal/federationout/config.go
@@ -31,19 +31,20 @@ type Config struct {
 	Database      database.Config
 	SecretManager secrets.Config
 
-	Port           string        `envconfig:"PORT" default:"8080"`
-	Timeout        time.Duration `envconfig:"RPC_TIMEOUT" default:"5m"`
-	TruncateWindow time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
-
-	// AllowAnyClient, if true, removes authentication requirements on the federation endpoint.
-	// In practise, this is only useful in local testing.
-	AllowAnyClient bool `envconfig:"ALLOW_ANY_CLIENT" default:"false"`
-
-	// TLSCertFile is the certificate file to use if TLS encryption is enabled on the server.
-	// If present, TLSKeyFile must also be present. These settings should be left blank on
-	// Managed Cloud Run where the TLS termination is handled by the environment.
-	TLSCertFile string `envconfig:"TLS_CERT_FILE"`
-	TLSKeyFile  string `envconfig:"TLS_KEY_FILE"`
+	Port           string        `env:"PORT, default=8080"`
+	Timeout        time.Duration `env:"RPC_TIMEOUT, default=5m"`
+	TruncateWindow time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
+
+	// AllowAnyClient, if true, removes authentication requirements on the
+	// federation endpoint. In practise, this is only useful in local testing.
+	AllowAnyClient bool `env:"ALLOW_ANY_CLIENT"`
+
+	// TLSCertFile is the certificate file to use if TLS encryption is enabled on
+	// the server. If present, TLSKeyFile must also be present. These settings
+	// should be left blank on Managed Cloud Run where the TLS termination is
+	// handled by the environment.
+	TLSCertFile string `env:"TLS_CERT_FILE"`
+	TLSKeyFile  string `env:"TLS_KEY_FILE"`
 }
 
 func (c *Config) DatabaseConfig() *database.Config {
diff --git a/internal/generate/config.go b/internal/generate/config.go
index 62dd0c2..07c7143 100644
--- a/internal/generate/config.go
+++ b/internal/generate/config.go
@@ -33,13 +33,13 @@ type Config struct {
 	Database      database.Config
 	SecretManager secrets.Config
 
-	Port             string        `envconfig:"PORT" default:"8080"`
-	NumExposures     int           `envconfig:"NUM_EXPOSURES_GENERATED" default:"10"`
-	KeysPerExposure  int           `envconfig:"KEYS_PER_EXPOSURE" default:"14"`
-	MaxKeysOnPublish int           `envconfig:"MAX_KEYS_ON_PUBLISH" default:"15"`
-	MaxIntervalAge   time.Duration `envconfig:"MAX_INTERVAL_AGE_ON_PUBLISH" default:"360h"`
-	TruncateWindow   time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
-	DefaultRegion    string        `envconfig:"DEFAULT_REGOIN" default:"US"`
+	Port             string        `env:"PORT, default=8080"`
+	NumExposures     int           `env:"NUM_EXPOSURES_GENERATED, default=10"`
+	KeysPerExposure  int           `env:"KEYS_PER_EXPOSURE, default=14"`
+	MaxKeysOnPublish int           `env:"MAX_KEYS_ON_PUBLISH, default=15"`
+	MaxIntervalAge   time.Duration `env:"MAX_INTERVAL_AGE_ON_PUBLISH, default=360h"`
+	TruncateWindow   time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
+	DefaultRegion    string        `env:"DEFAULT_REGOIN, default=US"`
 }
 
 func (c *Config) DatabaseConfig() *database.Config {
diff --git a/internal/interrupt/context.go b/internal/interrupt/context.go
new file mode 100644
index 0000000..f0d5af4
--- /dev/null
+++ b/internal/interrupt/context.go
@@ -0,0 +1,46 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package interrupt
+
+import (
+	"context"
+	"os"
+	"os/signal"
+	"syscall"
+)
+
+// Context returns a context that is cancelled on SIGINT and SIGTERM.
+func Context() (context.Context, func()) {
+	return WrappedContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+}
+
+// WrappedContext returns a new context wrapping the provided context and
+// cancelling it on the provided signals.
+func WrappedContext(ctx context.Context, signals ...os.Signal) (context.Context, func()) {
+	ctx, closer := context.WithCancel(ctx)
+
+	c := make(chan os.Signal, 1)
+	signal.Notify(c, signals...)
+
+	go func() {
+		select {
+		case <-c:
+			closer()
+		case <-ctx.Done():
+		}
+	}()
+
+	return ctx, closer
+}
diff --git a/internal/interrupt/interrupt.go b/internal/interrupt/interrupt.go
new file mode 100644
index 0000000..af35b9b
--- /dev/null
+++ b/internal/interrupt/interrupt.go
@@ -0,0 +1,16 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package interrupt handles common logic for handling interrupts.
+package interrupt
diff --git a/internal/monolith/config.go b/internal/monolith/config.go
new file mode 100644
index 0000000..1ade931
--- /dev/null
+++ b/internal/monolith/config.go
@@ -0,0 +1,68 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package monolith
+
+import (
+	"github.com/google/exposure-notifications-server/internal/authorizedapp"
+	"github.com/google/exposure-notifications-server/internal/cleanup"
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/export"
+	"github.com/google/exposure-notifications-server/internal/federationin"
+	"github.com/google/exposure-notifications-server/internal/publish"
+	"github.com/google/exposure-notifications-server/internal/secrets"
+	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/signing"
+	"github.com/google/exposure-notifications-server/internal/storage"
+)
+
+var _ setup.AuthorizedAppConfigProvider = (*Config)(nil)
+var _ setup.BlobstoreConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.KeyManagerConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
+
+type Config struct {
+	AuthorizedApp authorizedapp.Config
+	Storage       storage.Config
+	Cleanup       cleanup.Config
+	Database      database.Config
+	Export        export.Config
+	FederationIn  federationin.Config
+	KeyManager    signing.Config
+	Publish       publish.Config
+	SecretManager secrets.Config
+
+	Port string `env:"PORT, default=8080"`
+}
+
+func (c *Config) AuthorizedAppConfig() *authorizedapp.Config {
+	return &c.AuthorizedApp
+}
+
+func (c *Config) BlobstoreConfig() *storage.Config {
+	return &c.Storage
+}
+
+func (c *Config) DatabaseConfig() *database.Config {
+	return &c.Database
+}
+
+func (c *Config) KeyManagerConfig() *signing.Config {
+	return &c.KeyManager
+}
+
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return &c.SecretManager
+}
diff --git a/internal/monolith/monolith_util.go b/internal/monolith/monolith.go
similarity index 51%
rename from internal/monolith/monolith_util.go
rename to internal/monolith/monolith.go
index 58bf7c1..cb8caf5 100644
--- a/internal/monolith/monolith_util.go
+++ b/internal/monolith/monolith.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// Package main runs all the server components at different URL paths.
+// Package monolith runs all the server components at different URL paths.
 package monolith
 
 import (
@@ -20,95 +20,49 @@ import (
 	"fmt"
 	"net/http"
 
-	"go.opencensus.io/plugin/ochttp"
-
-	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/cleanup"
-	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/export"
 	"github.com/google/exposure-notifications-server/internal/federationin"
 	"github.com/google/exposure-notifications-server/internal/handlers"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/publish"
-	"github.com/google/exposure-notifications-server/internal/secrets"
+	"github.com/google/exposure-notifications-server/internal/server"
 	"github.com/google/exposure-notifications-server/internal/setup"
-	"github.com/google/exposure-notifications-server/internal/signing"
-	"github.com/google/exposure-notifications-server/internal/storage"
 
 	// Enable observability with distributed tracing and metrics.
 	_ "github.com/google/exposure-notifications-server/internal/observability"
 )
 
-var _ setup.AuthorizedAppConfigProvider = (*MonoConfig)(nil)
-var _ setup.BlobstoreConfigProvider = (*MonoConfig)(nil)
-var _ setup.DatabaseConfigProvider = (*MonoConfig)(nil)
-var _ setup.KeyManagerConfigProvider = (*MonoConfig)(nil)
-var _ setup.SecretManagerConfigProvider = (*MonoConfig)(nil)
-
-type MonoConfig struct {
-	AuthorizedApp authorizedapp.Config
-	Storage       storage.Config
-	Cleanup       cleanup.Config
-	Database      database.Config
-	Export        export.Config
-	FederationIn  federationin.Config
-	KeyManager    signing.Config
-	Publish       publish.Config
-	SecretManager secrets.Config
-
-	Port string `envconfig:"PORT" default:"8080"`
-}
-
-func (c *MonoConfig) AuthorizedAppConfig() *authorizedapp.Config {
-	return &c.AuthorizedApp
-}
-
-func (c *MonoConfig) BlobstoreConfig() *storage.Config {
-	return &c.Storage
-}
-
-func (c *MonoConfig) DatabaseConfig() *database.Config {
-	return &c.Database
-}
-
-func (c *MonoConfig) KeyManagerConfig() *signing.Config {
-	return &c.KeyManager
-}
-
-func (c *MonoConfig) SecretManagerConfig() *secrets.Config {
-	return &c.SecretManager
-}
-
-func RunServer(ctx context.Context) (*MonoConfig, error) {
+func RunServer(ctx context.Context) error {
 	logger := logging.FromContext(ctx)
 
-	var config MonoConfig
-	env, closer, err := setup.Setup(ctx, &config)
+	var config Config
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
-		return nil, fmt.Errorf("setup.Setup: %w", err)
+		return fmt.Errorf("setup.Setup: %w", err)
 	}
-	defer closer()
+	defer env.Close(ctx)
 
 	mux := http.NewServeMux()
 
 	// Cleanup export
 	cleanupExport, err := cleanup.NewExportHandler(&config.Cleanup, env)
 	if err != nil {
-		return nil, fmt.Errorf("cleanup.NewExportHandler: %w", err)
+		return fmt.Errorf("cleanup.NewExportHandler: %w", err)
 	}
 	mux.Handle("/cleanup-export", cleanupExport)
 
 	// Cleanup exposure
 	cleanupExposure, err := cleanup.NewExposureHandler(&config.Cleanup, env)
 	if err != nil {
-		return nil, fmt.Errorf("cleanup.NewExposureHandler: %w", err)
+		return fmt.Errorf("cleanup.NewExposureHandler: %w", err)
 	}
 	mux.Handle("/cleanup-exposure", cleanupExposure)
 
 	// Export
 	exportServer, err := export.NewServer(&config.Export, env)
 	if err != nil {
-		return nil, fmt.Errorf("export.NewServer: %w", err)
+		return fmt.Errorf("export.NewServer: %w", err)
 	}
 	mux.HandleFunc("/export/create-batches", exportServer.CreateBatchesHandler)
 	mux.HandleFunc("/export/do-work", exportServer.WorkerHandler)
@@ -122,12 +76,15 @@ func RunServer(ctx context.Context) (*MonoConfig, error) {
 	// Publish
 	publishServer, err := publish.NewHandler(ctx, &config.Publish, env)
 	if err != nil {
-		return nil, fmt.Errorf("publish.NewHandler: %w", err)
+		return fmt.Errorf("publish.NewHandler: %w", err)
 	}
 	mux.HandleFunc("/publish", handlers.WithMinimumLatency(config.Publish.MinRequestDuration, publishServer))
 
-	instrumentedHandler := &ochttp.Handler{Handler: mux}
+	server, err := server.New(config.Port)
+	if err != nil {
+		return fmt.Errorf("server.New: %w", err)
+	}
+	logger.Infof("listening on :%s", config.Port)
 
-	logger.Infof("monolith running at :%s", config.Port)
-	return &config, http.ListenAndServe(":"+config.Port, instrumentedHandler)
+	return server.ServeHTTPHandler(ctx, mux)
 }
diff --git a/internal/publish/config.go b/internal/publish/config.go
index 5574d75..9777c27 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -36,15 +36,15 @@ type Config struct {
 	Database      database.Config
 	SecretManager secrets.Config
 
-	Port               string        `envconfig:"PORT" default:"8080"`
-	MinRequestDuration time.Duration `envconfig:"TARGET_REQUEST_DURATION" default:"5s"`
-	MaxKeysOnPublish   int           `envconfig:"MAX_KEYS_ON_PUBLISH" default:"15"`
-	MaxIntervalAge     time.Duration `envconfig:"MAX_INTERVAL_AGE_ON_PUBLISH" default:"360h"`
-	TruncateWindow     time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
+	Port               string        `env:"PORT, default=8080"`
+	MinRequestDuration time.Duration `env:"TARGET_REQUEST_DURATION, default=5s"`
+	MaxKeysOnPublish   int           `env:"MAX_KEYS_ON_PUBLISH, default=15"`
+	MaxIntervalAge     time.Duration `env:"MAX_INTERVAL_AGE_ON_PUBLISH, default=360h"`
+	TruncateWindow     time.Duration `env:"TRUNCATE_WINDOW, default=1h"`
 
 	// Flags for local development and testing.
-	DebugAPIResponses       bool `envconfig:"DEBUG_API_RESPONSES"`
-	DebugReleaseSameDayKeys bool `envconfig:"DEBUG_RELEASE_SAME_DAY_KEYS"`
+	DebugAPIResponses       bool `env:"DEBUG_API_RESPONSES"`
+	DebugReleaseSameDayKeys bool `env:"DEBUG_RELEASE_SAME_DAY_KEYS"`
 }
 
 func (c *Config) AuthorizedAppConfig() *authorizedapp.Config {
diff --git a/internal/secrets/config.go b/internal/secrets/config.go
new file mode 100644
index 0000000..12625e4
--- /dev/null
+++ b/internal/secrets/config.go
@@ -0,0 +1,37 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package secrets
+
+import (
+	"time"
+)
+
+// SecretManagerType represents a type of secret manager.
+type SecretManagerType string
+
+const (
+	SecretManagerTypeAWSSecretsManager    SecretManagerType = "AWS_SECRETS_MANAGER"
+	SecretManagerTypeAzureKeyVault        SecretManagerType = "AZURE_KEY_VAULT"
+	SecretManagerTypeGoogleSecretManager  SecretManagerType = "GOOGLE_SECRET_MANAGER"
+	SecretManagerTypeGoogleHashiCorpVault SecretManagerType = "HASHICORP_VAULT"
+	SecretManagerTypeNoop                 SecretManagerType = "NOOP"
+)
+
+// Config represents the config for a secret manager.
+type Config struct {
+	SecretManagerType SecretManagerType `env:"SECRET_MANAGER, default=GOOGLE_SECRET_MANAGER"`
+	SecretsDir        string            `env:"SECRETS_DIR, default=/var/run/secrets"`
+	SecretCacheTTL    time.Duration     `env:"SECRET_CACHE_TTL, default=5m"`
+}
diff --git a/internal/secrets/noop.go b/internal/secrets/noop.go
index 0ac0d45..987c977 100644
--- a/internal/secrets/noop.go
+++ b/internal/secrets/noop.go
@@ -16,7 +16,6 @@ package secrets
 
 import (
 	"context"
-	"fmt"
 )
 
 // Compile-time check to verify implements interface.
@@ -31,5 +30,5 @@ func NewNoop(ctx context.Context) (SecretManager, error) {
 
 // GetSecretValue implements secrets.
 func (n *Noop) GetSecretValue(_ context.Context, _ string) (string, error) {
-	return "", fmt.Errorf("noop cannot resolve secrets")
+	return "noop-secret", nil
 }
diff --git a/internal/secrets/resolver.go b/internal/secrets/resolver.go
new file mode 100644
index 0000000..dff387b
--- /dev/null
+++ b/internal/secrets/resolver.go
@@ -0,0 +1,133 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package secrets
+
+import (
+	"context"
+	"crypto/sha1"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/sethvargo/go-envconfig/pkg/envconfig"
+)
+
+const (
+	// SecretPrefix is the prefix, that if the value of an env var starts with
+	// will be resolved through the configured secret store.
+	SecretPrefix = "secret://"
+
+	// FileSuffix is the suffix to use, if this secret path should be written to a file.
+	// only interpreted on environment variable values that start w/ secret://
+	FileSuffix = "?target=file"
+)
+
+// Resolver returns a function that fetches secrets from the secret manager. If
+// the provided secret manager is nil, the function is nil, Otherwise, it looks
+// for values prefixed with secret:// and resolves them as secrets.
+func Resolver(sm SecretManager, config *Config) envconfig.MutatorFunc {
+	if sm == nil {
+		return nil
+	}
+
+	resolver := &secretResolver{
+		sm:  sm,
+		dir: config.SecretsDir,
+	}
+
+	return func(ctx context.Context, key, value string) (string, error) {
+		return resolver.resolve(ctx, key, value)
+	}
+}
+
+type secretResolver struct {
+	sm  SecretManager
+	dir string
+}
+
+// resolve resolves an individual secretRef using the provided secret manager
+// and configuration.
+func (r *secretResolver) resolve(ctx context.Context, envName, secretRef string) (string, error) {
+	logger := logging.FromContext(ctx)
+
+	if !strings.HasPrefix(secretRef, SecretPrefix) {
+		return secretRef, nil
+	}
+
+	if r.sm == nil {
+		return "", fmt.Errorf("env requested secrets, but no secret manager is configured")
+	}
+
+	// Remove the prefix.
+	secretRef = strings.TrimPrefix(secretRef, SecretPrefix)
+
+	// Check if the value should be written to a file.
+	toFile := false
+	if strings.HasSuffix(secretRef, FileSuffix) {
+		toFile = true
+		secretRef = strings.TrimSuffix(secretRef, FileSuffix)
+	}
+
+	logger.Infof("resolving secret value for %q (toFile=%t)", envName, toFile)
+
+	secretVal, err := r.sm.GetSecretValue(ctx, secretRef)
+	if err != nil {
+		return "", fmt.Errorf("failed to resolve %q: %w", secretRef, err)
+	}
+
+	if toFile {
+		if err := r.ensureSecureDir(); err != nil {
+			return "", err
+		}
+
+		secretFileName := r.filenameForSecret(envName + "." + secretRef)
+		secretFilePath := filepath.Join(r.dir, secretFileName)
+		if err := ioutil.WriteFile(secretFilePath, []byte(secretVal), 0600); err != nil {
+			return "", fmt.Errorf("failed to write secret file for %q: %w", envName, err)
+		}
+
+		secretVal = secretFilePath
+	}
+
+	return secretVal, nil
+}
+
+// filenameForSecret returns the sha1 of the secret name.
+func (r *secretResolver) filenameForSecret(name string) string {
+	return fmt.Sprintf("%x", sha1.Sum([]byte(name)))
+}
+
+// ensureSecureDir creates the parent secretsDir with minimal permissions. If
+// the directory does not exist, it is created with 0700 permissions. If the
+// directory exists but has broader permissions that 0700, an error is returned.
+func (r *secretResolver) ensureSecureDir() error {
+	stat, err := os.Stat(r.dir)
+	if err != nil && !os.IsNotExist(err) {
+		return fmt.Errorf("failed to check if secure directory %q exists: %w", r.dir, err)
+	}
+	if os.IsNotExist(err) {
+		if err := os.MkdirAll(r.dir, 0700); err != nil {
+			return fmt.Errorf("failed to create secure directory %q: %w", r.dir, err)
+		}
+	} else {
+		if stat.Mode().Perm() != os.FileMode(0700) {
+			return fmt.Errorf("secure directory %q exists and is not restricted %v", r.dir, stat.Mode())
+		}
+	}
+	return nil
+}
diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go
index 12a7a79..d26647d 100644
--- a/internal/secrets/secrets.go
+++ b/internal/secrets/secrets.go
@@ -19,26 +19,8 @@ package secrets
 import (
 	"context"
 	"fmt"
-	"time"
 )
 
-// SecretManagerType represents a type of secret manager.
-type SecretManagerType string
-
-const (
-	SecretManagerTypeAWSSecretsManager    SecretManagerType = "AWS_SECRETS_MANAGER"
-	SecretManagerTypeAzureKeyVault        SecretManagerType = "AZURE_KEY_VAULT"
-	SecretManagerTypeGoogleSecretManager  SecretManagerType = "GOOGLE_SECRET_MANAGER"
-	SecretManagerTypeGoogleHashiCorpVault SecretManagerType = "HASHICORP_VAULT"
-	SecretManagerTypeNoop                 SecretManagerType = "NOOP"
-)
-
-// Config represents the config for a secret manager.
-type Config struct {
-	SecretManagerType SecretManagerType `envconfig:"SECRET_MANAGER" default:"GOOGLE_SECRET_MANAGER"`
-	SecretCacheTTL    time.Duration     `envconfig:"SECRET_CACHE_TTL" default:"5m"`
-}
-
 // SecretManager defines the minimum shared functionality for a secret manager
 // used by this application.
 type SecretManager interface {
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..f19bf8e
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,161 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package server provides an opinionated http server.
+package server
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/logging"
+	"go.opencensus.io/plugin/ochttp"
+	"google.golang.org/grpc"
+)
+
+// Server provides a gracefully-stoppable http server implementation. It is safe
+// for concurrent use in goroutines.
+type Server struct {
+	ip       string
+	port     string
+	listener net.Listener
+}
+
+// New creates a new server listening on the provided address that responds to
+// the http.Handler. It starts the listener, but does not start the server. If
+// an empty port is given, the server randombly chooses one.
+func New(port string) (*Server, error) {
+	// Create the net listener first, so the connection ready when we return. This
+	// guarantees that it can accept requests.
+	addr := fmt.Sprintf(":" + port)
+	listener, err := net.Listen("tcp", addr)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create listener on %s: %w", addr, err)
+	}
+
+	return &Server{
+		ip:       listener.Addr().(*net.TCPAddr).IP.String(),
+		port:     strconv.Itoa(listener.Addr().(*net.TCPAddr).Port),
+		listener: listener,
+	}, nil
+}
+
+// ServeHTTP starts the server and blocks until the provided context is closed.
+// When the provided context is closed, the server is gracefully stopped with a
+// timeout of 5 seconds.
+//
+// Once a server has been stopped, it is NOT safe for reuse.
+func (s *Server) ServeHTTP(ctx context.Context, srv *http.Server) error {
+	logger := logging.FromContext(ctx)
+
+	// Spawn a goroutine that listens for context closure. When the context is
+	// closed, the server is stopped.
+	errCh := make(chan error, 1)
+	go func() {
+		<-ctx.Done()
+
+		logger.Debugf("server.Serve: context closed")
+		shutdownCtx, done := context.WithTimeout(context.Background(), 5*time.Second)
+		defer done()
+
+		logger.Debugf("server.Serve: shutting down")
+		if err := srv.Shutdown(shutdownCtx); err != nil {
+			select {
+			case errCh <- err:
+			default:
+			}
+		}
+	}()
+
+	// Run the server. This will block until the provided context is closed.
+	if err := srv.Serve(s.listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
+		return fmt.Errorf("failed to serve: %w", err)
+	}
+
+	logger.Debugf("server.Serve: serving stopped")
+
+	// Return any errors that happened during shutdown.
+	select {
+	case err := <-errCh:
+		return fmt.Errorf("failed to shutdown: %w", err)
+	default:
+		return nil
+	}
+}
+
+// ServeHTTPHandler is a convenience wrapper around ServeHTTP. It creates an
+// HTTP server using the provided handler, wrapped in OpenCensus for
+// observability.
+func (s *Server) ServeHTTPHandler(ctx context.Context, handler http.Handler) error {
+	return s.ServeHTTP(ctx, &http.Server{
+		Handler: &ochttp.Handler{
+			Handler: handler,
+		},
+	})
+}
+
+// ServeGRPC starts the server and blocks until the provided context is closed.
+// When the provided context is closed, the server is gracefully stopped with a
+// timeout of 5 seconds.
+//
+// Once a server has been stopped, it is NOT safe for reuse.
+func (s *Server) ServeGRPC(ctx context.Context, srv *grpc.Server) error {
+	logger := logging.FromContext(ctx)
+
+	// Spawn a goroutine that listens for context closure. When the context is
+	// closed, the server is stopped.
+	errCh := make(chan error, 1)
+	go func() {
+		<-ctx.Done()
+
+		logger.Debugf("server.Serve: context closed")
+		logger.Debugf("server.Serve: shutting down")
+		srv.GracefulStop()
+	}()
+
+	// Run the server. This will block until the provided context is closed.
+	if err := srv.Serve(s.listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
+		return fmt.Errorf("failed to serve: %w", err)
+	}
+
+	logger.Debugf("server.Serve: serving stopped")
+
+	// Return any errors that happened during shutdown.
+	select {
+	case err := <-errCh:
+		return fmt.Errorf("failed to shutdown: %w", err)
+	default:
+		return nil
+	}
+}
+
+// Addr returns the server's listening address (ip + port).
+func (s *Server) Addr() string {
+	return net.JoinHostPort(s.ip, s.port)
+}
+
+// IP returns the server's listening IP.
+func (s *Server) IP() string {
+	return s.ip
+}
+
+// Port returns the server's listening port.
+func (s *Server) Port() string {
+	return s.port
+}
diff --git a/internal/serverenv/env.go b/internal/serverenv/env.go
index 70c4d39..6c19114 100644
--- a/internal/serverenv/env.go
+++ b/internal/serverenv/env.go
@@ -148,3 +148,15 @@ func (s *ServerEnv) MetricsExporter(ctx context.Context) metrics.Exporter {
 	}
 	return s.exporter(ctx)
 }
+
+// Close shuts down the server env, closing database connections, etc.
+func (s *ServerEnv) Close(ctx context.Context) error {
+	if s == nil {
+		return nil
+	}
+
+	if s.database != nil {
+		s.database.Close(ctx)
+	}
+	return nil
+}
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index f527314..7d1826a 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -20,15 +20,13 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/envconfig"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/metrics"
 	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	"github.com/google/exposure-notifications-server/internal/signing"
 	"github.com/google/exposure-notifications-server/internal/storage"
-
-	kenvconfig "github.com/kelseyhightower/envconfig"
+	"github.com/sethvargo/go-envconfig/pkg/envconfig"
 )
 
 // BlobstoreConfigProvider provides the information about current storage
@@ -61,13 +59,28 @@ type SecretManagerConfigProvider interface {
 	SecretManagerConfig() *secrets.Config
 }
 
-// Function returned from setup to be deferred until the caller exits.
-type Defer func()
+// Setup runs common initialization code for all servers. See SetupWith.
+func Setup(ctx context.Context, config interface{}) (*serverenv.ServerEnv, error) {
+	return SetupWith(ctx, config, envconfig.OsLookuper())
+}
 
-// Setup runs common initialization code for all servers.
-func Setup(ctx context.Context, config DatabaseConfigProvider) (*serverenv.ServerEnv, Defer, error) {
+// SetupWith processes the given configuration using envconfig. It is
+// responsible for establishing database connections, resolving secrets, and
+// accessing app configs. The provided interface must implement the various
+// interfaces.
+func SetupWith(ctx context.Context, config interface{}, l envconfig.Lookuper) (*serverenv.ServerEnv, error) {
 	logger := logging.FromContext(ctx)
 
+	// Build a list of mutators. This list will grow as we initialize more of the
+	// configuration, such as the secret manager.
+	var mutatorFuncs []envconfig.MutatorFunc
+
+	// Build a list of options to pass to the server env.
+	var serverEnvOpts []serverenv.Option
+
+	// TODO: support customizable metrics
+	serverEnvOpts = append(serverEnvOpts, serverenv.WithMetricsExporter(metrics.NewLogsBasedFromContext))
+
 	// Load the secret manager - this needs to be loaded first because other
 	// processors may require access to secrets.
 	var sm secrets.SecretManager
@@ -81,79 +94,96 @@ func Setup(ctx context.Context, config DatabaseConfigProvider) (*serverenv.Serve
 		// secret:// reference. This configuration option must always be the
 		// plaintext string.
 		smConfig := provider.SecretManagerConfig()
-		if err := kenvconfig.Process("", smConfig); err != nil {
-			return nil, nil, fmt.Errorf("unable to process secret manager environment: %w", err)
+		if err := envconfig.ProcessWith(ctx, smConfig, l, mutatorFuncs...); err != nil {
+			return nil, fmt.Errorf("unable to process secret manager env: %w", err)
 		}
 
 		var err error
 		sm, err = secrets.SecretManagerFor(ctx, smConfig.SecretManagerType)
 		if err != nil {
-			return nil, nil, fmt.Errorf("unable to connect to secret manager: %w", err)
+			return nil, fmt.Errorf("unable to connect to secret manager: %w", err)
 		}
 
-		// Enable caching, if provided.
+		// Enable caching, if a TTL was provided.
 		if ttl := smConfig.SecretCacheTTL; ttl > 0 {
 			sm = secrets.WrapCacher(ctx, sm, ttl)
 		}
-	}
 
-	// Process first round of environment variables.
-	if err := envconfig.Process(ctx, config, sm); err != nil {
-		return nil, nil, fmt.Errorf("error loading environment variables: %w", err)
-	}
-	logger.Infof("Effective environment variables: %+v", config)
+		// Update the mutators to process secrets.
+		mutatorFuncs = append(mutatorFuncs, secrets.Resolver(sm, smConfig))
 
-	// Start building serverenv opts
-	opts := []serverenv.Option{
-		serverenv.WithSecretManager(sm),
-		serverenv.WithMetricsExporter(metrics.NewLogsBasedFromContext),
+		// Update serverEnv setup.
+		serverEnvOpts = append(serverEnvOpts, serverenv.WithSecretManager(sm))
 	}
 
-	// Configure key management for signing.
+	// Load the key manager.
+	var km signing.KeyManager
 	if provider, ok := config.(KeyManagerConfigProvider); ok {
 		kmConfig := provider.KeyManagerConfig()
-		km, err := signing.KeyManagerFor(ctx, kmConfig.KeyManagerType)
+		if err := envconfig.ProcessWith(ctx, kmConfig, l, mutatorFuncs...); err != nil {
+			return nil, fmt.Errorf("unable to process key manager env: %w", err)
+		}
+
+		var err error
+		km, err = signing.KeyManagerFor(ctx, kmConfig.KeyManagerType)
 		if err != nil {
-			return nil, nil, fmt.Errorf("unable to connect to key manager: %w", err)
+			return nil, fmt.Errorf("unable to connect to key manager: %w", err)
 		}
-		opts = append(opts, serverenv.WithKeyManager(km))
+
+		// Update serverEnv setup.
+		serverEnvOpts = append(serverEnvOpts, serverenv.WithKeyManager(km))
+	}
+
+	// Process first round of environment variables.
+	if err := envconfig.ProcessWith(ctx, config, l, mutatorFuncs...); err != nil {
+		return nil, fmt.Errorf("error loading environment variables: %w", err)
 	}
+	logger.Infof("Effective environment variables: %+v", config)
 
 	// Configure blob storage.
 	if provider, ok := config.(BlobstoreConfigProvider); ok {
 		bsConfig := provider.BlobstoreConfig()
 		blobStore, err := storage.BlobstoreFor(ctx, bsConfig.BlobstoreType)
 		if err != nil {
-			return nil, nil, fmt.Errorf("unable to connect to storage system: %v", err)
+			return nil, fmt.Errorf("unable to connect to storage system: %v", err)
 		}
 		blobStorage := serverenv.WithBlobStorage(blobStore)
-		opts = append(opts, blobStorage)
-	}
 
-	// Setup the database connection.
-	db, err := database.NewFromEnv(ctx, config.DatabaseConfig())
-	if err != nil {
-		return nil, nil, fmt.Errorf("unable to connect to database: %v", err)
-	}
-	{
-		// Log the database config, but omit the password field.
-		redactedDB := config.DatabaseConfig()
-		redactedDB.Password = "<hidden>"
-		logger.Infof("Effective DB config: %+v", redactedDB)
+		// Update serverEnv setup.
+		serverEnvOpts = append(serverEnvOpts, blobStorage)
 	}
-	opts = append(opts, serverenv.WithDatabase(db))
 
-	// AuthorizedApp must come after database setup due to the dependency.
-	if typ, ok := config.(AuthorizedAppConfigProvider); ok {
-		logger.Infof("Effective AuthorizedApp config: %+v", typ.AuthorizedAppConfig())
-		provider, err := authorizedapp.NewDatabaseProvider(ctx, db, typ.AuthorizedAppConfig(), authorizedapp.WithSecretManager(sm))
+	// Setup the database connection.
+	if provider, ok := config.(DatabaseConfigProvider); ok {
+		dbConfig := provider.DatabaseConfig()
+		db, err := database.NewFromEnv(ctx, dbConfig)
 		if err != nil {
-			// Ensure the database is closed on an error.
-			defer db.Close(ctx)
-			return nil, nil, fmt.Errorf("unable to create AuthorizedApp provider: %v", err)
+			return nil, fmt.Errorf("unable to connect to database: %v", err)
+		}
+		{
+			// Log the database config, but omit the password field.
+			redactedDB := dbConfig
+			redactedDB.Password = "<hidden>"
+			logger.Infof("Effective DB config: %+v", redactedDB)
+		}
+
+		// Update serverEnv setup.
+		serverEnvOpts = append(serverEnvOpts, serverenv.WithDatabase(db))
+
+		// AuthorizedApp must come after database setup due to the dependency.
+		if provider, ok := config.(AuthorizedAppConfigProvider); ok {
+			aaConfig := provider.AuthorizedAppConfig()
+			aa, err := authorizedapp.NewDatabaseProvider(ctx, db, aaConfig, authorizedapp.WithSecretManager(sm))
+			if err != nil {
+				// Ensure the database is closed on an error.
+				defer db.Close(ctx)
+				return nil, fmt.Errorf("unable to create AuthorizedApp provider: %v", err)
+			}
+
+			// Update serverEnv setup.
+			serverEnvOpts = append(serverEnvOpts, serverenv.WithAuthorizedAppProvider(aa))
 		}
-		opts = append(opts, serverenv.WithAuthorizedAppProvider(provider))
 	}
 
-	return serverenv.New(ctx, opts...), func() { db.Close(ctx) }, nil
+	return serverenv.New(ctx, serverEnvOpts...), nil
 }
diff --git a/internal/signing/config.go b/internal/signing/config.go
new file mode 100644
index 0000000..c87ac20
--- /dev/null
+++ b/internal/signing/config.go
@@ -0,0 +1,30 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package signing
+
+// KeyManagerType defines a specific key manager.
+type KeyManagerType string
+
+const (
+	KeyManagerTypeAWSKMS         KeyManagerType = "AWS_KMS"
+	KeyManagerTypeGoogleCloudKMS KeyManagerType = "GOOGLE_CLOUD_KMS"
+	KeyManagerTypeHashiCorpVault KeyManagerType = "HASHICORP_VAULT"
+	KeyManagerTypeNoop           KeyManagerType = "NOOP"
+)
+
+// Config defines configuration.
+type Config struct {
+	KeyManagerType KeyManagerType `env:"KEY_MANAGER,default=GOOGLE_CLOUD_KMS"`
+}
diff --git a/internal/signing/signing.go b/internal/signing/signing.go
index 4968c8a..467c278 100644
--- a/internal/signing/signing.go
+++ b/internal/signing/signing.go
@@ -21,21 +21,6 @@ import (
 	"fmt"
 )
 
-// KeyManagerType defines a specific key manager.
-type KeyManagerType string
-
-const (
-	KeyManagerTypeAWSKMS         KeyManagerType = "AWS_KMS"
-	KeyManagerTypeGoogleCloudKMS KeyManagerType = "GOOGLE_CLOUD_KMS"
-	KeyManagerTypeHashiCorpVault KeyManagerType = "HASHICORP_VAULT"
-	KeyManagerTypeNoop           KeyManagerType = "NOOP"
-)
-
-// Config defines configuration.
-type Config struct {
-	KeyManagerType KeyManagerType `envconfig:"KEY_MANAGER" default:"GOOGLE_CLOUD_KMS"`
-}
-
 // KeyManager defines the interface for working with a KMS system that
 // is able to sign bytes using PKI.
 // KeyManager implementations must be able to return a crypto.Signer.
diff --git a/internal/storage/config.go b/internal/storage/config.go
new file mode 100644
index 0000000..f1e3955
--- /dev/null
+++ b/internal/storage/config.go
@@ -0,0 +1,32 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package storage
+
+// BlobstoreType defines a specific blobstore.
+type BlobstoreType string
+
+const (
+	BlobstoreTypeAWSS3              BlobstoreType = "AWS_S3"
+	BlobstoreTypeAzureBlobStorage   BlobstoreType = "AZURE_BLOB_STORAGE"
+	BlobstoreTypeFilesystem         BlobstoreType = "FILESYSTEM"
+	BlobstoreTypeGoogleCloudStorage BlobstoreType = "GOOGLE_CLOUD_STORAGE"
+	BlobstoreTypeMemory             BlobstoreType = "MEMORY"
+	BlobstoreTypeNoop               BlobstoreType = "NOOP"
+)
+
+// Config defines the configuration for a blobstore.
+type Config struct {
+	BlobstoreType BlobstoreType `env:"BLOBSTORE,default=GOOGLE_CLOUD_STORAGE"`
+}
diff --git a/internal/storage/memory.go b/internal/storage/memory.go
new file mode 100644
index 0000000..f98ce00
--- /dev/null
+++ b/internal/storage/memory.go
@@ -0,0 +1,59 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package storage
+
+import (
+	"context"
+	"path"
+	"sync"
+)
+
+// Compile-time check to verify implements interface.
+var _ Blobstore = (*Memory)(nil)
+
+// Memory implements Blobstore and provides the ability write files to
+// memory.
+type Memory struct {
+	lock sync.Mutex
+	data map[string][]byte
+}
+
+// NewMemory creates a Blobstore that writes data in memory.
+func NewMemory(_ context.Context) (Blobstore, error) {
+	return &Memory{
+		data: make(map[string][]byte),
+	}, nil
+}
+
+// CreateObject creates a new object.
+func (s *Memory) CreateObject(_ context.Context, folder, filename string, contents []byte, cacheable bool) error {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	pth := path.Join(folder, filename)
+	s.data[pth] = contents
+	return nil
+}
+
+// DeleteObject deletes an object. It returns nil if the object was deleted or
+// if the object no longer exists.
+func (s *Memory) DeleteObject(_ context.Context, folder, filename string) error {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	pth := path.Join(folder, filename)
+	delete(s.data, pth)
+	return nil
+}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 3b9cbe6..7e8f9dc 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -20,22 +20,6 @@ import (
 	"fmt"
 )
 
-// BlobstoreType defines a specific blobstore.
-type BlobstoreType string
-
-const (
-	BlobstoreTypeAWSS3              BlobstoreType = "AWS_S3"
-	BlobstoreTypeAzureBlobStorage   BlobstoreType = "AZURE_BLOB_STORAGE"
-	BlobstoreTypeGoogleCloudStorage BlobstoreType = "GOOGLE_CLOUD_STORAGE"
-	BlobstoreTypeFilesystem         BlobstoreType = "FILESYSTEM"
-	BlobstoreTypeNoop               BlobstoreType = "NOOP"
-)
-
-// Config defines the configuration for a blobstore.
-type Config struct {
-	BlobstoreType BlobstoreType `envconfig:"BLOBSTORE" default:"GOOGLE_CLOUD_STORAGE"`
-}
-
 // Blobstore defines the minimum interface for a blob storage system.
 type Blobstore interface {
 	// CreateObject creates or overwrites an object in the storage system.
@@ -57,6 +41,8 @@ func BlobstoreFor(ctx context.Context, typ BlobstoreType) (Blobstore, error) {
 		return NewGoogleCloudStorage(ctx)
 	case BlobstoreTypeFilesystem:
 		return NewFilesystemStorage(ctx)
+	case BlobstoreTypeMemory:
+		return NewMemory(ctx)
 	case BlobstoreTypeNoop:
 		return NewNoop(ctx)
 	default:
diff --git a/terraform/main.tf b/terraform/main.tf
index 298452d..a38e1c5 100644
--- a/terraform/main.tf
+++ b/terraform/main.tf
@@ -35,6 +35,7 @@ resource "google_project_service" "services" {
   for_each = toset([
     "cloudbuild.googleapis.com",
     "cloudkms.googleapis.com",
+    "cloudresourcemanager.googleapis.com",
     "cloudscheduler.googleapis.com",
     "compute.googleapis.com",
     "containerregistry.googleapis.com",
diff --git a/terraform/vars.tf b/terraform/vars.tf
index 38ae6fd..1c1ac55 100644
--- a/terraform/vars.tf
+++ b/terraform/vars.tf
@@ -116,8 +116,8 @@ variable "service_environment" {
 
 terraform {
   required_providers {
-    google      = "~> 3.20"
-    google-beta = "~> 3.20"
+    google      = "~> 3.24"
+    google-beta = "~> 3.24"
     null        = "~> 2.1"
     random      = "~> 2.2"
   }
diff --git a/tools/admin-console/main.go b/tools/admin-console/main.go
index 15a09f8..196db0a 100644
--- a/tools/admin-console/main.go
+++ b/tools/admin-console/main.go
@@ -34,11 +34,11 @@ func main() {
 	ctx := context.Background()
 
 	var config admin.Config
-	env, closer, err := setup.Setup(ctx, &config)
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
 		log.Fatalf("setup.Setup: %v", err)
 	}
-	defer closer()
+	defer env.Close(ctx)
 
 	router := gin.Default()
 	router.LoadHTMLGlob(config.TemplatePath + "/*")
diff --git a/tools/export-config/main.go b/tools/export-config/main.go
index 842d9e8..f590391 100644
--- a/tools/export-config/main.go
+++ b/tools/export-config/main.go
@@ -24,10 +24,8 @@ import (
 
 	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/export/database"
-
 	"github.com/google/exposure-notifications-server/internal/export/model"
-
-	"github.com/kelseyhightower/envconfig"
+	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 var (
@@ -82,16 +80,13 @@ func main() {
 
 	ctx := context.Background()
 	var config coredb.Config
-	err := envconfig.Process("database", &config)
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
-		log.Fatalf("error loading environment variables: %v", err)
+		log.Fatalf("failed to setup: %v", err)
 	}
+	defer env.Close(ctx)
 
-	db, err := coredb.NewFromEnv(ctx, &config)
-	if err != nil {
-		log.Fatalf("unable to connect to database: %v", err)
-	}
-	defer db.Close(ctx)
+	db := database.New(env.Database())
 
 	si := model.SignatureInfo{
 		SigningKey:        *signingKey,
@@ -100,7 +95,7 @@ func main() {
 		SigningKeyVersion: *signingKeyVersion,
 		SigningKeyID:      *signingKeyID,
 	}
-	if err := database.New(db).AddSignatureInfo(ctx, &si); err != nil {
+	if err := db.AddSignatureInfo(ctx, &si); err != nil {
 		log.Fatalf("AddSignatureInfo: %v", err)
 	}
 
@@ -113,7 +108,7 @@ func main() {
 		Thru:             thruTime,
 		SignatureInfoIDs: []int64{si.ID},
 	}
-	if err := database.New(db).AddExportConfig(ctx, &ec); err != nil {
+	if err := db.AddExportConfig(ctx, &ec); err != nil {
 		log.Fatalf("Failure: %v", err)
 	}
 	log.Printf("Successfully created ExportConfig %d.", ec.ConfigID)
diff --git a/tools/federationin-query/main.go b/tools/federationin-query/main.go
index e0978d6..209cc59 100644
--- a/tools/federationin-query/main.go
+++ b/tools/federationin-query/main.go
@@ -27,7 +27,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/federationin/database"
 	"github.com/google/exposure-notifications-server/internal/federationin/model"
 	cflag "github.com/google/exposure-notifications-server/internal/flag"
-	"github.com/kelseyhightower/envconfig"
+	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 var (
@@ -78,17 +78,13 @@ func main() {
 
 	ctx := context.Background()
 	var config coredb.Config
-	err := envconfig.Process("database", &config)
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
-		log.Fatalf("error loading environment variables: %v", err)
+		log.Fatalf("failed to setup: %v", err)
 	}
+	defer env.Close(ctx)
 
-	coredb, err := coredb.NewFromEnv(ctx, &config)
-	if err != nil {
-		log.Fatalf("unable to connect to database: %v", err)
-	}
-	defer coredb.Close(ctx)
-	db := database.New(coredb)
+	db := database.New(env.Database())
 
 	query := &model.FederationInQuery{
 		QueryID:        *queryID,
diff --git a/tools/federationout-authorization/main.go b/tools/federationout-authorization/main.go
index d9605b9..d2ee519 100644
--- a/tools/federationout-authorization/main.go
+++ b/tools/federationout-authorization/main.go
@@ -26,7 +26,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/federationin/model"
 	"github.com/google/exposure-notifications-server/internal/federationout/database"
 	cflag "github.com/google/exposure-notifications-server/internal/flag"
-	"github.com/kelseyhightower/envconfig"
+	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 const (
@@ -71,17 +71,13 @@ func main() {
 
 	ctx := context.Background()
 	var config coredb.Config
-	err := envconfig.Process("database", &config)
+	env, err := setup.Setup(ctx, &config)
 	if err != nil {
-		log.Fatalf("error loading environment variables: %v", err)
+		log.Fatalf("failed to setup: %v", err)
 	}
+	defer env.Close(ctx)
 
-	coredb, err := coredb.NewFromEnv(ctx, &config)
-	if err != nil {
-		log.Fatalf("unable to connect to database: %v", err)
-	}
-	defer coredb.Close(ctx)
-	db := database.New(coredb)
+	db := database.New(env.Database())
 
 	auth := &model.FederationOutAuthorization{
 		Issuer:         defaultIssuer, // Authorization interceptor currently only supports defaultIssuer.
diff --git a/cmd/monolith/main.go b/tools/monolith/main.go
similarity index 83%
rename from cmd/monolith/main.go
rename to tools/monolith/main.go
index 056bacc..191e2b4 100644
--- a/cmd/monolith/main.go
+++ b/tools/monolith/main.go
@@ -16,8 +16,7 @@
 package main
 
 import (
-	"context"
-
+	"github.com/google/exposure-notifications-server/internal/interrupt"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/monolith"
 
@@ -26,10 +25,11 @@ import (
 )
 
 func main() {
-	ctx := context.Background()
-	logger := logging.FromContext(ctx)
+	ctx, done := interrupt.Context()
+	defer done()
 
-	if _, err := monolith.RunServer(ctx); err != nil {
+	if err := monolith.RunServer(ctx); err != nil {
+		logger := logging.FromContext(ctx)
 		logger.Fatal(err)
 	}
 }


Test patch:
diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go
new file mode 100644
index 0000000..9949e0f
--- /dev/null
+++ b/internal/cache/cache_test.go
@@ -0,0 +1,179 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package cache implements an inmemory cache for any interface{} object.
+package cache
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+type order struct {
+	Burgers int
+	Fries   int
+}
+
+func checkSize(t *testing.T, c *Cache, want int) {
+	t.Helper()
+
+	if got := c.Size(); got != want {
+		t.Errorf("wrong size want: %v, got: %v", want, got)
+	}
+}
+
+func TestCache(t *testing.T) {
+	duration := time.Millisecond * 500
+	cache, err := New(duration)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	checkSize(t, cache, 0)
+
+	if err := cache.Set("foo", &order{2, 1}); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	time.Sleep(duration)
+	if got, hit := cache.Lookup("foo"); got != nil || hit {
+		t.Fatalf("key did not expire as expected")
+	}
+
+	if got, hit := cache.Lookup("bar"); got != nil || hit {
+		t.Fatalf("got key that was never inserted")
+	}
+
+	want := &order{42, 37}
+	if err := cache.Set("foo", want); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got, hit := cache.Lookup("foo"); got == nil || !hit {
+		t.Fatalf("lookup failed want: %v, got %v", want, got)
+	} else {
+		if diff := cmp.Diff(want, got); diff != "" {
+			t.Fatalf("mismatch (-want, +got):\n%s", diff)
+		}
+	}
+	time.Sleep(duration * 2)
+	if got, hit := cache.Lookup("foo"); got != nil || hit {
+		t.Fatalf("expected key to expire, but still available")
+	}
+	// potential race, yeild CPU so that the purge go routine has a chance to run.
+	time.Sleep(duration)
+	checkSize(t, cache, 0)
+}
+
+func TestWriteThruCache(t *testing.T) {
+	cache, err := New(time.Second)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	lookupCount := 0
+	want := &order{12, 34}
+	lookerUpper := func() (interface{}, error) {
+		lookupCount++
+		return want, nil
+	}
+
+	for i := 0; i < 2; i++ {
+		got, err := cache.WriteThruLookup("foo", lookerUpper)
+		if err != nil {
+			t.Fatalf("unexpected error on WriteThruLookup: %v", err)
+		}
+		if diff := cmp.Diff(want, got); diff != "" {
+			t.Fatalf("mismatch (-want, +got):\n%s", diff)
+		}
+	}
+
+	if lookupCount != 1 {
+		t.Fatalf("incorrect lookup count, want: 1, got: %v", lookupCount)
+	}
+}
+
+func TestWriteThruError(t *testing.T) {
+	cache, err := New(time.Second)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	lookerUpper := func() (interface{}, error) {
+		return nil, fmt.Errorf("nope")
+	}
+
+	got, err := cache.WriteThruLookup("foo", lookerUpper)
+	if err == nil {
+		t.Fatalf("expected error, got nil")
+	}
+	if err.Error() != "nope" {
+		t.Errorf("incorrect error, want: `nope` got: %v", err.Error())
+	}
+	if got != nil {
+		t.Errorf("unexpecetd cached item, want: nil, got %v", got)
+	}
+}
+
+func TestInvalidDuration(t *testing.T) {
+	if _, err := New(-1 * time.Second); err == nil {
+		t.Fatal("expecterd error, got nil")
+	} else if strings.Contains(err.Error(), "duration cannot be nagative") {
+		t.Fatalf("wrong error: want: `duration cannot be negative` got: %v", err.Error())
+	}
+}
+
+func TestConcurrentReaders(t *testing.T) {
+	cache, err := New(time.Second * 5)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	lookupCount := 0
+	want := &order{12, 34}
+	lookerUpper := func() (interface{}, error) {
+		lookupCount++
+		return want, nil
+	}
+
+	done := make(chan bool, 10)
+	for i := 0; i < 10; i++ {
+		ver := i
+		go func() {
+			got, err := cache.WriteThruLookup("foo", lookerUpper)
+			if err != nil {
+				t.Errorf("routine: %v got unexpected error: %v", ver, err)
+			}
+			if diff := cmp.Diff(want, got); diff != "" {
+				t.Fatalf("routine: %v mismatch (-want, +got):\n%s", ver, diff)
+			}
+			done <- true
+		}()
+	}
+
+	for i := 0; i < 10; i++ {
+		select {
+		case <-done:
+			continue
+		case <-time.After(1 * time.Second):
+			t.Fatal("gorountines didn't termine fast enough")
+		}
+	}
+
+	if lookupCount != 1 {
+		t.Errorf("unexpected lookupCount, want: 1, got: %v", lookupCount)
+	}
+}
diff --git a/internal/envconfig/envconfig_test.go b/internal/envconfig/envconfig_test.go
deleted file mode 100644
index c02f940..0000000
--- a/internal/envconfig/envconfig_test.go
+++ /dev/null
@@ -1,195 +0,0 @@
-// Copyright 2020 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package envconfig
-
-import (
-	"context"
-	"fmt"
-	"io/ioutil"
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-	"time"
-)
-
-const (
-	testEnvVar = "VERY_FAKE_ENV_VAR"
-)
-
-type myEnv struct {
-	Food string `envconfig:"VERY_FAKE_ENV_VAR"`
-}
-
-func clearSecrets() {
-	for _, e := range os.Environ() {
-		parts := strings.SplitN(e, "=", 2)
-		if len(parts) != 2 {
-			continue
-		}
-		name := parts[0]
-		val := parts[1]
-		if strings.HasPrefix(val, SecretPrefix) {
-			os.Unsetenv(name)
-		}
-	}
-}
-
-func TestResolveSecretNoSecretManager(t *testing.T) {
-	clearSecrets()
-
-	ctx := context.Background()
-	env := &myEnv{}
-	os.Setenv(testEnvVar, "secret://yo/secret/value")
-
-	expected := "no secret manager is configured"
-
-	err := Process(ctx, env, nil)
-	if err == nil {
-		t.Errorf("expected error, got nil")
-	} else if !strings.Contains(err.Error(), expected) {
-		t.Errorf("wrong error, want: `%v` got: %v", expected, err)
-	}
-}
-
-type TestSecretManager struct {
-	values map[string]string
-	errors map[string]string
-}
-
-func NewTestSecretManager() *TestSecretManager {
-	return &TestSecretManager{
-		values: make(map[string]string),
-		errors: make(map[string]string),
-	}
-}
-
-func (s *TestSecretManager) GetSecretValue(ctx context.Context, name string) (string, error) {
-	if v, ok := s.errors[name]; ok {
-		return "", fmt.Errorf(v)
-	}
-	if v, ok := s.values[name]; ok {
-		return v, nil
-	}
-	return "", fmt.Errorf("value not found")
-}
-
-func TestResolveSecretEnv(t *testing.T) {
-	clearSecrets()
-
-	cases := []struct {
-		name        string
-		varValue    string
-		secretPath  string
-		secretValue string
-		secretError string
-		want        string
-		wantError   string
-	}{
-		{
-			name:     "only set locally",
-			varValue: "BAR",
-			want:     "BAR",
-		},
-		{
-			name:        "resolve from secret store",
-			varValue:    "secret://value/for/test/1",
-			secretPath:  "value/for/test/1",
-			secretValue: "BAZ",
-			want:        "BAZ",
-		},
-		{
-			name:        "secret manager error",
-			varValue:    "secret://value/for/test/2",
-			secretPath:  "value/for/test/2",
-			secretError: "secretive error",
-			wantError:   "secretive error",
-		},
-	}
-
-	for _, c := range cases {
-		sm := NewTestSecretManager()
-
-		if c.varValue == "" {
-			os.Unsetenv(testEnvVar)
-		} else {
-			os.Setenv(testEnvVar, c.varValue)
-		}
-		if c.secretValue != "" {
-			sm.values[c.secretPath] = c.secretValue
-		}
-		if c.secretError != "" {
-			sm.errors[c.secretPath] = c.secretError
-		}
-
-		ctx := context.Background()
-
-		env := &myEnv{}
-		err := Process(ctx, env, sm)
-
-		if c.wantError != "" && err == nil {
-			t.Errorf("%v process want error: '%v' got: nil", c.name, c.wantError)
-		} else if c.wantError != "" {
-			if !strings.Contains(err.Error(), c.wantError) {
-				t.Errorf("%v process want error containing: '%v', got: %w", c.name, c.wantError, err)
-			}
-		} else if c.want != env.Food {
-			t.Errorf("%v process want '%v' got '%v'", c.name, c.want, env.Food)
-		}
-	}
-}
-
-type myEnvToFile struct {
-	EnvToFile string `envconfig:"VERY_FAKE_ENV_VAR"`
-}
-
-func TestWriteSecretToFile(t *testing.T) {
-	testVal := "YOU GOT IT"
-	ctx := context.Background()
-
-	tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("%d", time.Now().Unix()))
-	if err := os.Mkdir(tempDir, 0700); err != nil {
-		t.Fatal(err)
-	}
-	t.Cleanup(func() {
-		os.RemoveAll(tempDir)
-	})
-
-	os.Setenv("SECRETS_DIR", tempDir)
-	os.Setenv("VERY_FAKE_ENV_VAR", "secret://path/to/secret?target=file")
-
-	sm := NewTestSecretManager()
-	sm.values["path/to/secret"] = testVal
-
-	env := &myEnvToFile{}
-	err := Process(ctx, env, sm)
-
-	if err != nil {
-		t.Fatalf("unable to process environment: %v", err)
-	}
-
-	if _, err := os.Stat(env.EnvToFile); err != nil {
-		t.Errorf("expected %q to exist: %v", env.EnvToFile, err)
-	}
-
-	b, err := ioutil.ReadFile(env.EnvToFile)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if want, got := testVal, string(b); want != got {
-		t.Errorf("expected %q to be %q", got, want)
-	}
-}
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
new file mode 100644
index 0000000..2411cf4
--- /dev/null
+++ b/internal/integration/integration_test.go
@@ -0,0 +1,195 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package integration
+
+import (
+	"context"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/authorizedapp"
+	"github.com/google/exposure-notifications-server/internal/cleanup"
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/export"
+	"github.com/google/exposure-notifications-server/internal/federationin"
+	"github.com/google/exposure-notifications-server/internal/handlers"
+	"github.com/google/exposure-notifications-server/internal/publish"
+	"github.com/google/exposure-notifications-server/internal/secrets"
+	"github.com/google/exposure-notifications-server/internal/server"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/internal/signing"
+	"github.com/google/exposure-notifications-server/internal/storage"
+)
+
+func testServer(tb testing.TB) (*serverenv.ServerEnv, *http.Client) {
+	tb.Helper()
+
+	ctx := context.Background()
+
+	aa, err := authorizedapp.NewMemoryProvider(ctx, nil)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	bs, err := storage.NewMemory(ctx)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	db := database.NewTestDatabase(tb)
+
+	km, err := signing.NewNoop(ctx)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	sm, err := secrets.NewNoop(ctx)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	env := serverenv.New(ctx,
+		serverenv.WithAuthorizedAppProvider(aa),
+		serverenv.WithBlobStorage(bs),
+		serverenv.WithDatabase(db),
+		serverenv.WithKeyManager(km),
+		serverenv.WithSecretManager(sm),
+	)
+	// Note: don't call env.Cleanup() because the database helper closes the
+	// connection for us.
+
+	mux := http.NewServeMux()
+
+	// Cleanup export
+	cleanupExportConfig := &cleanup.Config{
+		Timeout: 10 * time.Minute,
+		TTL:     336 * time.Hour,
+	}
+
+	cleanupExportHandler, err := cleanup.NewExportHandler(cleanupExportConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.Handle("/cleanup-export", cleanupExportHandler)
+
+	// Cleanup exposure
+	cleanupExposureConfig := &cleanup.Config{
+		Timeout: 10 * time.Minute,
+		TTL:     336 * time.Hour,
+	}
+
+	cleanupExposureHandler, err := cleanup.NewExposureHandler(cleanupExposureConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.Handle("/cleanup-exposure", cleanupExposureHandler)
+
+	// Export
+	exportConfig := &export.Config{
+		CreateTimeout:  10 * time.Second,
+		WorkerTimeout:  10 * time.Second,
+		MinRecords:     1,
+		PaddingRange:   1,
+		MaxRecords:     10000,
+		TruncateWindow: 1 * time.Second,
+		MinWindowAge:   1 * time.Second,
+		TTL:            336 * time.Hour,
+	}
+
+	exportServer, err := export.NewServer(exportConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.HandleFunc("/export/create-batches", exportServer.CreateBatchesHandler)
+	mux.HandleFunc("/export/do-work", exportServer.WorkerHandler)
+
+	// Federation
+	federationInConfig := &federationin.Config{
+		Timeout:        10 * time.Minute,
+		TruncateWindow: 1 * time.Hour,
+	}
+
+	mux.Handle("/federation-in", federationin.NewHandler(env, federationInConfig))
+
+	// Federation out
+	// TODO: this is a grpc listener and requires a lot of setup.
+
+	// Publish
+	publishConfig := &publish.Config{
+		MinRequestDuration:      50 * time.Millisecond,
+		MaxKeysOnPublish:        15,
+		MaxIntervalAge:          360 * time.Hour,
+		TruncateWindow:          1 * time.Hour,
+		DebugAPIResponses:       true,
+		DebugReleaseSameDayKeys: true,
+	}
+
+	publishHandler, err := publish.NewHandler(ctx, publishConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.HandleFunc("/publish", handlers.WithMinimumLatency(publishConfig.MinRequestDuration, publishHandler))
+
+	srv, err := server.New("")
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	// Stop the server on cleanup
+	stopCtx, stop := context.WithCancel(ctx)
+	tb.Cleanup(stop)
+
+	// Start the server
+	go func() {
+		if err := srv.ServeHTTPHandler(stopCtx, mux); err != nil {
+			tb.Error(err)
+		}
+	}()
+
+	// Create a client
+	client := testClient(tb, srv)
+
+	return env, client
+}
+
+type prefixRoundTripper struct {
+	addr string
+	rt   http.RoundTripper
+}
+
+func (p *prefixRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
+	u := r.URL
+	if u.Scheme == "" {
+		u.Scheme = "http"
+	}
+	if u.Host == "" {
+		u.Host = p.addr
+	}
+
+	return p.rt.RoundTrip(r)
+}
+
+func testClient(tb testing.TB, srv *server.Server) *http.Client {
+	prt := &prefixRoundTripper{
+		addr: srv.Addr(),
+		rt:   http.DefaultTransport,
+	}
+
+	return &http.Client{
+		Timeout:   5 * time.Second,
+		Transport: prt,
+	}
+}
diff --git a/internal/integration/publish_test.go b/internal/integration/publish_test.go
new file mode 100644
index 0000000..a0d29d6
--- /dev/null
+++ b/internal/integration/publish_test.go
@@ -0,0 +1,141 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package integration
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"net/http"
+	"testing"
+	"time"
+
+	authorizedappmodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	exportdatabase "github.com/google/exposure-notifications-server/internal/export/database"
+	exportmodel "github.com/google/exposure-notifications-server/internal/export/model"
+	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+	"github.com/google/exposure-notifications-server/internal/util"
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
+)
+
+func TestPublish(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	env, client := testServer(t)
+	db := env.Database()
+
+	// Create an authorized app.
+	aa := env.AuthorizedAppProvider()
+	if err := aa.Add(ctx, &authorizedappmodel.AuthorizedApp{
+		AppPackageName: "com.example.app",
+		AllowedRegions: map[string]struct{}{
+			"US": {},
+		},
+		AllowedHealthAuthorityIDs: map[int64]struct{}{
+			12345: {},
+		},
+
+		// TODO: hook up verification, and disable
+		BypassHealthAuthorityVerification: true,
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	// Create a signature info.
+	si := &exportmodel.SignatureInfo{
+		SigningKey:        "signer",
+		AppPackageName:    "",
+		BundleID:          "",
+		SigningKeyVersion: "v1",
+		SigningKeyID:      "US",
+	}
+	if err := exportdatabase.New(db).AddSignatureInfo(ctx, si); err != nil {
+		t.Fatal(err)
+	}
+
+	// Create an export config.
+	ec := &exportmodel.ExportConfig{
+		BucketName:       "my-bucket",
+		Period:           1 * time.Second,
+		OutputRegion:     "US",
+		From:             time.Now().Add(-2 * time.Second),
+		Thru:             time.Now().Add(1 * time.Hour),
+		SignatureInfoIDs: []int64{si.ID},
+	}
+	if err := exportdatabase.New(db).AddExportConfig(ctx, ec); err != nil {
+		t.Fatal(err)
+	}
+
+	payload := &verifyapi.Publish{
+		Keys:           util.GenerateExposureKeys(3, -1, false),
+		Regions:        []string{"US"},
+		AppPackageName: "com.example.app",
+
+		// TODO: hook up verification
+		VerificationPayload: "TODO",
+	}
+
+	var body bytes.Buffer
+	if err := json.NewEncoder(&body).Encode(payload); err != nil {
+		t.Fatal(err)
+	}
+
+	resp, err := client.Post("/publish", "application/json", &body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	// Ensure we get a successful response code.
+	if got, want := resp.StatusCode, http.StatusOK; got != want {
+		t.Errorf("expected %v to be %v", got, want)
+	}
+
+	// Look up the exposures in the database.
+	criteria := publishdb.IterateExposuresCriteria{
+		OnlyLocalProvenance: false,
+	}
+
+	var exposures []*publishmodel.Exposure
+	if _, err := publishdb.New(db).IterateExposures(ctx, criteria, func(m *publishmodel.Exposure) error {
+		exposures = append(exposures, m)
+		return nil
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	if got, want := len(exposures), 3; got != want {
+		t.Errorf("expected %v to be %v: %#v", got, want, exposures)
+	}
+
+	// Create an export.
+	resp, err = client.Get("/export/create-batches")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	resp, err = client.Get("/export/do-work")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	// TODO: verify export has the correct file
+
+	// TODO: verify signature
+}
diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go
index 1601abb..6b53bed 100644
--- a/internal/setup/setup_test.go
+++ b/internal/setup/setup_test.go
@@ -25,99 +25,84 @@ import (
 	"github.com/google/exposure-notifications-server/internal/setup"
 	"github.com/google/exposure-notifications-server/internal/signing"
 	"github.com/google/exposure-notifications-server/internal/storage"
+	"github.com/sethvargo/go-envconfig/pkg/envconfig"
 )
 
-type testDatabaseConfig struct {
-	database *database.Config
-}
+var _ setup.AuthorizedAppConfigProvider = (*testConfig)(nil)
+var _ setup.BlobstoreConfigProvider = (*testConfig)(nil)
+var _ setup.DatabaseConfigProvider = (*testConfig)(nil)
+var _ setup.KeyManagerConfigProvider = (*testConfig)(nil)
+var _ setup.SecretManagerConfigProvider = (*testConfig)(nil)
 
-func (t *testDatabaseConfig) DatabaseConfig() *database.Config {
-	return t.database
+type testConfig struct {
+	Database *database.Config
+	Secret   string `env:"MY_SECRET"`
 }
 
-func (t *testDatabaseConfig) SecretManagerConfig() *secrets.Config {
-	return &secrets.Config{
-		SecretManagerType: secrets.SecretManagerType("NOOP"),
-		SecretCacheTTL:    10 * time.Minute,
+func (t *testConfig) AuthorizedAppConfig() *authorizedapp.Config {
+	return &authorizedapp.Config{
+		// TODO: type
+		CacheDuration: 10 * time.Minute,
 	}
 }
 
-type testSecretManagerConfig struct {
-	database *database.Config
-}
-
-func (t *testSecretManagerConfig) DatabaseConfig() *database.Config {
-	return t.database
-}
-
-func (t *testSecretManagerConfig) SecretManagerConfig() *secrets.Config {
-	return &secrets.Config{
-		SecretManagerType: secrets.SecretManagerType("NOOP"),
-		SecretCacheTTL:    10 * time.Minute,
+func (t *testConfig) BlobstoreConfig() *storage.Config {
+	return &storage.Config{
+		BlobstoreType: storage.BlobstoreType("NOOP"),
 	}
 }
 
-type testKeyManagerConfig struct {
-	database *database.Config
-}
-
-func (t *testKeyManagerConfig) DatabaseConfig() *database.Config {
-	return t.database
+func (t *testConfig) DatabaseConfig() *database.Config {
+	return t.Database
 }
 
-func (t *testKeyManagerConfig) KeyManagerConfig() *signing.Config {
+func (t *testConfig) KeyManagerConfig() *signing.Config {
 	return &signing.Config{
 		KeyManagerType: signing.KeyManagerType("NOOP"),
 	}
 }
 
-func (t *testKeyManagerConfig) SecretManagerConfig() *secrets.Config {
+func (t *testConfig) SecretManagerConfig() *secrets.Config {
 	return &secrets.Config{
 		SecretManagerType: secrets.SecretManagerType("NOOP"),
-		SecretCacheTTL:    time.Nanosecond,
+		SecretCacheTTL:    10 * time.Minute,
 	}
 }
 
-type testBlobstoreConfig struct {
-	database *database.Config
-}
-
-func (t *testBlobstoreConfig) DatabaseConfig() *database.Config {
-	return t.database
-}
+func TestSetupWith(t *testing.T) {
+	t.Parallel()
 
-func (t *testBlobstoreConfig) BlobstoreConfig() *storage.Config {
-	return &storage.Config{
-		BlobstoreType: storage.BlobstoreType("NOOP"),
-	}
-}
+	lookuper := envconfig.MapLookuper(map[string]string{
+		"MY_SECRET": "secret://foo",
+	})
 
-type testAuthorizedAppConfig struct {
-	database *database.Config
-}
+	ctx := context.Background()
+	_, dbconfig := database.NewTestDatabaseWithConfig(t)
 
-func (t *testAuthorizedAppConfig) DatabaseConfig() *database.Config {
-	return t.database
-}
+	t.Run("default", func(t *testing.T) {
+		t.Parallel()
 
-func (t *testAuthorizedAppConfig) AuthorizedAppConfig() *authorizedapp.Config {
-	return &authorizedapp.Config{}
-}
+		config := &testConfig{Database: dbconfig}
+		env, err := setup.SetupWith(ctx, config, lookuper)
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer env.Close(ctx)
 
-func TestSetup(t *testing.T) {
-	t.Parallel()
+		if got, want := config.Secret, "noop-secret"; got != want {
+			t.Errorf("expected %v to be %v", got, want)
+		}
+	})
 
-	t.Run("default", func(t *testing.T) {
+	t.Run("database", func(t *testing.T) {
 		t.Parallel()
 
-		ctx := context.Background()
-		_, dbconfig := database.NewTestDatabaseWithConfig(t)
-		config := &testDatabaseConfig{database: dbconfig}
-		env, done, err := setup.Setup(ctx, config)
+		config := &testConfig{Database: dbconfig}
+		env, err := setup.SetupWith(ctx, config, lookuper)
 		if err != nil {
 			t.Fatal(err)
 		}
-		defer done()
+		defer env.Close(ctx)
 
 		db := env.Database()
 		if db == nil {
@@ -125,91 +110,83 @@ func TestSetup(t *testing.T) {
 		}
 	})
 
-	t.Run("secret_manager", func(t *testing.T) {
+	t.Run("authorizedapp", func(t *testing.T) {
 		t.Parallel()
 
-		ctx := context.Background()
-		_, dbconfig := database.NewTestDatabaseWithConfig(t)
-		config := &testSecretManagerConfig{database: dbconfig}
-		env, done, err := setup.Setup(ctx, config)
+		config := &testConfig{Database: dbconfig}
+		env, err := setup.SetupWith(ctx, config, lookuper)
 		if err != nil {
 			t.Fatal(err)
 		}
-		defer done()
+		defer env.Close(ctx)
 
-		sm := env.SecretManager()
-		if sm == nil {
-			t.Errorf("expected secret manager to exist")
+		ap := env.AuthorizedAppProvider()
+		if ap == nil {
+			t.Errorf("expected appprovider to exist")
 		}
 
-		if _, ok := sm.(*secrets.Cacher); !ok {
-			t.Errorf("expected %T to be Cacher", sm)
+		if _, ok := ap.(*authorizedapp.DatabaseProvider); !ok {
+			t.Errorf("expected %T to be DatabaseProvider", ap)
 		}
 	})
 
-	t.Run("key_manager", func(t *testing.T) {
+	t.Run("blobstore", func(t *testing.T) {
 		t.Parallel()
 
-		ctx := context.Background()
-		_, dbconfig := database.NewTestDatabaseWithConfig(t)
-		config := &testKeyManagerConfig{database: dbconfig}
-		env, done, err := setup.Setup(ctx, config)
+		config := &testConfig{Database: dbconfig}
+		env, err := setup.SetupWith(ctx, config, lookuper)
 		if err != nil {
 			t.Fatal(err)
 		}
-		defer done()
+		defer env.Close(ctx)
 
-		km := env.KeyManager()
-		if km == nil {
-			t.Errorf("expected key manager to exist")
+		bs := env.Blobstore()
+		if bs == nil {
+			t.Errorf("expected blobstore to exist")
 		}
 
-		if _, ok := km.(*signing.Noop); !ok {
-			t.Errorf("expected %T to be Noop", km)
+		if _, ok := bs.(*storage.Noop); !ok {
+			t.Errorf("expected %T to be Noop", bs)
 		}
 	})
 
-	t.Run("blobstore", func(t *testing.T) {
+	t.Run("key_manager", func(t *testing.T) {
 		t.Parallel()
 
-		ctx := context.Background()
-		_, dbconfig := database.NewTestDatabaseWithConfig(t)
-		config := &testBlobstoreConfig{database: dbconfig}
-		env, done, err := setup.Setup(ctx, config)
+		config := &testConfig{Database: dbconfig}
+		env, err := setup.SetupWith(ctx, config, lookuper)
 		if err != nil {
 			t.Fatal(err)
 		}
-		defer done()
+		defer env.Close(ctx)
 
-		bs := env.Blobstore()
-		if bs == nil {
-			t.Errorf("expected blobstore to exist")
+		km := env.KeyManager()
+		if km == nil {
+			t.Errorf("expected key manager to exist")
 		}
 
-		if _, ok := bs.(*storage.Noop); !ok {
-			t.Errorf("expected %T to be Noop", bs)
+		if _, ok := km.(*signing.Noop); !ok {
+			t.Errorf("expected %T to be Noop", km)
 		}
 	})
 
-	t.Run("authorizedapp", func(t *testing.T) {
+	t.Run("secret_manager", func(t *testing.T) {
 		t.Parallel()
 
-		ctx := context.Background()
-		_, dbconfig := database.NewTestDatabaseWithConfig(t)
-		config := &testAuthorizedAppConfig{database: dbconfig}
-		env, done, err := setup.Setup(ctx, config)
+		config := &testConfig{Database: dbconfig}
+		env, err := setup.SetupWith(ctx, config, lookuper)
 		if err != nil {
 			t.Fatal(err)
 		}
-		defer done()
+		defer env.Close(ctx)
 
-		ap := env.AuthorizedAppProvider()
-		if ap == nil {
-			t.Errorf("expected appprovider to exist")
+		sm := env.SecretManager()
+		if sm == nil {
+			t.Errorf("expected secret manager to exist")
 		}
 
-		if _, ok := ap.(*authorizedapp.DatabaseProvider); !ok {
-			t.Errorf("expected %T to be DatabaseProvider", ap)
+		if _, ok := sm.(*secrets.Cacher); !ok {
+			t.Errorf("expected %T to be Cacher", sm)
 		}
 	})
 }