Repository: google/exposure-notifications-server
Pull request #406 (2020-05-21 20:33:53)

Get Terraform out of the CI/CD business
I did a spike on this today, and I think it fixes GH-386. 

Terraform does the initial _everything_, but then ignores all future changes on Cloud Run services. This lets us run `terraform apply` and get a fully-functional setup, but then iterate and deploy on services individually.

The containers built by Terraform are tagged and deployed as `:initial`. Future builds use a timestamp-based tag.

I also addressed some of Steren's feedback in previous issues about being able to deploy individual services.

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

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/406#" title="Author self-approved">sethvargo</a>*

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

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

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

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

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: /assign @mikehelmick @ndmckinley 

Patch:
diff --git a/.gitignore b/.gitignore
index 4c35d77..a52567a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,5 +15,6 @@
 *.tfstate*
 *.terraform/
 terraform.tfvars
+terraform/state.tf
 
 /local/
diff --git a/.ko.yaml b/.ko.yaml
deleted file mode 100644
index 6ccd2d5..0000000
--- a/.ko.yaml
+++ /dev/null
@@ -1,1 +0,0 @@
-defaultBaseImage: gcr.io/distroless/static:nonroot
diff --git a/setup_ko.sh b/Dockerfile
old mode 100755
new mode 100644
similarity index 50%
rename from setup_ko.sh
rename to Dockerfile
index e6d52b8..c24af11
--- a/setup_ko.sh
+++ b/Dockerfile
@@ -1,5 +1,3 @@
-#!/bin/sh
-
 # Copyright 2020 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,6 +12,36 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-export PROJECT_ID=$(gcloud config get-value core/project)
-export KO_DOCKER_REPO="us.gcr.io/${PROJECT_ID}"
-export DOCKER_REPO_OVERRIDE="us.gcr.io/${PROJECT_ID}"
+FROM golang:1.14 AS builder
+
+ARG SERVICE
+
+RUN apt-get -qq update && apt-get -yqq install upx
+
+ENV GO111MODULE=on \
+  CGO_ENABLED=0 \
+  GOOS=linux \
+  GOARCH=amd64
+
+WORKDIR /src
+COPY . .
+
+RUN go build \
+  -trimpath \
+  -ldflags "-s -w -extldflags '-static'" \
+  -installsuffix cgo \
+  -tags netgo \
+  -o /bin/service \
+  ./cmd/${SERVICE}
+
+RUN strip /bin/service
+RUN upx -q -9 /bin/service
+
+
+FROM scratch
+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
+COPY --from=builder /bin/service /bin/service
+
+ENV PORT 8080
+
+ENTRYPOINT ["/bin/service"]
diff --git a/builders/build.yaml b/builders/build.yaml
index c3d9dd2..958f2a7 100644
--- a/builders/build.yaml
+++ b/builders/build.yaml
@@ -13,69 +13,26 @@
 # limitations under the License.
 
 #
-# Builds container imges.
+# Builds a container image.
 #
 
-options:
-  env:
-  - 'KO_DOCKER_REPO=us.gcr.io/${PROJECT_ID}'
-  - 'DOCKER_REPO_OVERRIDE=us.gcr.io/${PROJECT_ID}'
-  machineType: N1_HIGHCPU_8
+substitutions:
+  _SERVICE:
+  _TAG:
 
 steps:
-# Tests
-- id: test
-  name: 'mirror.gcr.io/library/golang'
-  env:
-  - GO111MODULE=on
-  args: ['go', 'test', './...']
-  waitFor: ['-']
-
-# Build and publish containers`
-- id: export
-  name: 'gcr.io/cloud-devrel-public-resources/exposure-notifications/ko:latest'
-  args:
-  - publish
-  - -P
-  - ./cmd/export
-  waitFor: ['test']
-
-- id: federationin
-  name: 'gcr.io/cloud-devrel-public-resources/exposure-notifications/ko:latest'
-  args:
-  - publish
-  - -P
-  - ./cmd/federationin
-  waitFor: ['test']
-
-- id: federationout
-  name: 'gcr.io/cloud-devrel-public-resources/exposure-notifications/ko:latest'
-  args:
-  - publish
-  - -P
-  - ./cmd/federationout
-  waitFor: ['test']
-
-- id: exposure
-  name: 'gcr.io/cloud-devrel-public-resources/exposure-notifications/ko:latest'
-  args:
-  - publish
-  - -P
-  - ./cmd/exposure
-  waitFor: ['test']
-
-- id: cleanup-export
-  name: 'gcr.io/cloud-devrel-public-resources/exposure-notifications/ko:latest'
-  args:
-  - publish
-  - -P
-  - ./cmd/cleanup-export
-  waitFor: ['test']
-
-- id: cleanup-exposure
-  name: 'gcr.io/cloud-devrel-public-resources/exposure-notifications/ko:latest'
-  args:
-  - publish
-  - -P
-  - ./cmd/cleanup-exposure
-  waitFor: ['test']
+- id: 'build'
+  name: 'registry.hub.docker.com/library/docker:18'
+  args: [
+    'build',
+    '--tag', 'gcr.io/${PROJECT_ID}/github.com/google/exposure-notifications-server/cmd/${_SERVICE}:${_TAG}',
+    '--build-arg', 'SERVICE=${_SERVICE}',
+    '.',
+  ]
+
+- id: 'publish'
+  name: 'registry.hub.docker.com/library/docker:18'
+  args: [
+    'push',
+    'gcr.io/${PROJECT_ID}/github.com/google/exposure-notifications-server/cmd/${_SERVICE}:${_TAG}',
+  ]
diff --git a/builders/deploy.yaml b/builders/deploy.yaml
index 0ce0b09..816e309 100644
--- a/builders/deploy.yaml
+++ b/builders/deploy.yaml
@@ -13,100 +13,27 @@
 # limitations under the License.
 
 #
-# Re-deploys existing Cloud Run services. The services must have been deployed
-# with Terraform first, before using this script.
+# Deploys a Cloud Run service.
 #
 
 substitutions:
   _REGION:
+  _SERVICE:
+  _TAG:
 
 steps:
-- id: 'export'
+- id: 'deploy'
   name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
   args:
   - 'bash'
+  - '-eEuo'
+  - 'pipefail'
   - '-c'
   - |-
-    gcloud run deploy export \
+    gcloud run deploy "${_SERVICE}" \
       --quiet \
       --project "${PROJECT_ID}" \
       --platform "managed" \
       --region "${_REGION}" \
-      --image "us.gcr.io/$PROJECT_ID/github.com/google/exposure-notifications-server/cmd/export:latest" \
+      --image "gcr.io/${PROJECT_ID}/github.com/google/exposure-notifications-server/cmd/${_SERVICE}:${_TAG}" \
       --no-traffic
-  waitFor: ['-']
-
-- id: 'federationin'
-  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
-  args:
-  - 'bash'
-  - '-c'
-  - |-
-    gcloud run deploy federationin \
-      --quiet \
-      --project "${PROJECT_ID}" \
-      --platform "managed" \
-      --region "${_REGION}" \
-      --image "us.gcr.io/$PROJECT_ID/github.com/google/exposure-notifications-server/cmd/federationin:latest" \
-      --no-traffic
-  waitFor: ['-']
-
-- id: 'federationout'
-  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
-  args:
-  - 'bash'
-  - '-c'
-  - |-
-    gcloud run deploy federationout \
-      --quiet \
-      --project "${PROJECT_ID}" \
-      --platform "managed" \
-      --region "${_REGION}" \
-      --image "us.gcr.io/$PROJECT_ID/github.com/google/exposure-notifications-server/cmd/federationout:latest" \
-      --no-traffic
-  waitFor: ['-']
-
-- id: 'exposure'
-  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
-  args:
-  - 'bash'
-  - '-c'
-  - |-
-    gcloud run deploy exposure \
-      --quiet \
-      --project "${PROJECT_ID}" \
-      --platform "managed" \
-      --region "${_REGION}" \
-      --image "us.gcr.io/$PROJECT_ID/github.com/google/exposure-notifications-server/cmd/exposure:latest" \
-      --no-traffic
-  waitFor: ['-']
-
-- id: 'cleanup-export'
-  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
-  args:
-  - 'bash'
-  - '-c'
-  - |-
-    gcloud run deploy cleanup-export \
-      --quiet \
-      --project "${PROJECT_ID}" \
-      --platform "managed" \
-      --region "${_REGION}" \
-      --image "us.gcr.io/$PROJECT_ID/github.com/google/exposure-notifications-server/cmd/cleanup-export:latest" \
-      --no-traffic
-  waitFor: ['-']
-
-- id: 'cleanup-exposure'
-  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
-  args:
-  - 'bash'
-  - '-c'
-  - |-
-    gcloud run deploy cleanup-exposure \
-      --quiet \
-      --project "${PROJECT_ID}" \
-      --platform "managed" \
-      --region "${_REGION}" \
-      --image "us.gcr.io/$PROJECT_ID/github.com/google/exposure-notifications-server/cmd/cleanup-exposure:latest" \
-      --no-traffic
-  waitFor: ['-']
diff --git a/builders/migrate.yaml b/builders/migrate.yaml
index e96bdd3..daf21aa 100644
--- a/builders/migrate.yaml
+++ b/builders/migrate.yaml
@@ -30,6 +30,8 @@ steps:
   name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
   args:
   - 'bash'
+  - '-eEuo'
+  - 'pipefail'
   - '-c'
   - |-
     gcloud secrets versions access "${_DB_PASS_SECRET}" > ./dbpass
diff --git a/builders/promote.yaml b/builders/promote.yaml
index 6f0e333..0c72223 100644
--- a/builders/promote.yaml
+++ b/builders/promote.yaml
@@ -17,91 +17,23 @@
 #
 
 substitutions:
+  _PERCENTAGE: '100'
   _REGION:
   _REVISION: 'LATEST'
-  _PERCENTAGE: '100'
+  _SERVICE:
 
 steps:
 - id: 'export'
   name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
   args:
   - 'bash'
+  - '-eEuo'
+  - 'pipefail'
   - '-c'
   - |-
-    gcloud run services update-traffic export \
-      --quiet \
-      --project "${PROJECT_ID}" \
-      --platform "managed" \
-      --region "${_REGION}" \
-      --to-revisions "${_REVISION}=${_PERCENTAGE}"
-  waitFor: ['-']
-
-- id: 'federationin'
-  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
-  args:
-  - 'bash'
-  - '-c'
-  - |-
-    gcloud run services update-traffic federationin \
-      --quiet \
-      --project "${PROJECT_ID}" \
-      --platform "managed" \
-      --region "${_REGION}" \
-      --to-revisions "${_REVISION}=${_PERCENTAGE}"
-  waitFor: ['-']
-
-- id: 'federationout'
-  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
-  args:
-  - 'bash'
-  - '-c'
-  - |-
-    gcloud run services update-traffic federationout \
-      --quiet \
-      --project "${PROJECT_ID}" \
-      --platform "managed" \
-      --region "${_REGION}" \
-      --to-revisions "${_REVISION}=${_PERCENTAGE}"
-  waitFor: ['-']
-
-- id: 'exposure'
-  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
-  args:
-  - 'bash'
-  - '-c'
-  - |-
-    gcloud run services update-traffic exposure \
-      --quiet \
-      --project "${PROJECT_ID}" \
-      --platform "managed" \
-      --region "${_REGION}" \
-      --to-revisions "${_REVISION}=${_PERCENTAGE}"
-  waitFor: ['-']
-
-- id: 'cleanup-export'
-  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
-  args:
-  - 'bash'
-  - '-c'
-  - |-
-    gcloud run services update-traffic cleanup-export \
-      --quiet \
-      --project "${PROJECT_ID}" \
-      --platform "managed" \
-      --region "${_REGION}" \
-      --to-revisions "${_REVISION}=${_PERCENTAGE}"
-  waitFor: ['-']
-
-- id: 'cleanup-exposure'
-  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine'
-  args:
-  - 'bash'
-  - '-c'
-  - |-
-    gcloud run services update-traffic cleanup-exposure \
+    gcloud run services update-traffic ${_SERVICE} \
       --quiet \
       --project "${PROJECT_ID}" \
       --platform "managed" \
       --region "${_REGION}" \
       --to-revisions "${_REVISION}=${_PERCENTAGE}"
-  waitFor: ['-']
diff --git a/cmd/monolith/main.go b/cmd/monolith/main.go
index a1e0581..9461689 100644
--- a/cmd/monolith/main.go
+++ b/cmd/monolith/main.go
@@ -29,6 +29,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/publish"
 	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
 var _ setup.DBConfigProvider = (*MonoConfig)(nil)
@@ -45,11 +46,16 @@ type MonoConfig struct {
 	Publish       *publish.Config
 	Database      *database.Config
 	FederationIn  *federationin.Config
+	BlobstoreType string `envconfig:"BLOBSTORE_TYPE" default:"CLOUD_STORAGE"`
 }
 
-func (c *MonoConfig) DB() *database.Config                       { return c.Database }
-func (c *MonoConfig) KeyManager() bool                           { return true }
-func (c *MonoConfig) BlobStorage() bool                          { return true }
+func (c *MonoConfig) DB() *database.Config { return c.Database }
+func (c *MonoConfig) KeyManager() bool     { return true }
+func (c *MonoConfig) BlobStorage() storage.BlobstoreConfig {
+	return storage.BlobstoreConfig{
+		BlobstoreType: storage.BlobstoreType(c.BlobstoreType),
+	}
+}
 func (c *MonoConfig) AuthorizedAppConfig() *authorizedapp.Config { return c.AuthorizedApp }
 
 func main() {
diff --git a/docs/deploying.md b/docs/deploying.md
index cc5417e..2883d25 100644
--- a/docs/deploying.md
+++ b/docs/deploying.md
@@ -69,110 +69,155 @@ Each service's `main` package is located in the `cmd` directory.
 | exposure cleanup | cmd/cleanup-exposure | Deletes old exposure keys |
 | export cleanup | cmd/cleanup-export | Deletes old exported files published by the exposure key export service |
 
-### Deploying using Terraform
+### Provisioning infrastructure with Terraform
 
-You can use Terraform to deploy the reference Exposure Notification on Google
-Cloud. These instructions make use of the
-[Google Cloud Terraform provider](https://github.com/terraform-providers/terraform-provider-google).
+You can use [Terraform](https://www.terraform.io) to provision the initial
+infrastructure, database, service accounts, and first deployment of the services
+on Cloud Run. **Terraform does not manage the Cloud Run services after their
+initial creation!**
 
-1. [Download and install Terraform version 0.12](https://www.terraform.io/downloads.html)
-   or newer.
+See [Deploying with Terraform](../terraform/README.md) for more information.
 
-1. [Create a Google Cloud project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#creating_a_project)
+### Deploying services
 
-   Make a note of the project ID, you will be needed later. We recommend
-   setting it as an environment variable:
+While Terraform does an initial deployment of the services, it does not manage
+the Cloud Run services beyond their initial creation. If you make changes to the
+code, you will need to build, deploy, and promote new services. The general
+order of operations is:
 
-   ```console
-   export PROJECT_ID="PROJECT-ID"
-   ```
+1.  **Build** - this is the phase where the code is bundled into a container
+    image and pushed to a registry.
 
-1. **(OPTIONAL)** You can use [Cloud Build triggers](https://cloud.google.com/cloud-build/docs/automating-builds/create-github-app-triggers)
-   to automatically re-deploy when the Exposure Notification Server GitHub
-   repository is updated. To set up Cloud Build triggers:
+1.  **Deploy** - this is the phase where the container image is deployed onto
+    Cloud Run, but is not receiving any traffic.
 
-   1. Go to the
-   [Connect Repository Cloud Console page](https://console.cloud.google.com/cloud-build/triggers/connect)
-   and follow the instructions using GitHub as the source code location
-   (Cloud Build GitHub App). You must choose a repository on which you have
-   administrator permissions.
+1.  **Promote** - this is the phase where a deployed container image begins
+    receiving all or a percentage of traffic.
 
-   1. Make a note of the repository you have chosen. You must set the
-   repository name (for example, 'exposure-notification-server') and owner
-   (for example 'google') as variables when running the `terraform apply`
-   command.
+#### Building
 
-1. Authenticate with Google Cloud using the `gcloud` command-line tool:
+Build new services by using the script at `./scripts/build`, specifying the
+following values:
 
-   ```console
-   gcloud auth login && gcloud auth application-default login
-   ```
+-   `PROJECT_ID` (required) - your Google Cloud project ID.
 
-   This will open two authentication windows in your web browser.
+-   `SERVICES` (required) - comma-separated list of names of the services to
+    build, or "all" to build all. See the list of services in the table above.
 
-   You may need to `unset GOOGLE_APPLICATION_CREDENTIALS` as it takes precedence
-   over gcloud's login settings.
+-   `TAG` (optional) - tag to use for the images. If not specified, it uses a
+    datetime-based tag of the format YYYYMMDDhhmmss.
 
-1. Change to this directory and run `terraform init`.  Terraform will
-   automatically download the plugins required to execute this code.
+```text
+PROJECT_ID="my-project" \
+SERVICES="export" \
+./scripts/build
+```
 
-1. Run `terraform apply` to start deployment:
+Expect this process to take 3-5 minutes.
 
-   Without Cloud Build Triggers:
+#### Deploying
 
-   ```console
-   terraform apply \
-     -var project=$PROJECT_ID
-   ```
+Deploy already-built container using the script at `./scripts/deploy`,
+specifying the following values:
 
-   With Cloud Build Triggers:
+-   `PROJECT_ID` (required) - your Google Cloud project ID.
 
-   ```console
-   terraform apply \
-     -var project=${PROJECT_ID} \
-     -var region="us-central-1" \
-     -var use_build_triggers=true \
-     -var repo_owner=${YOUR_REPO_OWNER} \
-     -var repo_name=${YOUR_REPO_NAME}
-   ```
+-   `REGION` (required) - region in which to deploy the services.
 
-Terraform will begin by creating the service accounts and enabling the services
-on Google Cloud that are required to run this server.
+-   `SERVICES` (required) - comma-separated list of names of the services to
+    deploy, or "all" to deploy all. Note, if you specify multiple services, they
+    must use the same tag.
 
-1. Initialize or migrate the database.
+-   `TAG` (required) - tag of the deployed image (e.g. YYYYMMDDhhmmss).
 
-   To migrate the database, you will want to start the
-   [Cloud SQL Proxy](https://cloud.google.com/sql/docs/postgres/quickstart-proxy-test#install-proxy)
-   and then run the [`migrate`](https://github.com/golang-migrate/migrate)
-   command.
+```text
+PROJECT_ID="my-project" \
+REGION="us-central1" \
+SERVICES="export" \
+TAG="20200521084829" \
+./scripts/deploy
+```
 
-   ```console
-   DB_HOST="localhost"
-   DB_PORT="1433"
-   DB_USER="notification"
-   DB_PASSWORD="YOUR-DB-PASSWORD"
-   DB_SSLMODE="disable"
-   DB_NAME="main"
-   DB_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}"
+Expect this process to take 1-2 minutes.
 
-   migrate -database ${DB_URL} -path ./migrations up
-   ```
+#### Promoting
 
-### Local development and testing example deployment
+Promote an already-deployed service to begin receiving production traffic using
+the script at `./scripts/promote`, specifying the following values:
 
-The default Terraform deployment is a production-ready, high traffic
-deployment. For local development and testing, we recommend you use the
-following sample deployment:
+-   `PROJECT_ID` (required) - your Google Cloud project ID.
 
-1. Run `terraform apply` with the following command:
+-   `REGION` (required) - region in which to promote the services.
 
-   ```console
-   terraform apply \
-     -var project=${PROJECT_ID} \
-     -var region="us-central-1" \
-     -var use_build_triggers=true \
-     -var repo_owner=${YOUR_REPO_OWNER} \
-     -var repo_name=${YOUR_REPO_NAME} \
-     -var cloudsql_tier="db-custom-1-3840" \
-     -var cloudsql_disk_size_gb="16"
-   ```
+-   `SERVICES` (required) - comma-separated list of names of the services to
+    promote, or "all" to deploy all. Note, if you specify multiple services,
+    then the revision must be "LATEST".
+
+-   `REVISION` (optional) - revision of the service to promote, usually the
+    output of a deployment step. Defaults to "LATEST".
+
+-   `PERCENTAGE` (optional) - percent of traffic to shift to the new revision.
+    Defaults to "100".
+
+```text
+PROJECT_ID="my-project" \
+REGION="us-central1" \
+SERVICES="export" \
+./scripts/promote
+```
+
+Expect this process to take 1-2 minutes.
+
+### Running migrations
+
+#### On Google Cloud
+
+To migrate the production database, use the script in `./scripts/migrate`. This
+script triggers a Cloud Build invocation which uses the Cloud SQL Proxy to run
+the database migrations and uses the following environment variables:
+
+-   `PROJECT_ID` (required) - your Google Cloud project ID.
+
+-   `DB_CONN` (required) - your Cloud SQL connection name.
+
+-   `DB_PASS_SECRET` (required) - the **reference** to the secret where the
+    database password is stored in Secret Manager.
+
+-   `DB_NAME` (default: "main") - the name of the database against which to run
+    migrations.
+
+-   `DB_USER` (default: "notification") - the username with which to
+    authenticate.
+
+-   `COMMAND` (default: "up") - the migration command to run.
+
+If you created the infrastructure using Terraform, you can get these values by
+running `terraform output` from inside the `terraform/` directory:
+
+```text
+PROJECT_ID=$(terraform output project)
+DB_CONN=$(terraform output db_conn)
+DB_PASS_SECRET=$(terraform output db_pass_secret)
+```
+
+#### On a custom setup
+
+If you did not use the Terraform configurations to provision your server, or if you are running your own Postgres server,
+
+1.  Download and install the
+    [`migrate`](https://github.com/golang-migrate/migrate) tool.
+
+1.  Construct the [database URL](https://github.com/golang-migrate/migrate/tree/master/database/postgres) for your database. This is usually of the format:
+
+    ```text
+    postgres://DB_USER:DB_PASSWORD@DB_HOST:DB_PORT/DB_NAME?sslmode=require
+    ```
+
+1.  Run the migrate command with this database URL:
+
+    ```text
+    migrate \
+      -database "YOUR_DB_URL" \
+      -path ./migrations \
+      up
+    ```
diff --git a/internal/cleanup/cleanup.go b/internal/cleanup/cleanup.go
index 154d1a5..d242497 100644
--- a/internal/cleanup/cleanup.go
+++ b/internal/cleanup/cleanup.go
@@ -21,8 +21,8 @@ import (
 	"net/http"
 	"time"
 
-	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/export/database"
+	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
 
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
@@ -43,14 +43,14 @@ func NewExposureHandler(config *Config, env *serverenv.ServerEnv) (http.Handler,
 	return &exposureCleanupHandler{
 		config:   config,
 		env:      env,
-		database: env.Database(),
+		database: publishdb.New(env.Database()),
 	}, nil
 }
 
 type exposureCleanupHandler struct {
 	config   *Config
 	env      *serverenv.ServerEnv
-	database *coredb.DB
+	database *publishdb.PublishDB
 }
 
 func (h *exposureCleanupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/cleanup/config.go b/internal/cleanup/config.go
index f600154..9de7908 100644
--- a/internal/cleanup/config.go
+++ b/internal/cleanup/config.go
@@ -19,6 +19,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
 // Compile-time check to assert this config matches requirements.
@@ -28,10 +29,11 @@ var _ setup.DBConfigProvider = (*Config)(nil)
 // Config represents the configuration and associated environment variables for
 // the cleanup components.
 type Config struct {
-	Port     string        `envconfig:"PORT" default:"8080"`
-	Timeout  time.Duration `envconfig:"CLEANUP_TIMEOUT" default:"10m"`
-	TTL      time.Duration `envconfig:"CLEANUP_TTL" default:"336h"`
-	Database *database.Config
+	Port          string        `envconfig:"PORT" default:"8080"`
+	Timeout       time.Duration `envconfig:"CLEANUP_TIMEOUT" default:"10m"`
+	TTL           time.Duration `envconfig:"CLEANUP_TTL" default:"336h"`
+	Database      *database.Config
+	BlobstoreType string `envconfig:"BLOBSTORE_TYPE" default:"CLOUD_STORAGE"`
 }
 
 // DB return the databsae configuration.
@@ -40,6 +42,8 @@ func (c *Config) DB() *database.Config {
 }
 
 // BlobStorage returns the BlobStorage configuration.
-func (c *Config) BlobStorage() bool {
-	return true
+func (c *Config) BlobStorage() storage.BlobstoreConfig {
+	return storage.BlobstoreConfig{
+		BlobstoreType: storage.BlobstoreType(c.BlobstoreType),
+	}
 }
diff --git a/internal/export/batcher.go b/internal/export/batcher.go
index ff9f749..00d77e6 100644
--- a/internal/export/batcher.go
+++ b/internal/export/batcher.go
@@ -25,6 +25,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"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/exposure-notifications-server/internal/logging"
 )
 
@@ -140,7 +142,7 @@ var sanityDate = time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)
 func makeBatchRanges(period time.Duration, latestEnd, now time.Time, truncateWindow time.Duration) []batchRange {
 
 	// Compute the end of the exposure publish window; we don't want any batches with an end date greater than this time.
-	publishEnd := coredb.TruncateWindow(now, truncateWindow)
+	publishEnd := publishmodel.TruncateWindow(now, truncateWindow)
 
 	// Special case: if there have not been batches before, return only a single one.
 	// We use sanityDate here because the loop below will happily create batch ranges
diff --git a/internal/export/config.go b/internal/export/config.go
index bc5a8be..aa5a98e 100644
--- a/internal/export/config.go
+++ b/internal/export/config.go
@@ -19,6 +19,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
 // Compile-time check to assert this config matches requirements.
@@ -38,6 +39,7 @@ type Config struct {
 	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"`
+	BlobstoreType  string        `envconfig:"BLOBSTORE_TYPE" default:"CLOUD_STORAGE"`
 }
 
 // DB returns the database config.
@@ -51,6 +53,8 @@ func (c *Config) KeyManager() bool {
 }
 
 // BlobStorage returns the BlobStorage configuration.
-func (c *Config) BlobStorage() bool {
-	return true
+func (c *Config) BlobStorage() storage.BlobstoreConfig {
+	return storage.BlobstoreConfig{
+		BlobstoreType: storage.BlobstoreType(c.BlobstoreType),
+	}
 }
diff --git a/internal/export/exportfile.go b/internal/export/exportfile.go
index 4c7ad61..c3c7fc3 100644
--- a/internal/export/exportfile.go
+++ b/internal/export/exportfile.go
@@ -23,8 +23,8 @@ import (
 	"fmt"
 	"sort"
 
-	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/export/model"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
 
 	"github.com/google/exposure-notifications-server/internal/pb/export"
 
@@ -46,7 +46,7 @@ type ExportSigners struct {
 }
 
 // MarshalExportFile converts the inputs into an encoded byte array.
-func MarshalExportFile(eb *model.ExportBatch, exposures []*database.Exposure, batchNum, batchSize int, signers []ExportSigners) ([]byte, error) {
+func MarshalExportFile(eb *model.ExportBatch, exposures []*publishmodel.Exposure, batchNum, batchSize int, signers []ExportSigners) ([]byte, error) {
 	// create main exposure key export binary
 	expContents, err := marshalContents(eb, exposures, int32(batchNum), int32(batchSize), signers)
 	if err != nil {
@@ -84,7 +84,7 @@ func MarshalExportFile(eb *model.ExportBatch, exposures []*database.Exposure, ba
 	return buf.Bytes(), nil
 }
 
-func marshalContents(eb *model.ExportBatch, exposures []*database.Exposure, batchNum int32, batchSize int32, signers []ExportSigners) ([]byte, error) {
+func marshalContents(eb *model.ExportBatch, exposures []*publishmodel.Exposure, batchNum int32, batchSize int32, signers []ExportSigners) ([]byte, error) {
 	exportBytes := []byte("EK Export v1    ")
 	if len(exportBytes) != fixedHeaderWidth {
 		return nil, fmt.Errorf("incorrect header length: %d", len(exportBytes))
diff --git a/internal/export/server.go b/internal/export/server.go
index c914d12..e759787 100644
--- a/internal/export/server.go
+++ b/internal/export/server.go
@@ -17,7 +17,10 @@ package export
 import (
 	"fmt"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	coredb "github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/export/database"
+	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
+
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 )
 
@@ -35,15 +38,19 @@ func NewServer(config *Config, env *serverenv.ServerEnv) (*Server, error) {
 	}
 
 	return &Server{
-		db:     env.Database(),
-		config: config,
-		env:    env,
+		db:        env.Database(),
+		exportdb:  database.New(env.Database()),
+		publishdb: publishdb.New(env.Database()),
+		config:    config,
+		env:       env,
 	}, nil
 }
 
 // Server hosts end points to manage export batches.
 type Server struct {
-	db     *database.DB
-	config *Config
-	env    *serverenv.ServerEnv
+	db        *coredb.DB
+	exportdb  *database.ExportDB
+	publishdb *publishdb.PublishDB
+	config    *Config
+	env       *serverenv.ServerEnv
 }
diff --git a/internal/export/worker.go b/internal/export/worker.go
index bfda681..e10751a 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -26,7 +26,11 @@ import (
 
 	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/export/database"
+	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
+
 	"github.com/google/exposure-notifications-server/internal/export/model"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/util"
 )
@@ -86,9 +90,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	logger := logging.FromContext(ctx)
 	logger.Infof("Processing export batch %d (root: %q, region: %s), max records per file %d", eb.BatchID, eb.FilenameRoot, eb.Region, s.config.MaxRecords)
 
-	exportDB := database.New(s.db)
-
-	criteria := coredb.IterateExposuresCriteria{
+	criteria := publishdb.IterateExposuresCriteria{
 		SinceTimestamp:      eb.StartTimestamp,
 		UntilTimestamp:      eb.EndTimestamp,
 		IncludeRegions:      []string{eb.Region},
@@ -98,10 +100,10 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	// Build up groups of exposures in memory. We need to use memory so we can determine the
 	// total number of groups (which is embedded in each export file). This technique avoids
 	// SELECT COUNT which would lock the database slowing new uploads.
-	var groups [][]*coredb.Exposure
-	var exposures []*coredb.Exposure
+	var groups [][]*publishmodel.Exposure
+	var exposures []*publishmodel.Exposure
 
-	_, err := s.db.IterateExposures(ctx, criteria, func(exp *coredb.Exposure) error {
+	_, err := s.publishdb.IterateExposures(ctx, criteria, func(exp *publishmodel.Exposure) error {
 		exposures = append(exposures, exp)
 		if len(exposures) == s.config.MaxRecords {
 			groups = append(groups, exposures)
@@ -128,7 +130,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	}
 
 	// Load the non-expired signature infos associated with this export batch.
-	sigInfos, err := exportDB.LookupSignatureInfos(ctx, eb.SignatureInfoIDs, time.Now())
+	sigInfos, err := s.exportdb.LookupSignatureInfos(ctx, eb.SignatureInfoIDs, time.Now())
 	if err != nil {
 		return fmt.Errorf("error loading signature info for batch %d, %w", eb.BatchID, err)
 	}
@@ -166,7 +168,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	}
 
 	// Write the files records in database and complete the batch.
-	if err := exportDB.FinalizeBatch(ctx, eb, objectNames, batchSize); err != nil {
+	if err := s.exportdb.FinalizeBatch(ctx, eb, objectNames, batchSize); err != nil {
 		return fmt.Errorf("completing batch: %w", err)
 	}
 	logger.Infof("Batch %d completed", eb.BatchID)
@@ -174,7 +176,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 }
 
 type createFileInfo struct {
-	exposures      []*coredb.Exposure
+	exposures      []*publishmodel.Exposure
 	exportBatch    *model.ExportBatch
 	signatureInfos []*model.SignatureInfo
 	batchNum       int
@@ -298,7 +300,7 @@ func randomInt(min, max int) (int, error) {
 	return int(n.Int64()) + min, nil
 }
 
-func ensureMinNumExposures(exposures []*coredb.Exposure, region string, minLength, jitter int) ([]*coredb.Exposure, error) {
+func ensureMinNumExposures(exposures []*publishmodel.Exposure, region string, minLength, jitter int) ([]*publishmodel.Exposure, error) {
 	if len(exposures) == 0 {
 		return exposures, nil
 	}
@@ -310,7 +312,7 @@ func ensureMinNumExposures(exposures []*coredb.Exposure, region string, minLengt
 		// Pieces needed are
 		// (1) exposure key, (2) interval number, (3) transmission risk
 		// Exposure key is 16 random bytes.
-		eKey := make([]byte, coredb.KeyLength)
+		eKey := make([]byte, publishmodel.KeyLength)
 		_, err := rand.Read(eKey)
 		if err != nil {
 			return nil, fmt.Errorf("rand.Read: %w", err)
@@ -336,13 +338,13 @@ func ensureMinNumExposures(exposures []*coredb.Exposure, region string, minLengt
 		}
 		intervalCount := exposures[fromIdx].IntervalCount
 
-		ek := &coredb.Exposure{
+		ek := &publishmodel.Exposure{
 			ExposureKey:      eKey,
 			TransmissionRisk: transmissionRisk,
 			Regions:          []string{region},
 			IntervalNumber:   intervalNumber,
 			IntervalCount:    intervalCount,
-			// The rest of the coredb.Exposure fields are not used in the export file.
+			// The rest of the publishmodel.Exposure fields are not used in the export file.
 		}
 		exposures = append(exposures, ek)
 	}
diff --git a/internal/federationin/federationin.go b/internal/federationin/federationin.go
index 0faee37..1eb3163 100644
--- a/internal/federationin/federationin.go
+++ b/internal/federationin/federationin.go
@@ -27,10 +27,12 @@ import (
 	"strings"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/metrics"
 	"github.com/google/exposure-notifications-server/internal/pb"
+	"github.com/google/exposure-notifications-server/internal/publish/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 
 	"google.golang.org/api/idtoken"
@@ -49,8 +51,8 @@ var (
 
 type (
 	fetchFn               func(context.Context, *pb.FederationFetchRequest, ...grpc.CallOption) (*pb.FederationFetchResponse, error)
-	insertExposuresFn     func(context.Context, []*database.Exposure) error
-	startFederationSyncFn func(context.Context, *database.FederationInQuery, time.Time) (int64, database.FinalizeSyncFn, error)
+	insertExposuresFn     func(context.Context, []*model.Exposure) error
+	startFederationSyncFn func(context.Context, *coredb.FederationInQuery, time.Time) (int64, coredb.FinalizeSyncFn, error)
 )
 
 type pullDependencies struct {
@@ -63,16 +65,18 @@ type pullDependencies struct {
 // federation results for a single federation query.
 func NewHandler(env *serverenv.ServerEnv, config *Config) http.Handler {
 	return &handler{
-		env:    env,
-		db:     env.Database(),
-		config: config,
+		env:       env,
+		db:        env.Database(),
+		publishdb: database.New(env.Database()),
+		config:    config,
 	}
 }
 
 type handler struct {
-	env    *serverenv.ServerEnv
-	db     *database.DB
-	config *Config
+	env       *serverenv.ServerEnv
+	db        *coredb.DB
+	publishdb *database.PublishDB
+	config    *Config
 }
 
 func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -102,7 +106,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	lock := "query_" + queryID
 	unlockFn, err := h.db.Lock(ctx, lock, h.config.Timeout)
 	if err != nil {
-		if errors.Is(err, database.ErrAlreadyLocked) {
+		if errors.Is(err, coredb.ErrAlreadyLocked) {
 			metrics.WriteInt("federation-pull-lock-contention", true, 1)
 			msg := fmt.Sprintf("Lock %s already in use. No work will be performed.", lock)
 			logger.Infof(msg)
@@ -116,7 +120,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	query, err := h.db.GetFederationInQuery(ctx, queryID)
 	if err != nil {
-		if errors.Is(err, database.ErrNotFound) {
+		if errors.Is(err, coredb.ErrNotFound) {
 			badRequestf(ctx, w, "unknown %s", queryParam)
 			return
 		}
@@ -170,7 +174,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	deps := pullDependencies{
 		fetch:               client.Fetch,
-		insertExposures:     h.db.InsertExposures,
+		insertExposures:     h.publishdb.InsertExposures,
 		startFederationSync: h.db.StartFederationInSync,
 	}
 	batchStart := time.Now()
@@ -184,7 +188,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies, q *database.FederationInQuery, batchStart time.Time, truncateWindow time.Duration) error {
+func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies, q *coredb.FederationInQuery, batchStart time.Time, truncateWindow time.Duration) error {
 	logger := logging.FromContext(ctx)
 	logger.Infof("Processing query %q", q.QueryID)
 
@@ -205,7 +209,7 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 		logger.Infof("Inserted %d keys", total)
 	}()
 
-	createdAt := database.TruncateWindow(batchStart, truncateWindow)
+	createdAt := model.TruncateWindow(batchStart, truncateWindow)
 	partial := true
 	for partial {
 
@@ -222,7 +226,7 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 		}
 
 		// Loop through the result set, storing in database.
-		var exposures []*database.Exposure
+		var exposures []*model.Exposure
 		for _, ctr := range response.Response {
 
 			var upperRegions []string
@@ -234,12 +238,12 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 			for _, cti := range ctr.ContactTracingInfo {
 				for _, key := range cti.ExposureKeys {
 
-					if cti.TransmissionRisk < database.MinTransmissionRisk || cti.TransmissionRisk > database.MaxTransmissionRisk {
+					if cti.TransmissionRisk < model.MinTransmissionRisk || cti.TransmissionRisk > model.MaxTransmissionRisk {
 						logger.Errorf("invalid transmission risk %v - dropping record.", cti.TransmissionRisk)
 						continue
 					}
 
-					exposures = append(exposures, &database.Exposure{
+					exposures = append(exposures, &model.Exposure{
 						TransmissionRisk: int(cti.TransmissionRisk),
 						ExposureKey:      key.ExposureKey,
 						Regions:          upperRegions,
diff --git a/internal/federationout/federationout.go b/internal/federationout/federationout.go
index 2170e74..b4c8817 100644
--- a/internal/federationout/federationout.go
+++ b/internal/federationout/federationout.go
@@ -23,9 +23,12 @@ import (
 	"strings"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/pb"
+
+	coredb "github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	"google.golang.org/api/idtoken"
 	"google.golang.org/grpc"
@@ -42,21 +45,23 @@ const (
 // Compile time assert that this server implements the required grpc interface.
 var _ pb.FederationServer = (*Server)(nil)
 
-type iterateExposuresFunc func(context.Context, database.IterateExposuresCriteria, func(*database.Exposure) error) (string, error)
+type iterateExposuresFunc func(context.Context, database.IterateExposuresCriteria, func(*model.Exposure) error) (string, error)
 
 // NewServer builds a new FederationServer.
 func NewServer(env *serverenv.ServerEnv, config *Config) pb.FederationServer {
 	return &Server{
-		env:    env,
-		db:     env.Database(),
-		config: config,
+		env:       env,
+		db:        env.Database(),
+		publishdb: database.New(env.Database()),
+		config:    config,
 	}
 }
 
 type Server struct {
-	env    *serverenv.ServerEnv
-	db     *database.DB
-	config *Config
+	env       *serverenv.ServerEnv
+	db        *coredb.DB
+	publishdb *database.PublishDB
+	config    *Config
 }
 
 type authKey struct{}
@@ -66,7 +71,7 @@ func (s Server) Fetch(ctx context.Context, req *pb.FederationFetchRequest) (*pb.
 	ctx, cancel := context.WithTimeout(ctx, s.config.Timeout)
 	defer cancel()
 	logger := logging.FromContext(ctx)
-	response, err := s.fetch(ctx, req, s.db.IterateExposures, database.TruncateWindow(time.Now(), s.config.TruncateWindow)) // Don't fetch the current window, which isn't complete yet. TODO(squee1945): should I double this for safety?
+	response, err := s.fetch(ctx, req, s.publishdb.IterateExposures, model.TruncateWindow(time.Now(), s.config.TruncateWindow)) // Don't fetch the current window, which isn't complete yet. TODO(squee1945): should I double this for safety?
 	if err != nil {
 		s.env.MetricsExporter(ctx).WriteInt("federation-fetch-failed", true, 1)
 		logger.Errorf("Fetch error: %v", err)
@@ -91,7 +96,7 @@ func (s Server) fetch(ctx context.Context, req *pb.FederationFetchRequest, itFun
 	logger.Infof("Processing client request %#v", req)
 
 	// If there is a FederationAuthorization on the context, set the query to operate within its limits.
-	if auth, ok := ctx.Value(authKey{}).(*database.FederationOutAuthorization); ok {
+	if auth, ok := ctx.Value(authKey{}).(*coredb.FederationOutAuthorization); ok {
 		// For included regions, we INTERSECT the requested included regions with the configured included regions.
 		req.RegionIdentifiers = intersect(req.RegionIdentifiers, auth.IncludeRegions)
 		// For excluded regions, we UNION the the requested excluded regions with the configured excluded regions.
@@ -125,7 +130,7 @@ func (s Server) fetch(ctx context.Context, req *pb.FederationFetchRequest, itFun
 	ctiMap := map[string]*pb.ContactTracingInfo{}     // local index into the response being assembled; keys on unique set of (ctrMap key, transmissionRisk, verificationAuthorityName)
 	response := &pb.FederationFetchResponse{}
 	count := 0
-	cursor, err := itFunc(ctx, criteria, func(inf *database.Exposure) error {
+	cursor, err := itFunc(ctx, criteria, func(inf *model.Exposure) error {
 		// If the diagnosis key is empty, it's malformed, so skip it.
 		if len(inf.ExposureKey) == 0 {
 			logger.Debugf("Exposure %s missing ExposureKey, skipping.", inf.ExposureKey)
@@ -243,7 +248,7 @@ func (s Server) AuthInterceptor(ctx context.Context, req interface{}, info *grpc
 
 	auth, err := s.db.GetFederationOutAuthorization(ctx, token.Issuer, token.Subject)
 	if err != nil {
-		if errors.Is(err, database.ErrNotFound) {
+		if errors.Is(err, coredb.ErrNotFound) {
 			metrics.WriteInt("federation-fetch-unauthorized", true, 1)
 			logger.Infof("Authorization not found (issuer %q, subject %s)", token.Issuer, token.Subject)
 			return nil, status.Errorf(codes.Unauthenticated, "Invalid issuer/subject")
diff --git a/internal/jsonutil/unmarshal.go b/internal/jsonutil/unmarshal.go
index bcc122e..999ba6b 100644
--- a/internal/jsonutil/unmarshal.go
+++ b/internal/jsonutil/unmarshal.go
@@ -33,7 +33,7 @@ const (
 
 // Unmarshal provides a common implementation of JSON unmarshalling with well defined error handling.
 func Unmarshal(w http.ResponseWriter, r *http.Request, data interface{}) (int, error) {
-	if t := r.Header.Get("Content-type"); t != "application/json" {
+	if t := r.Header.Get("content-type"); len(t) < 16 || t[:16] != "application/json" {
 		return http.StatusUnsupportedMediaType, fmt.Errorf("content-type is not application/json")
 	}
 
diff --git a/internal/pb/federation.pb.go b/internal/pb/federation.pb.go
index 11eec2b..3923714 100644
--- a/internal/pb/federation.pb.go
+++ b/internal/pb/federation.pb.go
@@ -255,6 +255,7 @@ type ContactTracingInfo struct {
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
+	// Transmission risk is an integer with valid values from 1-8.
 	TransmissionRisk int32          `protobuf:"varint,1,opt,name=transmissionRisk,proto3" json:"transmissionRisk,omitempty"` // required
 	ExposureKeys     []*ExposureKey `protobuf:"bytes,2,rep,name=exposureKeys,proto3" json:"exposureKeys,omitempty"`
 }
diff --git a/internal/database/exposure.go b/internal/publish/database/exposure.go
similarity index 90%
rename from internal/database/exposure.go
rename to internal/publish/database/exposure.go
index 1237985..8c07898 100644
--- a/internal/database/exposure.go
+++ b/internal/publish/database/exposure.go
@@ -23,7 +23,9 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/base64util"
+	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 
 	pgx "github.com/jackc/pgx/v4"
 )
@@ -33,6 +35,16 @@ const (
 	InsertExposuresBatchSize = 500
 )
 
+type PublishDB struct {
+	db *database.DB
+}
+
+func New(db *database.DB) *PublishDB {
+	return &PublishDB{
+		db: db,
+	}
+}
+
 // IterateExposuresCriteria is criteria to iterate exposures.
 type IterateExposuresCriteria struct {
 	IncludeRegions []string
@@ -54,8 +66,8 @@ type IterateExposuresCriteria struct {
 // criteria.LastCursor in a subsequent call to IterateExposures, will continue
 // the iteration at the failed row. If IterateExposures returns a nil error,
 // the first return value will be the empty string.
-func (db *DB) IterateExposures(ctx context.Context, criteria IterateExposuresCriteria, f func(*Exposure) error) (cur string, err error) {
-	conn, err := db.Pool.Acquire(ctx)
+func (db *PublishDB) IterateExposures(ctx context.Context, criteria IterateExposuresCriteria, f func(*model.Exposure) error) (cur string, err error) {
+	conn, err := db.db.Pool.Acquire(ctx)
 	if err != nil {
 		return "", fmt.Errorf("acquiring connection: %v", err)
 	}
@@ -94,7 +106,7 @@ func (db *DB) IterateExposures(ctx context.Context, criteria IterateExposuresCri
 			return cursor(), err
 		}
 		var (
-			m          Exposure
+			m          model.Exposure
 			encodedKey string
 			syncID     *int64
 		)
@@ -178,8 +190,8 @@ func generateExposureQuery(criteria IterateExposuresCriteria) (string, []interfa
 }
 
 // InsertExposures inserts a set of exposures.
-func (db *DB) InsertExposures(ctx context.Context, exposures []*Exposure) error {
-	return db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+func (db *PublishDB) InsertExposures(ctx context.Context, exposures []*model.Exposure) error {
+	return db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
 		const stmtName = "insert exposures"
 		_, err := tx.Prepare(ctx, stmtName, `
 			INSERT INTO
@@ -210,10 +222,10 @@ func (db *DB) InsertExposures(ctx context.Context, exposures []*Exposure) error
 }
 
 // DeleteExposures deletes exposures created before "before" date. Returns the number of records deleted.
-func (db *DB) DeleteExposures(ctx context.Context, before time.Time) (int64, error) {
+func (db *PublishDB) DeleteExposures(ctx context.Context, before time.Time) (int64, error) {
 	var count int64
 	// ReadCommitted is sufficient here because we are dealing with historical, immutable rows.
-	err := db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+	err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
 		result, err := tx.Exec(ctx, `
 			DELETE FROM
 				Exposure
diff --git a/internal/database/exposure_model.go b/internal/publish/model/exposure_model.go
similarity index 99%
rename from internal/database/exposure_model.go
rename to internal/publish/model/exposure_model.go
index e5dae2b..611aab0 100644
--- a/internal/database/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package database
+package model
 
 import (
 	"crypto/sha256"
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 5b0ed49..8338fd0 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -22,9 +22,11 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/database"
+
 	"github.com/google/exposure-notifications-server/internal/jsonutil"
 	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	"github.com/google/exposure-notifications-server/internal/verification"
 )
@@ -40,9 +42,9 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 		return nil, fmt.Errorf("missing AuthorizedApp provider in server environment")
 	}
 
-	transformer, err := database.NewTransformer(config.MaxKeysOnPublish, config.MaxIntervalAge, config.TruncateWindow)
+	transformer, err := model.NewTransformer(config.MaxKeysOnPublish, config.MaxIntervalAge, config.TruncateWindow)
 	if err != nil {
-		return nil, fmt.Errorf("database.NewTransformer: %w", err)
+		return nil, fmt.Errorf("model.NewTransformer: %w", err)
 	}
 	logger.Infof("max keys per upload: %v", config.MaxKeysOnPublish)
 	logger.Infof("max interval start age: %v", config.MaxIntervalAge)
@@ -52,7 +54,7 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 		serverenv:             env,
 		transformer:           transformer,
 		config:                config,
-		database:              env.Database(),
+		database:              database.New(env.Database()),
 		authorizedAppProvider: env.AuthorizedAppProvider(),
 	}, nil
 }
@@ -60,8 +62,8 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 type publishHandler struct {
 	config                *Config
 	serverenv             *serverenv.ServerEnv
-	transformer           *database.Transformer
-	database              *database.DB
+	transformer           *model.Transformer
+	database              *database.PublishDB
 	authorizedAppProvider authorizedapp.Provider
 }
 
@@ -77,7 +79,7 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 	ctx := r.Context()
 	logger := logging.FromContext(ctx)
 
-	var data database.Publish
+	var data model.Publish
 	code, err := jsonutil.Unmarshal(w, r, &data)
 	if err != nil {
 		// Log the unparsable JSON, but return success to the client.
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index 80772b4..82b1040 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -47,9 +47,9 @@ type KeyManagerProvider interface {
 	KeyManager() bool
 }
 
-// BlobStorageConfigProvider is a marker interface indicating the BlobStorage interface should be installed.
+// BlobStorageConfigProvider provides the information about current Blobstore configuration.
 type BlobStorageConfigProvider interface {
-	BlobStorage() bool
+	BlobStorage() storage.BlobstoreConfig
 }
 
 // Function returned from setup to be deferred until the caller exits.
@@ -86,13 +86,15 @@ func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv,
 		}
 		opts = append(opts, serverenv.WithKeyManager(km))
 	}
-	// TODO(mikehelmick): Make this extensible to other providers.
+
 	if _, ok := config.(BlobStorageConfigProvider); ok {
-		storage, err := storage.NewGoogleCloudStorage(ctx)
+		provider := config.(BlobStorageConfigProvider)
+		blobStore, err := storage.CreateBlobstore(ctx, provider.BlobStorage())
 		if err != nil {
 			return nil, nil, fmt.Errorf("unable to connect to storage system: %v", err)
 		}
-		opts = append(opts, serverenv.WithBlobStorage(storage))
+		blobStorage := serverenv.WithBlobStorage(blobStore)
+		opts = append(opts, blobStorage)
 	}
 
 	// Setup the database connection.
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index f1b9d1c..80ea5c5 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -15,7 +15,26 @@
 // Package storage is an interface over file/blob storage
 package storage
 
-import "context"
+import (
+	"context"
+	"fmt"
+
+	"github.com/google/exposure-notifications-server/internal/logging"
+)
+
+// Identifies the type of Blobestore to use
+type BlobstoreType string
+
+const (
+	None          BlobstoreType = "NONE"
+	Cloud_storage BlobstoreType = "CLOUD_STORAGE"
+	Filesystem    BlobstoreType = "FILESYSTEM"
+)
+
+// Blobstore Configuration
+type BlobstoreConfig struct {
+	BlobstoreType BlobstoreType
+}
 
 // Blobstore defines the minimum interface for a blob storage system.
 type Blobstore interface {
@@ -25,3 +44,37 @@ type Blobstore interface {
 	// DeleteObject deltes an object or does nothing if the object doesn't exist.
 	DeleteObject(ctx context.Context, bucket, objectName string) error
 }
+
+// Blobstore that does nothing.
+type NoopBlobstore struct{}
+
+func NewNoopBlobstore(ctx context.Context) (Blobstore, error) {
+	return &NoopBlobstore{}, nil
+}
+
+// No op.
+func (s *NoopBlobstore) CreateObject(ctx context.Context, folder, filename string, contents []byte) error {
+	return nil
+}
+
+// No op.
+func (s *NoopBlobstore) DeleteObject(ctx context.Context, folder, filename string) error {
+	return nil
+}
+
+// Creates a new BlobstoreFactory based on the provided BlobstoreType.
+func CreateBlobstore(ctx context.Context, config BlobstoreConfig) (Blobstore, error) {
+	logger := logging.FromContext(ctx)
+	logger.Infof("BlobstoreType is set up to %v", config.BlobstoreType)
+
+	switch config.BlobstoreType {
+	case Cloud_storage:
+		return NewGoogleCloudStorage(ctx)
+	case Filesystem:
+		return NewFilesystemStorage(ctx)
+	case None:
+		return NewNoopBlobstore(ctx)
+	default:
+		return nil, fmt.Errorf("Unknown BlobstoreType: %v", config.BlobstoreType)
+	}
+}
diff --git a/internal/util/generate.go b/internal/util/generate.go
index 5871f92..32d9cd1 100644
--- a/internal/util/generate.go
+++ b/internal/util/generate.go
@@ -22,7 +22,7 @@ import (
 	"math/big"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/testing/enclient"
 )
 
@@ -58,7 +58,7 @@ func RandomIntWithMin(min, max int) (int, error) {
 }
 
 func RandomTransmissionRisk() (int, error) {
-	n, err := RandomInt(database.MaxTransmissionRisk)
+	n, err := RandomInt(model.MaxTransmissionRisk)
 	return n + 1, err
 }
 
@@ -70,14 +70,14 @@ func RandomArrValue(arr []string) (string, error) {
 	return arr[n], nil
 }
 
-func GenerateExposureKeys(numKeys, tr int) []database.ExposureKey {
+func GenerateExposureKeys(numKeys, tr int) []model.ExposureKey {
 	// When publishing multiple keys - they'll be on different days.
 	intervalCount, err := RandomIntervalCount()
 	if err != nil {
 		log.Fatalf("problem with random interval: %v", err)
 	}
 	intervalNumber := int32(time.Now().Unix()/600) - intervalCount
-	exposureKeys := make([]database.ExposureKey, numKeys)
+	exposureKeys := make([]model.ExposureKey, numKeys)
 	for i := 0; i < numKeys; i++ {
 		transmissionRisk := tr
 		if transmissionRisk < 0 {
@@ -102,12 +102,12 @@ func GenerateExposureKeys(numKeys, tr int) []database.ExposureKey {
 }
 
 // Creates a random exposure key.
-func RandomExposureKey(intervalNumber enclient.Interval, intervalCount int32, transmissionRisk int) (database.ExposureKey, error) {
+func RandomExposureKey(intervalNumber enclient.Interval, intervalCount int32, transmissionRisk int) (model.ExposureKey, error) {
 	key, err := GenerateKey()
 	if err != nil {
-		return database.ExposureKey{}, err
+		return model.ExposureKey{}, err
 	}
-	return database.ExposureKey{
+	return model.ExposureKey{
 		Key:              key,
 		IntervalNumber:   int32(intervalNumber),
 		IntervalCount:    intervalCount,
diff --git a/internal/verification/verify.go b/internal/verification/verify.go
index d429b4a..9750aab 100644
--- a/internal/verification/verify.go
+++ b/internal/verification/verify.go
@@ -21,7 +21,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/android"
 	authorizedapp "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
-	"github.com/google/exposure-notifications-server/internal/database"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
 
 	"github.com/google/exposure-notifications-server/internal/ios"
 )
@@ -33,7 +33,7 @@ var (
 
 // VerifyRegions checks the request regions against the regions allowed by
 // the configuration for the application.
-func VerifyRegions(cfg *authorizedapp.AuthorizedApp, data *database.Publish) error {
+func VerifyRegions(cfg *authorizedapp.AuthorizedApp, data *publishmodel.Publish) error {
 	if cfg == nil {
 		return fmt.Errorf("app configuration is empty")
 	}
@@ -50,7 +50,7 @@ func VerifyRegions(cfg *authorizedapp.AuthorizedApp, data *database.Publish) err
 
 // VerifySafetyNet verifies the Android SafetyNet device attestation against the
 // allowed configuration for the application.
-func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *authorizedapp.AuthorizedApp, publish *database.Publish) error {
+func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *authorizedapp.AuthorizedApp, publish *publishmodel.Publish) error {
 	if cfg == nil {
 		return fmt.Errorf("cannot enforce SafetyNet, missing config")
 	}
@@ -64,7 +64,7 @@ func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *authorized
 }
 
 // VerifyDeviceCheck verifies an iOS DeviceCheck token against the Apple API.
-func VerifyDeviceCheck(ctx context.Context, cfg *authorizedapp.AuthorizedApp, data *database.Publish) error {
+func VerifyDeviceCheck(ctx context.Context, cfg *authorizedapp.AuthorizedApp, data *publishmodel.Publish) error {
 	if cfg == nil {
 		return fmt.Errorf("cannot enforce DeviceCheck, missing config")
 	}
diff --git a/scripts/build b/scripts/build
index 6a9b460..cac995e 100755
--- a/scripts/build
+++ b/scripts/build
@@ -17,12 +17,35 @@
 set -eEuo pipefail
 
 ROOT="$(cd "$(dirname "$0")/.." &>/dev/null; pwd -P)"
+ALL_SERVICES="cleanup-export,cleanup-exposure,export,exposure,federationin,federationout"
 
 if [ -z "${PROJECT_ID:-}" ]; then
   echo "✋ Missing PROJECT_ID!" >&2
   exit 1
 fi
 
-gcloud builds submit "${ROOT}" \
-  --config "${ROOT}/builders/build.yaml" \
-  --project "${PROJECT_ID}"
+if [ -z "${SERVICES:-}" ]; then
+  echo "✋ Missing SERVICES!" >&2
+  exit 1
+fi
+
+if [ "${SERVICES}" == "all" ]; then
+  SERVICES=${ALL_SERVICES}
+  echo "🚧 Building all services! (${SERVICES})" >&2
+fi
+
+if [ -z "${TAG:-}" ]; then
+  TAG="$(date '+%Y%m%d%H%M%S')"
+  echo "🚧 Using generated tag! (${TAG})" >&2
+fi
+
+IFS=',' read -ra SERVICES_ARR <<< "${SERVICES}"
+for SERVICE in "${SERVICES_ARR[@]}"; do
+  gcloud builds submit "${ROOT}" \
+    --project "${PROJECT_ID}" \
+    --config "${ROOT}/builders/build.yaml" \
+    --substitutions "_SERVICE=${SERVICE},_TAG=${TAG}" \
+    &
+done
+
+wait
diff --git a/scripts/deploy b/scripts/deploy
index 05f19d4..5bc9fa0 100755
--- a/scripts/deploy
+++ b/scripts/deploy
@@ -17,6 +17,7 @@
 set -eEuo pipefail
 
 ROOT="$(cd "$(dirname "$0")/.." &>/dev/null; pwd -P)"
+ALL_SERVICES="cleanup-export,cleanup-exposure,export,exposure,federationin,federationout"
 
 if [ -z "${PROJECT_ID:-}" ]; then
   echo "✋ Missing PROJECT_ID!" >&2
@@ -28,7 +29,28 @@ if [ -z "${REGION:-}" ]; then
   exit 1
 fi
 
-gcloud builds submit "${ROOT}" \
-  --config "${ROOT}/builders/deploy.yaml" \
-  --substitutions "_REGION=${REGION}" \
-  --project "${PROJECT_ID}"
+if [ -z "${SERVICES:-}" ]; then
+  echo "✋ Missing SERVICES!" >&2
+  exit 1
+fi
+
+if [ "${SERVICES}" == "all" ]; then
+  SERVICES="${ALL_SERVICES}"
+  echo "🚧 Building all services! (${SERVICES})" >&2
+fi
+
+if [ -z "${TAG:-}" ]; then
+  TAG="$(date '+%Y%m%d%H%M%S')"
+  echo "🚧 Using generated tag! (${TAG})" >&2
+fi
+
+IFS=',' read -ra SERVICES_ARR <<< "${SERVICES}"
+for SERVICE in "${SERVICES_ARR[@]}"; do
+  gcloud builds submit --no-source \
+    --project "${PROJECT_ID}" \
+    --config "${ROOT}/builders/deploy.yaml" \
+    --substitutions "_REGION=${REGION},_SERVICE=${SERVICE},_TAG=${TAG}" \
+    &
+done
+
+wait
diff --git a/scripts/promote b/scripts/promote
index 13d55b3..4f3d932 100755
--- a/scripts/promote
+++ b/scripts/promote
@@ -17,6 +17,7 @@
 set -eEuo pipefail
 
 ROOT="$(cd "$(dirname "$0")/.." &>/dev/null; pwd -P)"
+ALL_SERVICES="cleanup-export,cleanup-exposure,export,exposure,federationin,federationout"
 
 if [ -z "${PROJECT_ID:-}" ]; then
   echo "✋ Missing PROJECT_ID!" >&2
@@ -28,6 +29,16 @@ if [ -z "${REGION:-}" ]; then
   exit 1
 fi
 
+if [ -z "${SERVICES:-}" ]; then
+  echo "✋ Missing SERVICES!" >&2
+  exit 1
+fi
+
+if [ "${SERVICES}" == "all" ]; then
+  SERVICES="${ALL_SERVICES}"
+  echo "🚧 Building all services! (${SERVICES})" >&2
+fi
+
 if [ -z "${REVISION:-}" ]; then
   echo "🚧 Using LATEST revision" >&2
   REVISION="LATEST"
@@ -38,7 +49,13 @@ if [ -z "${PERCENTAGE:-}" ]; then
   PERCENTAGE="100"
 fi
 
-gcloud builds submit "${ROOT}" \
-  --config "${ROOT}/builders/promote.yaml" \
-  --substitutions "_REGION=${REGION},_REVISION=${REVISION},_PERCENTAGE=${PERCENTAGE}" \
-  --project "${PROJECT_ID}"
+IFS=',' read -ra SERVICES_ARR <<< "${SERVICES}"
+for SERVICE in "${SERVICES_ARR[@]}"; do
+  gcloud builds submit --no-source \
+    --project "${PROJECT_ID}" \
+    --config "${ROOT}/builders/promote.yaml" \
+    --substitutions "_REGION=${REGION},_REVISION=${REVISION},_PERCENTAGE=${PERCENTAGE},_SERVICE=${SERVICE}" \
+    &
+done
+
+wait
diff --git a/terraform/README.md b/terraform/README.md
index 4c973de..f9954a4 100644
--- a/terraform/README.md
+++ b/terraform/README.md
@@ -1,9 +1,10 @@
 # Starting the exposure notifications server
 
-This is a set of terraform configs which should enable you to bring up a copy of
-the exposure notifications server on GCP.  It makes heavy use of the GCP
-terraform provider, developed at
-https://github.com/terraform-providers/terraform-provider-google.
+This is a set of Terraform configurations which create the required
+infrastructure for an exposure notifications server on Google Cloud. Please note
+that **Terraform is only used for the initial deployment and provisioning of
+underlying infrastructure!** It is not used for continuous delivery or
+continuous deployment.
 
 ## Requirements
 
@@ -36,18 +37,6 @@ For full instructions on deploying, view the [deployment docs](../docs/deploying
     $ export PROJECT_ID="<value-from-above>"
     ```
 
-1.  (Optional) Decide to use Cloud Build Triggers. If you do, every push to
-    master on the GitHub repo containing the exposure server code will trigger a
-    new deployment. To enable this:
-
-    1.  Visit https://console.cloud.google.com/cloud-build/triggers/connect and
-        follow the instructions to connect as a Cloud Build GitHub App. You must
-        choose a repository that you have admin permissions on.
-
-    1.  Remember which repo you used. You will need to set the repo owner (e.g.
-        'google') and name (e.g. 'exposure-notifications-server') as variables
-        in the `terraform apply`
-
 1.  Authenticate to gcloud with:
 
     ```text
@@ -56,33 +45,57 @@ For full instructions on deploying, view the [deployment docs](../docs/deploying
 
     This will open two authentication windows in your web browser.
 
-1.  Change to this directory and run `terraform init`. Terraform will
-    automatically download the plugins required to execute this code:
+1.  (Optional, but recommended) Create a Cloud Storage bucket for storing remote
+    state. This is important if you plan to have multiple people running
+    Terraform or collaborating.
 
     ```text
-    $ terraform init
+    $ gsutil mb -p ${PROJECT_ID} gs://${PROJECT_ID}-tf-state
     ```
 
-1.  Execute Terraform:
+    Configurre Terraform to store state in the bucket:
+
+    ```text
+    cat <<EOF > ./terraform/state.tf
+    terraform {
+      backend "gcs" {
+        bucket = "${PROJECT_ID}-tf-state"
+      }
+    }
+    EOF
+    ```
 
-    Without Cloud Build Triggers:
+1.  Change to the `terraform` directory and run `terraform init`. Terraform will
+    automatically download the plugins required to execute this code:
 
     ```text
-    $ terraform apply \
-        -var project=$PROJECT_ID
+    $ terraform init
     ```
 
-    With Cloud Build Triggers:
+1.  Execute Terraform:
 
     ```text
     $ terraform apply \
-        -var project=${PROJECT_ID} \
-        -var use_build_triggers=true \
-        -var repo_owner=${YOUR_REPO_OWNER} \
-        -var repo_name=${YOUR_REPO_NAME}
+        -var project=${PROJECT_ID}
     ```
 
-Terraform will begin by creating the service accounts and enabling the services
-on GCP which are required to run this server.
+Terraform will create the required infrastructure including the database,
+service accounts, storage bucket, keys, and secrets. **As a one-time
+operation**, Terraform will also migrate the database schema and build/deploy
+the initial set of services on Cloud Run. Terraform does not manage the
+lifecycle of those resources beyond their initial creation.
+
+### Local development and testing example deployment
+
+The default Terraform deployment is a production-ready, high traffic
+deployment. For local development and testing, we recommend you use the
+following sample deployment:
+
+1. Run `terraform apply` with the following command:
 
-It will then create the database and user and apply the DB schema, and run the assorted binaries with everything hooked up.
+   ```console
+   terraform apply \
+     -var project=${PROJECT_ID} \
+     -var cloudsql_tier="db-custom-1-3840" \
+     -var cloudsql_disk_size_gb="16"
+   ```
diff --git a/terraform/database.tf b/terraform/database.tf
index 618b9a1..f8cdf10 100644
--- a/terraform/database.tf
+++ b/terraform/database.tf
@@ -62,51 +62,6 @@ resource "google_sql_ssl_cert" "client_cert" {
   instance    = google_sql_database_instance.db-inst.name
 }
 
-resource "google_secret_manager_secret" "ssl-ca-cert" {
-  provider  = google-beta
-  secret_id = "dbServerCA"
-  replication {
-    automatic = true
-  }
-  depends_on = [google_project_service.services["secretmanager.googleapis.com"]]
-}
-
-resource "google_secret_manager_secret_version" "db-ca-cert" {
-  provider    = google-beta
-  secret      = google_secret_manager_secret.ssl-ca-cert.id
-  secret_data = google_sql_ssl_cert.client_cert.server_ca_cert
-}
-
-resource "google_secret_manager_secret" "ssl-key" {
-  provider  = google-beta
-  secret_id = "dbClientKey"
-  replication {
-    automatic = true
-  }
-  depends_on = [google_project_service.services["secretmanager.googleapis.com"]]
-}
-
-resource "google_secret_manager_secret_version" "db-key" {
-  provider    = google-beta
-  secret      = google_secret_manager_secret.ssl-key.id
-  secret_data = google_sql_ssl_cert.client_cert.private_key
-}
-
-resource "google_secret_manager_secret" "ssl-cert" {
-  provider  = google-beta
-  secret_id = "dbClientCert"
-  replication {
-    automatic = true
-  }
-  depends_on = [google_project_service.services["secretmanager.googleapis.com"]]
-}
-
-resource "google_secret_manager_secret_version" "db-cert" {
-  provider    = google-beta
-  secret      = google_secret_manager_secret.ssl-cert.id
-  secret_data = google_sql_ssl_cert.client_cert.cert
-}
-
 resource "random_password" "userpassword" {
   length  = 16
   special = false
@@ -128,26 +83,14 @@ resource "google_sql_database" "db" {
 resource "google_secret_manager_secret" "db-pwd" {
   provider  = google-beta
   secret_id = "dbPassword"
+
   replication {
     automatic = true
   }
-  depends_on = [google_project_service.services["secretmanager.googleapis.com"]]
-}
-
-resource "google_project_iam_member" "cloudbuild-secrets" {
-  project = data.google_project.project.project_id
-  role    = "roles/secretmanager.secretAccessor"
-  member  = "serviceAccount:${data.google_project.project.number}@cloudbuild.gserviceaccount.com"
 
-  depends_on = [google_project_service.services["cloudbuild.googleapis.com"]]
-}
-
-resource "google_project_iam_member" "cloudbuild-sql" {
-  project = data.google_project.project.project_id
-  role    = "roles/cloudsql.client"
-  member  = "serviceAccount:${data.google_project.project.number}@cloudbuild.gserviceaccount.com"
-
-  depends_on = [google_project_service.services["cloudbuild.googleapis.com"]]
+  depends_on = [
+    google_project_service.services["secretmanager.googleapis.com"],
+  ]
 }
 
 resource "google_secret_manager_secret_version" "db-pwd-initial" {
@@ -156,51 +99,50 @@ resource "google_secret_manager_secret_version" "db-pwd-initial" {
   secret_data = google_sql_user.user.password
 }
 
-locals {
-  schema_substitutions = {
-    "_DB_CONN" : google_sql_database_instance.db-inst.connection_name,
-    "_DB_NAME" : google_sql_database.db.name,
-    "_DB_PASS_SECRET" : google_secret_manager_secret_version.db-pwd-initial.name,
-    "_DB_USER" : google_sql_user.user.name,
-  }
-}
-
-resource "google_cloudbuild_trigger" "update-schema" {
-  provider = google-beta
-  count    = var.use_build_triggers ? 1 : 0
-
-  name        = "update-schema"
-  description = "Build the containers for the schema migrator and run it to ensure the DB is up to date."
-  filename    = "builders/migrate.yaml"
-
-  github {
-    owner = var.repo_owner
-    name  = var.repo_name
-    push {
-      branch = "^master$"
-    }
-  }
-
-  substitutions = local.schema_substitutions
+# Grant Cloud Build the ability to access the database password (required to run
+# migrations).
+resource "google_secret_manager_secret_iam_member" "cloudbuild-db-pwd" {
+  provider  = google-beta
+  secret_id = google_secret_manager_secret.db-pwd.id
+  role      = "roles/secretmanager.secretAccessor"
+  member    = "serviceAccount:${data.google_project.project.number}@cloudbuild.gserviceaccount.com"
 
   depends_on = [
-    google_project_iam_member.cloudbuild-secrets,
-    google_project_iam_member.cloudbuild-sql,
+    google_project_service.services["cloudbuild.googleapis.com"],
   ]
 }
 
-resource "null_resource" "submit-update-schema" {
+# Grant Cloud Build the ability to connect to Cloud SQL.
+resource "google_project_iam_member" "cloudbuild-sql" {
+  project = data.google_project.project.project_id
+  role    = "roles/cloudsql.client"
+  member  = "serviceAccount:${data.google_project.project.number}@cloudbuild.gserviceaccount.com"
+
+  depends_on = [google_project_service.services["cloudbuild.googleapis.com"]]
+}
+
+# Migrate runs the initial database migrations.
+resource "null_resource" "migrate" {
   provisioner "local-exec" {
-    command = "gcloud builds submit ../ --config ../builders/migrate.yaml --project ${data.google_project.project.project_id} --substitutions=${join(",", formatlist("%s=%s", keys(local.schema_substitutions), values(local.schema_substitutions)))}"
-  }
+    environment = {
+      PROJECT_ID     = data.google_project.project.project_id
+      DB_CONN        = google_sql_database_instance.db-inst.connection_name
+      DB_PASS_SECRET = google_secret_manager_secret_version.db-pwd-initial.name
+      DB_NAME        = google_sql_database.db.name
+      DB_USER        = google_sql_user.user.name
+      COMMAND        = "up"
+
+      REGION   = var.region
+      SERVICES = "all"
+      TAG      = "initial"
+    }
 
-  triggers = {
-    database      = google_sql_database_instance.db-inst.name,
-    substitutions = join(",", values(local.schema_substitutions)),
+    command = "${path.module}/../scripts/migrate"
   }
 
   depends_on = [
-    google_project_iam_member.cloudbuild-secrets,
+    google_project_service.services["cloudbuild.googleapis.com"],
+    google_secret_manager_secret_iam_member.cloudbuild-db-pwd,
     google_project_iam_member.cloudbuild-sql,
   ]
 }
diff --git a/terraform/main.tf b/terraform/main.tf
index 7c5587e..df68b4b 100644
--- a/terraform/main.tf
+++ b/terraform/main.tf
@@ -68,50 +68,26 @@ resource "google_service_networking_connection" "private_vpc_connection" {
   reserved_peering_ranges = [google_compute_global_address.private_ip_address.name]
 }
 
-locals {
-  build_substitutions = {
-    "_REGION" : var.region,
-  }
-}
-
-# This step automatically runs a build as well, so everything that uses an image depends on it.
-resource "google_cloudbuild_trigger" "build-and-publish" {
-  provider = google-beta
-  count    = var.use_build_triggers ? 1 : 0
-
-  name        = "build-containers"
-  description = "Build the containers for the exposure notification service and deploy them to cloud run"
-  filename    = "builders/deploy.yaml"
-  github {
-    owner = var.repo_owner
-    name  = var.repo_name
-    push {
-      branch = "^master$"
-    }
-  }
-
-  substitutions = local.build_substitutions
-
-  depends_on = [google_project_service.services["cloudbuild.googleapis.com"]]
-}
-
-# "build" does first time setup - it is different from "deploy" which we set up to trigger for later.
-resource "null_resource" "submit-build-and-publish" {
+# Build creates the container images. It does not deploy or promote them.
+resource "null_resource" "build" {
   provisioner "local-exec" {
     environment = {
       PROJECT_ID = data.google_project.project.project_id
       REGION     = var.region
+      SERVICES   = "all"
+      TAG        = "initial"
     }
 
     command = "${path.module}/../scripts/build"
   }
 
   depends_on = [
-    google_project_iam_member.cloudbuild-secrets,
-    google_project_iam_member.cloudbuild-sql,
+    google_project_service.services["cloudbuild.googleapis.com"],
   ]
 }
 
+# Grant Cloud Build the ability to deploy images. It does not do so in these
+# configurations, but it will do future deployments.
 resource "google_project_iam_member" "cloudbuild-deploy" {
   project = data.google_project.project.project_id
   role    = "roles/run.admin"
@@ -163,3 +139,11 @@ resource "google_app_engine_application" "app" {
   project     = data.google_project.project.project_id
   location_id = var.appengine_location
 }
+
+output "region" {
+  value = var.region
+}
+
+output "project" {
+  value = data.google_project.project.project_id
+}
diff --git a/terraform/service_cleanup_export.tf b/terraform/service_cleanup_export.tf
index 5ec5478..375a209 100644
--- a/terraform/service_cleanup_export.tf
+++ b/terraform/service_cleanup_export.tf
@@ -61,7 +61,7 @@ resource "google_cloud_run_service" "cleanup-export" {
       service_account_name = google_service_account.cleanup-export.email
 
       containers {
-        image = "us.gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/cleanup-export:latest"
+        image = "gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/cleanup-export:initial"
 
         resources {
           limits = {
@@ -91,8 +91,14 @@ resource "google_cloud_run_service" "cleanup-export" {
   depends_on = [
     google_project_service.services["run.googleapis.com"],
     google_project_service.services["sqladmin.googleapis.com"],
-    null_resource.submit-build-and-publish,
+    null_resource.build,
   ]
+
+  lifecycle {
+    ignore_changes = [
+      template,
+    ]
+  }
 }
 
 
diff --git a/terraform/service_cleanup_exposure.tf b/terraform/service_cleanup_exposure.tf
index fb17a89..d73c2f8 100644
--- a/terraform/service_cleanup_exposure.tf
+++ b/terraform/service_cleanup_exposure.tf
@@ -55,7 +55,7 @@ resource "google_cloud_run_service" "cleanup-exposure" {
       service_account_name = google_service_account.cleanup-exposure.email
 
       containers {
-        image = "us.gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/cleanup-exposure:latest"
+        image = "gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/cleanup-exposure:initial"
 
         resources {
           limits = {
@@ -85,8 +85,14 @@ resource "google_cloud_run_service" "cleanup-exposure" {
   depends_on = [
     google_project_service.services["run.googleapis.com"],
     google_project_service.services["sqladmin.googleapis.com"],
-    null_resource.submit-build-and-publish,
+    null_resource.build,
   ]
+
+  lifecycle {
+    ignore_changes = [
+      template,
+    ]
+  }
 }
 
 
diff --git a/terraform/service_export.tf b/terraform/service_export.tf
index 03ecfcd..4d05d4e 100644
--- a/terraform/service_export.tf
+++ b/terraform/service_export.tf
@@ -67,7 +67,7 @@ resource "google_cloud_run_service" "export" {
       service_account_name = google_service_account.export.email
 
       containers {
-        image = "us.gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/export:latest"
+        image = "gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/export:initial"
 
         resources {
           limits = {
@@ -107,8 +107,14 @@ resource "google_cloud_run_service" "export" {
   depends_on = [
     google_project_service.services["run.googleapis.com"],
     google_project_service.services["sqladmin.googleapis.com"],
-    google_cloudbuild_trigger.build-and-publish,
+    null_resource.build,
   ]
+
+  lifecycle {
+    ignore_changes = [
+      template,
+    ]
+  }
 }
 
 
diff --git a/terraform/service_exposure.tf b/terraform/service_exposure.tf
index 6e6f4b7..3488578 100644
--- a/terraform/service_exposure.tf
+++ b/terraform/service_exposure.tf
@@ -55,7 +55,7 @@ resource "google_cloud_run_service" "exposure" {
       service_account_name = google_service_account.exposure.email
 
       containers {
-        image = "us.gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/exposure:latest"
+        image = "gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/exposure:initial"
 
         resources {
           limits = {
@@ -85,8 +85,14 @@ resource "google_cloud_run_service" "exposure" {
   depends_on = [
     google_project_service.services["run.googleapis.com"],
     google_project_service.services["sqladmin.googleapis.com"],
-    null_resource.submit-build-and-publish,
+    null_resource.build,
   ]
+
+  lifecycle {
+    ignore_changes = [
+      template,
+    ]
+  }
 }
 
 resource "google_cloud_run_service_iam_member" "exposure-public" {
diff --git a/terraform/service_federationin.tf b/terraform/service_federationin.tf
index c8354e3..46569a4 100644
--- a/terraform/service_federationin.tf
+++ b/terraform/service_federationin.tf
@@ -55,7 +55,7 @@ resource "google_cloud_run_service" "federationin" {
       service_account_name = google_service_account.federationin.email
 
       containers {
-        image = "us.gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/federationin:latest"
+        image = "gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/federationin:initial"
 
         resources {
           limits = {
@@ -85,6 +85,12 @@ resource "google_cloud_run_service" "federationin" {
   depends_on = [
     google_project_service.services["run.googleapis.com"],
     google_project_service.services["sqladmin.googleapis.com"],
-    null_resource.submit-build-and-publish,
+    null_resource.build,
   ]
+
+  lifecycle {
+    ignore_changes = [
+      template,
+    ]
+  }
 }
diff --git a/terraform/service_federationout.tf b/terraform/service_federationout.tf
index 90cda73..2ae98e6 100644
--- a/terraform/service_federationout.tf
+++ b/terraform/service_federationout.tf
@@ -55,7 +55,7 @@ resource "google_cloud_run_service" "federationout" {
       service_account_name = google_service_account.federationout.email
 
       containers {
-        image = "us.gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/federationout:latest"
+        image = "gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/federationout:initial"
 
         resources {
           limits = {
@@ -85,8 +85,14 @@ resource "google_cloud_run_service" "federationout" {
   depends_on = [
     google_project_service.services["run.googleapis.com"],
     google_project_service.services["sqladmin.googleapis.com"],
-    null_resource.submit-build-and-publish,
+    null_resource.build,
   ]
+
+  lifecycle {
+    ignore_changes = [
+      template,
+    ]
+  }
 }
 
 resource "google_cloud_run_service_iam_member" "federationout-public" {
diff --git a/terraform/vars.tf b/terraform/vars.tf
index e033593..fa5f051 100644
--- a/terraform/vars.tf
+++ b/terraform/vars.tf
@@ -26,11 +26,6 @@ variable "project" {
   type = string
 }
 
-variable "use_build_triggers" {
-  type    = bool
-  default = false
-}
-
 variable "repo_owner" {
   type    = string
   default = "google"
diff --git a/tools/export-generate/main.go b/tools/export-generate/main.go
index ab69341..fb076ea 100644
--- a/tools/export-generate/main.go
+++ b/tools/export-generate/main.go
@@ -29,7 +29,8 @@ import (
 	"math"
 	"time"
 
-	coredb "github.com/google/exposure-notifications-server/internal/database"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/exposure-notifications-server/internal/export"
 	"github.com/google/exposure-notifications-server/internal/export/model"
 
@@ -44,7 +45,7 @@ var (
 	region         = flag.String("region", "US", "The region for the test export.")
 	startTimestamp = flag.String("start-timestamp", "2020-05-01T15:00:00Z", "The test export start timestamp (RFC3339).")
 	endTimestamp   = flag.String("end-timestamp", "2020-05-02T15:00:00Z", "The test export end timestamp (RFC3339).")
-	numKeys        = flag.Int("num-keys", 450, "Number of total random temporary exposure keys to generate in the export")
+	numKeys        = flag.Int("num-keys", 450, "Number of total random temporary exposure keys to generate. Ignored if tek-file set.")
 	tekFile        = flag.String("tek-file", "", "JSON file of TEKs in the same format as calling publish endpoint")
 	batchSize      = flag.Int("batches-size", 100, "Max number of keys in each file in the batch")
 )
@@ -91,20 +92,20 @@ func main() {
 		log.Fatalf("problem with random transmission risk: %v", err)
 	}
 	var actualNumKeys int
-	var exposureKeys []coredb.Exposure
+	var exposureKeys []publishmodel.Exposure
 	if *tekFile != "" {
 		log.Printf("Using TEKs provided in: %s", *tekFile)
 		file, err := ioutil.ReadFile(*tekFile)
 		if err != nil {
 			log.Fatalf("unable to read file: %v", err)
 		}
-		data := coredb.ExposureKeys{}
+		data := publishmodel.ExposureKeys{}
 		err = json.Unmarshal([]byte(file), &data)
 		if err != nil {
 			log.Fatalf("unable to parse json: %v", err)
 		}
 		for _, k := range data.Keys {
-			ek, err := coredb.TransformExposureKey(k, "", []string{}, time.Now(), int32(0), math.MaxInt32)
+			ek, err := publishmodel.TransformExposureKey(k, "", []string{}, time.Now(), int32(0), math.MaxInt32)
 			if err != nil {
 				log.Fatalf("invalid exposure key: %v", err)
 			}
@@ -112,10 +113,11 @@ func main() {
 		}
 		actualNumKeys = len(exposureKeys)
 	} else {
+		log.Printf("Genrating %d random TEKs", *numKeys)
 		keys := util.GenerateExposureKeys(*numKeys, tr)
 		actualNumKeys = *numKeys
 
-		exposureKeys = make([]coredb.Exposure, actualNumKeys)
+		exposureKeys = make([]publishmodel.Exposure, actualNumKeys)
 		for i, k := range keys {
 			decoded, err := base64.StdEncoding.DecodeString(k.Key)
 			if err != nil {
@@ -141,13 +143,13 @@ func main() {
 	numBatches := int(math.Ceil(float64(actualNumKeys) / float64(*batchSize)))
 	log.Printf("number of batches: %d", numBatches)
 	b := 0
-	currentBatch := []*coredb.Exposure{}
+	currentBatch := []*publishmodel.Exposure{}
 	for i := 0; i < actualNumKeys; i++ {
 		currentBatch = append(currentBatch, &exposureKeys[i])
 		if len(currentBatch) == *batchSize {
 			b++
 			writeFile(eb, currentBatch, b, numBatches, actualNumKeys, privateKey)
-			currentBatch = []*coredb.Exposure{}
+			currentBatch = []*publishmodel.Exposure{}
 		}
 	}
 	if len(currentBatch) > 0 {
@@ -156,7 +158,7 @@ func main() {
 	}
 }
 
-func writeFile(eb *model.ExportBatch, currentBatch []*coredb.Exposure, b, numBatches, numRecords int, privateKey *ecdsa.PrivateKey) {
+func writeFile(eb *model.ExportBatch, currentBatch []*publishmodel.Exposure, b, numBatches, numRecords int, privateKey *ecdsa.PrivateKey) {
 	signatureInfo := &model.SignatureInfo{
 		SigningKeyID:      *keyID,
 		SigningKeyVersion: *keyVersion,
diff --git a/tools/exposure-client/main.go b/tools/exposure-client/main.go
index 32a7b49..73e5142 100644
--- a/tools/exposure-client/main.go
+++ b/tools/exposure-client/main.go
@@ -24,7 +24,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/util"
 	"github.com/google/exposure-notifications-server/testing/enclient"
 )
@@ -84,7 +84,7 @@ func main() {
 		log.Printf("could not get random padding: %v", err)
 	}
 
-	data := database.Publish{
+	data := model.Publish{
 		Keys:           exposureKeys,
 		Regions:        region,
 		AppPackageName: *appPackage,


Test patch:
diff --git a/internal/android/safetynet_test.go b/internal/android/safetynet_test.go
index 6441744..bc8e3ff 100644
--- a/internal/android/safetynet_test.go
+++ b/internal/android/safetynet_test.go
@@ -21,7 +21,8 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
 	"github.com/google/exposure-notifications-server/internal/base64util"
-	"github.com/google/exposure-notifications-server/internal/database"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/go-cmp/cmp"
 )
 
@@ -39,8 +40,8 @@ var (
 	// **This comment applies to all test data in this file.**
 	payload = `eyJhbGciOiJSUzI1NiIsIng1YyI6WyJNSUlGa3pDQ0JIdWdBd0lCQWdJUkFOY1NramRzNW42K0NBQUFBQUFwYTBjd0RRWUpLb1pJaHZjTkFRRUxCUUF3UWpFTE1Ba0dBMVVFQmhNQ1ZWTXhIakFjQmdOVkJBb1RGVWR2YjJkc1pTQlVjblZ6ZENCVFpYSjJhV05sY3pFVE1CRUdBMVVFQXhNS1IxUlRJRU5CSURGUE1UQWVGdzB5TURBeE1UTXhNVFF4TkRsYUZ3MHlNVEF4TVRFeE1UUXhORGxhTUd3eEN6QUpCZ05WQkFZVEFsVlRNUk13RVFZRFZRUUlFd3BEWVd4cFptOXlibWxoTVJZd0ZBWURWUVFIRXcxTmIzVnVkR0ZwYmlCV2FXVjNNUk13RVFZRFZRUUtFd3BIYjI5bmJHVWdURXhETVJzd0dRWURWUVFERXhKaGRIUmxjM1F1WVc1a2NtOXBaQzVqYjIwd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUUNXRXJCUVRHWkdOMWlaYk45ZWhSZ2lmV0J4cWkyUGRneHcwM1A3VHlKWmZNeGpwNUw3ajFHTmVQSzVIemRyVW9JZDF5Q0l5Qk15eHFnYXpxZ3RwWDVXcHNYVzRWZk1oSmJOMVkwOXF6cXA2SkQrMlBaZG9UVTFrRlJBTVdmTC9VdVp0azdwbVJYZ0dtNWpLRHJaOU54ZTA0dk1ZUXI4OE5xd1cva2ZaMWdUT05JVVQwV3NMVC80NTIyQlJXeGZ3eGMzUUUxK1RLV2tMQ3J2ZWs2V2xJcXlhQzUyVzdNRFI4TXBGZWJ5bVNLVHZ3Zk1Sd3lLUUxUMDNVTDR2dDQ4eUVjOHNwN3dUQUhNL1dEZzhRb3RhcmY4T0JIa25vWjkyWGl2aWFWNnRRcWhST0hDZmdtbkNYaXhmVzB3RVhDdnFpTFRiUXRVYkxzUy84SVJ0ZFhrcFFCOUFnTUJBQUdqZ2dKWU1JSUNWREFPQmdOVkhROEJBZjhFQkFNQ0JhQXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUhBd0V3REFZRFZSMFRBUUgvQkFJd0FEQWRCZ05WSFE0RUZnUVU2REhCd3NBdmI1M2cvQzA3cHJUdnZ3TlFRTFl3SHdZRFZSMGpCQmd3Rm9BVW1OSDRiaERyejV2c1lKOFlrQnVnNjMwSi9Tc3daQVlJS3dZQkJRVUhBUUVFV0RCV01DY0dDQ3NHQVFVRkJ6QUJoaHRvZEhSd09pOHZiMk56Y0M1d2Eya3VaMjl2Wnk5bmRITXhiekV3S3dZSUt3WUJCUVVITUFLR0gyaDBkSEE2THk5d2Eya3VaMjl2Wnk5bmMzSXlMMGRVVXpGUE1TNWpjblF3SFFZRFZSMFJCQll3RklJU1lYUjBaWE4wTG1GdVpISnZhV1F1WTI5dE1DRUdBMVVkSUFRYU1CZ3dDQVlHWjRFTUFRSUNNQXdHQ2lzR0FRUUIxbmtDQlFNd0x3WURWUjBmQkNnd0pqQWtvQ0tnSUlZZWFIUjBjRG92TDJOeWJDNXdhMmt1WjI5dlp5OUhWRk14VHpFdVkzSnNNSUlCQkFZS0t3WUJCQUhXZVFJRUFnU0I5UVNCOGdEd0FIY0E5bHlVTDlGM01DSVVWQmdJTUpSV2p1Tk5FeGt6djk4TUx5QUx6RTd4Wk9NQUFBRnZudXkwWndBQUJBTUFTREJHQWlFQTdlLzBZUnUzd0FGbVdIMjdNMnZiVmNaL21ycCs0cmZZYy81SVBKMjlGNmdDSVFDbktDQ0FhY1ZOZVlaOENDZllkR3BCMkdzSHh1TU9Ia2EvTzQxaldlRit6Z0IxQUVTVVpTNnc3czZ2eEVBSDJLaitLTURhNW9LKzJNc3h0VC9UTTVhMXRvR29BQUFCYjU3c3RKTUFBQVFEQUVZd1JBSWdFWGJpb1BiSnA5cUMwRGoyNThERkdTUk1BVStaQjFFaVZFYmJiLzRVdk5FQ0lCaEhrQnQxOHZSbjl6RHZ5cmZ4eXVkY0hUT1NsM2dUYVlBLzd5VC9CaUg0TUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFESUFjUUJsbWQ4TUVnTGRycnJNYkJUQ3ZwTVhzdDUrd3gyRGxmYWpKTkpVUDRqWUZqWVVROUIzWDRFMnpmNDluWDNBeXVaRnhBcU9SbmJqLzVqa1k3YThxTUowajE5ekZPQitxZXJ4ZWMwbmhtOGdZbExiUW02c0tZN1AwZXhmcjdIdUszTWtQMXBlYzE0d0ZFVWFHcUR3VWJHZ2wvb2l6MzhGWENFK0NXOEUxUUFFVWZ2YlFQVFliS3hZait0Q05sc3MwYlRTb0wyWjJkL2ozQnBMM01GdzB5eFNLL1VUcXlrTHIyQS9NZGhKUW14aStHK01LUlNzUXI2MkFuWmF1OXE2WUZvaSs5QUVIK0E0OFh0SXlzaEx5Q1RVM0h0K2FLb2hHbnhBNXVsMVhSbXFwOEh2Y0F0MzlQOTVGWkdGSmUwdXZseWpPd0F6WHVNdTdNK1BXUmMiLCJNSUlFU2pDQ0F6S2dBd0lCQWdJTkFlTzBtcUdOaXFtQkpXbFF1REFOQmdrcWhraUc5dzBCQVFzRkFEQk1NU0F3SGdZRFZRUUxFeGRIYkc5aVlXeFRhV2R1SUZKdmIzUWdRMEVnTFNCU01qRVRNQkVHQTFVRUNoTUtSMnh2WW1Gc1UybG5iakVUTUJFR0ExVUVBeE1LUjJ4dlltRnNVMmxuYmpBZUZ3MHhOekEyTVRVd01EQXdOREphRncweU1URXlNVFV3TURBd05ESmFNRUl4Q3pBSkJnTlZCQVlUQWxWVE1SNHdIQVlEVlFRS0V4VkhiMjluYkdVZ1ZISjFjM1FnVTJWeWRtbGpaWE14RXpBUkJnTlZCQU1UQ2tkVVV5QkRRU0F4VHpFd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUURRR005RjFJdk4wNXprUU85K3ROMXBJUnZKenp5T1RIVzVEekVaaEQyZVBDbnZVQTBRazI4RmdJQ2ZLcUM5RWtzQzRUMmZXQllrL2pDZkMzUjNWWk1kUy9kTjRaS0NFUFpSckF6RHNpS1VEelJybUJCSjV3dWRnem5kSU1ZY0xlL1JHR0ZsNXlPRElLZ2pFdi9TSkgvVUwrZEVhbHROMTFCbXNLK2VRbU1GKytBY3hHTmhyNTlxTS85aWw3MUkyZE44RkdmY2Rkd3VhZWo0YlhocDBMY1FCYmp4TWNJN0pQMGFNM1Q0SStEc2F4bUtGc2JqemFUTkM5dXpwRmxnT0lnN3JSMjV4b3luVXh2OHZObWtxN3pkUEdIWGt4V1k3b0c5aitKa1J5QkFCazdYckpmb3VjQlpFcUZKSlNQazdYQTBMS1cwWTN6NW96MkQwYzF0Skt3SEFnTUJBQUdqZ2dFek1JSUJMekFPQmdOVkhROEJBZjhFQkFNQ0FZWXdIUVlEVlIwbEJCWXdGQVlJS3dZQkJRVUhBd0VHQ0NzR0FRVUZCd01DTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0hRWURWUjBPQkJZRUZKalIrRzRRNjgrYjdHQ2ZHSkFib090OUNmMHJNQjhHQTFVZEl3UVlNQmFBRkp2aUIxZG5IQjdBYWdiZVdiU2FMZC9jR1lZdU1EVUdDQ3NHQVFVRkJ3RUJCQ2t3SnpBbEJnZ3JCZ0VGQlFjd0FZWVphSFIwY0RvdkwyOWpjM0F1Y0d0cExtZHZiMmN2WjNOeU1qQXlCZ05WSFI4RUt6QXBNQ2VnSmFBamhpRm9kSFJ3T2k4dlkzSnNMbkJyYVM1bmIyOW5MMmR6Y2pJdlozTnlNaTVqY213d1B3WURWUjBnQkRnd05qQTBCZ1puZ1F3QkFnSXdLakFvQmdnckJnRUZCUWNDQVJZY2FIUjBjSE02THk5d2Eya3VaMjl2Wnk5eVpYQnZjMmwwYjNKNUx6QU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFHb0ErTm5uNzh5NnBSamQ5WGxRV05hN0hUZ2laL3IzUk5Ha21VbVlIUFFxNlNjdGk5UEVhanZ3UlQyaVdUSFFyMDJmZXNxT3FCWTJFVFV3Z1pRK2xsdG9ORnZoc085dHZCQ09JYXpwc3dXQzlhSjl4anU0dFdEUUg4TlZVNllaWi9YdGVEU0dVOVl6SnFQalk4cTNNRHhyem1xZXBCQ2Y1bzhtdy93SjRhMkc2eHpVcjZGYjZUOE1jRE8yMlBMUkw2dTNNNFR6czNBMk0xajZieWtKWWk4d1dJUmRBdktMV1p1L2F4QlZielltcW13a201ekxTRFc1bklBSmJFTENRQ1p3TUg1NnQyRHZxb2Z4czZCQmNDRklaVVNweHU2eDZ0ZDBWN1N2SkNDb3NpclNtSWF0ai85ZFNTVkRRaWJldDhxLzdVSzR2NFpVTjgwYXRuWnoxeWc9PSJdfQ.eyJub25jZSI6IlZreEphblUwYW5CcFkydDNObEZFZVM5d2IzRk9iRlprTHpCaFNYTktiRTVqYkVGSmFESnFRbm94VFQwPSIsInRpbWVzdGFtcE1zIjoxNTg5NTg4NDA4MTg1LCJhcGtQYWNrYWdlTmFtZSI6ImNvbS5nb29nbGUuYW5kcm9pZC5hcHBzLmV4cG9zdXJlbm90aWZpY2F0aW9uIiwiYXBrRGlnZXN0U2hhMjU2IjoiYWt2Y3oxdWgyak85NzF5blZ1bXdsSEM4L2xHZG5vYjVGVlNIWVlabCtWcz0iLCJjdHNQcm9maWxlTWF0Y2giOnRydWUsImFwa0NlcnRpZmljYXRlRGlnZXN0U2hhMjU2IjpbImpxbVlFcWk5cVV2cFVlMTFxTWYzdjJvNlZFUU0rNU5EZWUyYnoweGR6V2M9Il0sImJhc2ljSW50ZWdyaXR5Ijp0cnVlLCJldmFsdWF0aW9uVHlwZSI6IkJBU0lDIn0.cU97Z9Fax39ehQvKpa8C0I0hXIh1zclZErn5uDGdtWsVTddpf1qxSVnAwxAGhBmqbOF_YL-75dGojxT8dsGpe7LBXQr9qyRm79FjfIAAqDQrblvOxIatGbR1qkmLJITF7DAc4sAcK8CP2RxIrAkUIAUAdgwsx4JbXeNMfPxR-XODMo93MaMRLV5byA1MDaRw3RA-HTx81LEAuQJfUcoRf-O7WTnBBYDUDlUAY5_VNp7wGRKg9eNNzbbysPqpS-o-sW67dvsalav2ENwwQT9RJwWR4YCI4bDh_qH3Mz8FfChwefS1AW4nOPsOJwkEhLHIO521ZQiwZVct1b6glPc08g`
 
-	publish = &database.Publish{
-		Keys: []database.ExposureKey{
+	publish = &publishmodel.Publish{
+		Keys: []publishmodel.ExposureKey{
 			{Key: "QLIvVheW9p6JiTx4pslesg==", IntervalNumber: 2649312, IntervalCount: 144, TransmissionRisk: 1},
 			{Key: "UW7pkDKfLbfLs8LveFyY3w==", IntervalNumber: 2649168, IntervalCount: 144, TransmissionRisk: 1},
 		},
diff --git a/internal/authorizedapp/database/authorized_app_test.go b/internal/authorizedapp/database/authorized_app_test.go
index ac90c85..4781166 100644
--- a/internal/authorizedapp/database/authorized_app_test.go
+++ b/internal/authorizedapp/database/authorized_app_test.go
@@ -59,6 +59,7 @@ func TestMain(m *testing.M) {
 	}
 	os.Exit(m.Run())
 }
+
 func TestGetAuthorizedApp(t *testing.T) {
 	if testDB == nil {
 		t.Skip("no test DB")
diff --git a/internal/export/database/export_test.go b/internal/export/database/export_test.go
index 52872b6..c1f1d92 100644
--- a/internal/export/database/export_test.go
+++ b/internal/export/database/export_test.go
@@ -25,6 +25,8 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/database"
 	"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/go-cmp/cmp"
 	pgx "github.com/jackc/pgx/v4"
 )
@@ -477,21 +479,21 @@ func TestKeysInBatch(t *testing.T) {
 	}
 
 	// Create key aligned with the StartTimestamp
-	sek := &database.Exposure{
+	sek := &publishmodel.Exposure{
 		ExposureKey: []byte("aaa"),
 		Regions:     []string{ec.Region},
 		CreatedAt:   startTimestamp,
 	}
 
 	// Create key aligned with the EndTimestamp
-	eek := &database.Exposure{
+	eek := &publishmodel.Exposure{
 		ExposureKey: []byte("bbb"),
 		Regions:     []string{ec.Region},
 		CreatedAt:   endTimestamp,
 	}
 
 	// Add the keys to the database.
-	if err := testDB.InsertExposures(ctx, []*database.Exposure{sek, eek}); err != nil {
+	if err := publishdb.New(testDB).InsertExposures(ctx, []*publishmodel.Exposure{sek, eek}); err != nil {
 		t.Fatal(err)
 	}
 
@@ -512,14 +514,14 @@ func TestKeysInBatch(t *testing.T) {
 
 	// Lookup the keys; they must be only the key created_at the startTimestamp
 	// (because start is inclusive, end is exclusive).
-	criteria := database.IterateExposuresCriteria{
+	criteria := publishdb.IterateExposuresCriteria{
 		IncludeRegions: []string{leased.Region},
 		SinceTimestamp: leased.StartTimestamp,
 		UntilTimestamp: leased.EndTimestamp,
 	}
 
-	var got []*database.Exposure
-	_, err = testDB.IterateExposures(ctx, criteria, func(exp *database.Exposure) error {
+	var got []*publishmodel.Exposure
+	_, err = publishdb.New(testDB).IterateExposures(ctx, criteria, func(exp *publishmodel.Exposure) error {
 		got = append(got, exp)
 		return nil
 	})
diff --git a/internal/export/worker_test.go b/internal/export/worker_test.go
index 4407d3b..4755504 100644
--- a/internal/export/worker_test.go
+++ b/internal/export/worker_test.go
@@ -19,24 +19,24 @@ import (
 	"fmt"
 	"testing"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 )
 
 func TestRandomInt(t *testing.T) {
 	expected := make(map[int]bool)
-	for i := database.MinTransmissionRisk; i <= database.MaxTransmissionRisk; i++ {
+	for i := model.MinTransmissionRisk; i <= model.MaxTransmissionRisk; i++ {
 		expected[i] = true
 	}
 
 	// Run through 1,000 iterations. To ensure the entire range can be hit.
 	for i := 0; i < 1000; i++ {
-		v, err := randomInt(database.MinTransmissionRisk, database.MaxTransmissionRisk)
+		v, err := randomInt(model.MinTransmissionRisk, model.MaxTransmissionRisk)
 		if err != nil {
 			t.Fatalf("error getting random data")
 		}
-		if v < database.MinTransmissionRisk || v > database.MaxTransmissionRisk {
+		if v < model.MinTransmissionRisk || v > model.MaxTransmissionRisk {
 			t.Fatalf("random data outside expected bounds. %v <= %v <= %v",
-				database.MinTransmissionRisk, v, database.MaxTransmissionRisk)
+				model.MinTransmissionRisk, v, model.MaxTransmissionRisk)
 		}
 		delete(expected, v)
 	}
@@ -46,7 +46,7 @@ func TestRandomInt(t *testing.T) {
 }
 
 func TestDoNotPadZeroLength(t *testing.T) {
-	exposures := make([]*database.Exposure, 0)
+	exposures := make([]*model.Exposure, 0)
 	exposures, err := ensureMinNumExposures(exposures, "US", 1000, 100)
 	if err != nil {
 		t.Fatalf("unepected error: %v", err)
@@ -56,14 +56,14 @@ func TestDoNotPadZeroLength(t *testing.T) {
 	}
 }
 
-func addExposure(t *testing.T, exposures []*database.Exposure, interval, count int32, risk int) []*database.Exposure {
-	key := make([]byte, database.KeyLength)
+func addExposure(t *testing.T, exposures []*model.Exposure, interval, count int32, risk int) []*model.Exposure {
+	key := make([]byte, model.KeyLength)
 	_, err := rand.Read(key)
 	if err != nil {
 		t.Fatalf("error getting random data: %v", err)
 	}
 	return append(exposures,
-		&database.Exposure{
+		&model.Exposure{
 			ExposureKey:      key,
 			TransmissionRisk: risk,
 			IntervalNumber:   interval,
@@ -73,11 +73,11 @@ func addExposure(t *testing.T, exposures []*database.Exposure, interval, count i
 
 func TestEnsureMinExposures(t *testing.T) {
 	expectedTR := make(map[int]bool)
-	for i := database.MinTransmissionRisk; i <= database.MaxTransmissionRisk; i++ {
+	for i := model.MinTransmissionRisk; i <= model.MaxTransmissionRisk; i++ {
 		expectedTR[i] = true
 	}
 	// Insert a few exposures - that will be used to base the interval information off of.
-	exposures := make([]*database.Exposure, 0)
+	exposures := make([]*model.Exposure, 0)
 	exposures = addExposure(t, exposures, 123456, 144, 0)
 	exposures = addExposure(t, exposures, 789101, 88, 0)
 	// all of these combinations are expected
diff --git a/internal/federationin/federationin_test.go b/internal/federationin/federationin_test.go
index 9fd0ffa..8daef98 100644
--- a/internal/federationin/federationin_test.go
+++ b/internal/federationin/federationin_test.go
@@ -20,6 +20,8 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/exposure-notifications-server/internal/metrics"
 	"github.com/google/exposure-notifications-server/internal/pb"
 
@@ -38,7 +40,7 @@ var (
 )
 
 // makeRemoteExposure returns a mock model.Exposure with LocalProvenance=false.
-func makeRemoteExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *database.Exposure {
+func makeRemoteExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *model.Exposure {
 	inf := makeExposure(diagKey, diagStatus, regions...)
 	inf.LocalProvenance = false
 	inf.FederationSyncID = syncID
@@ -62,12 +64,12 @@ func (r *remoteFetchServer) fetch(ctx context.Context, req *pb.FederationFetchRe
 	return response, nil
 }
 
-// exposureDB mocks the database, recording exposure insertions.
-type exposureDB struct {
-	exposures []*database.Exposure
+// publishDB mocks the database, recording exposure insertions.
+type publishDB struct {
+	exposures []*model.Exposure
 }
 
-func (idb *exposureDB) insertExposures(ctx context.Context, exposures []*database.Exposure) error {
+func (idb *publishDB) insertExposures(ctx context.Context, exposures []*model.Exposure) error {
 	idb.exposures = append(idb.exposures, exposures...)
 	return nil
 }
@@ -99,7 +101,7 @@ func TestFederationPull(t *testing.T) {
 		name             string
 		batchSize        int
 		fetchResponses   []*pb.FederationFetchResponse
-		wantExposures    []*database.Exposure
+		wantExposures    []*model.Exposure
 		wantTokens       []string
 		wantMaxTimestamp time.Time
 	}{
@@ -135,7 +137,7 @@ func TestFederationPull(t *testing.T) {
 					FetchResponseKeyTimestamp: 400,
 				},
 			},
-			wantExposures: []*database.Exposure{
+			wantExposures: []*model.Exposure{
 				makeRemoteExposure(aaa, 1, "US"),
 				makeRemoteExposure(bbb, 1, "US"),
 				makeRemoteExposure(ccc, 2, "CA", "US"),
@@ -171,7 +173,7 @@ func TestFederationPull(t *testing.T) {
 					FetchResponseKeyTimestamp: 400,
 				},
 			},
-			wantExposures: []*database.Exposure{
+			wantExposures: []*model.Exposure{
 				makeRemoteExposure(aaa, 1, "US"),
 				makeRemoteExposure(bbb, 1, "US"),
 			},
@@ -212,7 +214,7 @@ func TestFederationPull(t *testing.T) {
 					FetchResponseKeyTimestamp: 400,
 				},
 			},
-			wantExposures: []*database.Exposure{
+			wantExposures: []*model.Exposure{
 				makeRemoteExposure(aaa, 8, "US"),
 				makeRemoteExposure(bbb, 8, "US"),
 				makeRemoteExposure(ccc, 7, "US"),
@@ -249,7 +251,7 @@ func TestFederationPull(t *testing.T) {
 					FetchResponseKeyTimestamp: 400,
 				},
 			},
-			wantExposures: []*database.Exposure{
+			wantExposures: []*model.Exposure{
 				makeRemoteExposure(aaa, 3, "US"),
 				makeRemoteExposure(bbb, 3, "US"),
 				makeRemoteExposure(ccc, 2, "CA", "US"),
@@ -265,7 +267,7 @@ func TestFederationPull(t *testing.T) {
 			ctx := context.Background()
 			query := &database.FederationInQuery{}
 			remote := remoteFetchServer{responses: tc.fetchResponses}
-			idb := exposureDB{}
+			idb := publishDB{}
 			sdb := syncDB{}
 			batchStart := time.Now()
 			if tc.batchSize > 0 {
@@ -284,7 +286,7 @@ func TestFederationPull(t *testing.T) {
 				t.Fatalf("pull returned err=%v, want err=nil", err)
 			}
 
-			if diff := cmp.Diff(tc.wantExposures, idb.exposures, cmpopts.IgnoreFields(database.Exposure{}, "CreatedAt")); diff != "" {
+			if diff := cmp.Diff(tc.wantExposures, idb.exposures, cmpopts.IgnoreFields(model.Exposure{}, "CreatedAt")); diff != "" {
 				t.Errorf("exposures mismatch (-want +got):\n%s", diff)
 			}
 			if diff := cmp.Diff(tc.wantTokens, remote.gotTokens); diff != "" {
@@ -309,8 +311,8 @@ func TestFederationPull(t *testing.T) {
 	}
 }
 
-func makeExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *database.Exposure {
-	return &database.Exposure{
+func makeExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *model.Exposure {
+	return &model.Exposure{
 		Regions:          regions,
 		TransmissionRisk: diagStatus,
 		ExposureKey:      diagKey.ExposureKey,
diff --git a/internal/federationout/federationout_test.go b/internal/federationout/federationout_test.go
index 57cea09..97660df 100644
--- a/internal/federationout/federationout_test.go
+++ b/internal/federationout/federationout_test.go
@@ -20,7 +20,10 @@ import (
 	"testing"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/database"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+
 	"github.com/google/exposure-notifications-server/internal/pb"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	"google.golang.org/grpc/metadata"
@@ -44,9 +47,9 @@ var (
 	ddd = &pb.ExposureKey{ExposureKey: []byte("ddd"), IntervalNumber: 4}
 )
 
-// makeExposure returns a mock database.Exposure.
-func makeExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *database.Exposure {
-	return &database.Exposure{
+// makeExposure returns a mock model.Exposure.
+func makeExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *model.Exposure {
+	return &model.Exposure{
 		Regions:          regions,
 		TransmissionRisk: diagStatus,
 		ExposureKey:      diagKey.ExposureKey,
@@ -60,11 +63,11 @@ func makeExposure(diagKey *pb.ExposureKey, diagStatus int, regions ...string) *d
 type timeout struct{}
 
 func iterFunc(elements []interface{}) iterateExposuresFunc {
-	return func(_ context.Context, _ database.IterateExposuresCriteria, f func(*database.Exposure) error) (string, error) {
+	return func(_ context.Context, _ database.IterateExposuresCriteria, f func(*model.Exposure) error) (string, error) {
 		var cursor string
 		for _, el := range elements {
 			switch v := el.(type) {
-			case *database.Exposure:
+			case *model.Exposure:
 				// Set the cursor to the most recent diagnosis key, suffixed with "_cursor".
 				cursor = string(v.ExposureKey) + "_cursor"
 				if err := f(v); err != nil {
diff --git a/internal/jsonutil/unmarshal_test.go b/internal/jsonutil/unmarshal_test.go
index 1a410ec..08a77c3 100644
--- a/internal/jsonutil/unmarshal_test.go
+++ b/internal/jsonutil/unmarshal_test.go
@@ -23,7 +23,7 @@ import (
 	"testing"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/go-cmp/cmp"
 )
 
@@ -33,7 +33,7 @@ func TestInvalidHeader(t *testing.T) {
 	r.Header.Set("content-type", "application/text")
 
 	w := httptest.NewRecorder()
-	data := &database.Publish{}
+	data := &model.Publish{}
 	code, err := Unmarshal(w, r, data)
 
 	expCode := http.StatusUnsupportedMediaType
@@ -130,7 +130,7 @@ func TestValidPublishMessage(t *testing.T) {
 
 	w := httptest.NewRecorder()
 
-	got := &database.Publish{}
+	got := &model.Publish{}
 	code, err := Unmarshal(w, r, got)
 	if err != nil {
 		t.Fatalf("unexpected err, %v", err)
@@ -139,8 +139,8 @@ func TestValidPublishMessage(t *testing.T) {
 		t.Errorf("unmarshal wanted %v response code, got %v", http.StatusOK, code)
 	}
 
-	want := &database.Publish{
-		Keys: []database.ExposureKey{
+	want := &model.Publish{
+		Keys: []model.ExposureKey{
 			{Key: "ABC", IntervalNumber: intervalNumber, IntervalCount: 144, TransmissionRisk: 2},
 			{Key: "DEF", IntervalNumber: intervalNumber, IntervalCount: 122, TransmissionRisk: 2},
 			{Key: "123", IntervalNumber: intervalNumber, IntervalCount: 1, TransmissionRisk: 2},
@@ -161,10 +161,10 @@ func unmarshalTestHelper(t *testing.T, payloads []string, errors []string, expCo
 	for i, testStr := range payloads {
 		body := ioutil.NopCloser(bytes.NewReader([]byte(testStr)))
 		r := httptest.NewRequest("POST", "/", body)
-		r.Header.Set("content-type", "application/json")
+		r.Header.Set("content-type", "application/json; charset=utf-8")
 
 		w := httptest.NewRecorder()
-		data := &database.Publish{}
+		data := &model.Publish{}
 		code, err := Unmarshal(w, r, data)
 		if code != expCode {
 			t.Errorf("unmarshal wanted %v response code, got %v", expCode, code)
diff --git a/internal/database/exposure_test.go b/internal/publish/database/exposure_test.go
similarity index 78%
rename from internal/database/exposure_test.go
rename to internal/publish/database/exposure_test.go
index d737288..329bb99 100644
--- a/internal/database/exposure_test.go
+++ b/internal/publish/database/exposure_test.go
@@ -17,22 +17,43 @@ package database
 import (
 	"context"
 	"errors"
+	"log"
+	"os"
 	"testing"
 	"time"
 
+	coredb "github.com/google/exposure-notifications-server/internal/database"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/go-cmp/cmp"
 )
 
+var testDB *coredb.DB
+var testPublishDB *PublishDB
+
+func TestMain(m *testing.M) {
+	ctx := context.Background()
+
+	if os.Getenv("DB_USER") != "" {
+		var err error
+		testDB, err = coredb.CreateTestDB(ctx)
+		if err != nil {
+			log.Fatalf("creating test DB: %v", err)
+		}
+		testPublishDB = New(testDB)
+	}
+	os.Exit(m.Run())
+}
 func TestExposures(t *testing.T) {
 	if testDB == nil {
 		t.Skip("no test DB")
 	}
-	defer ResetTestDB(t, testDB)
+	defer coredb.ResetTestDB(t, testDB)
 	ctx := context.Background()
 
 	// Insert some Exposures.
 	batchTime := time.Date(2020, 5, 1, 0, 0, 0, 0, time.UTC).Truncate(time.Microsecond)
-	exposures := []*Exposure{
+	exposures := []*model.Exposure{
 		{
 			ExposureKey:     []byte("ABC"),
 			Regions:         []string{"US", "CA", "MX"},
@@ -66,7 +87,7 @@ func TestExposures(t *testing.T) {
 			LocalProvenance: false,
 		},
 	}
-	if err := testDB.InsertExposures(ctx, exposures); err != nil {
+	if err := testPublishDB.InsertExposures(ctx, exposures); err != nil {
 		t.Fatal(err)
 	}
 
@@ -112,7 +133,7 @@ func TestExposures(t *testing.T) {
 		if err != nil {
 			t.Fatalf("%+v: %v", test.criteria, err)
 		}
-		var want []*Exposure
+		var want []*model.Exposure
 		for _, i := range test.want {
 			want = append(want, exposures[i])
 		}
@@ -122,7 +143,7 @@ func TestExposures(t *testing.T) {
 	}
 
 	// Delete some exposures.
-	gotN, err := testDB.DeleteExposures(ctx, exposures[2].CreatedAt)
+	gotN, err := testPublishDB.DeleteExposures(ctx, exposures[2].CreatedAt)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -140,9 +161,9 @@ func TestExposures(t *testing.T) {
 
 }
 
-func listExposures(ctx context.Context, c IterateExposuresCriteria) (_ []*Exposure, err error) {
-	var exps []*Exposure
-	_, err = testDB.IterateExposures(ctx, c, func(e *Exposure) error {
+func listExposures(ctx context.Context, c IterateExposuresCriteria) (_ []*model.Exposure, err error) {
+	var exps []*model.Exposure
+	_, err = testPublishDB.IterateExposures(ctx, c, func(e *model.Exposure) error {
 		exps = append(exps, e)
 		return nil
 	})
@@ -156,10 +177,10 @@ func TestIterateExposuresCursor(t *testing.T) {
 	if testDB == nil {
 		t.Skip("no test DB")
 	}
-	defer ResetTestDB(t, testDB)
+	defer coredb.ResetTestDB(t, testDB)
 	ctx, cancel := context.WithCancel(context.Background())
 	// Insert some Exposures.
-	exposures := []*Exposure{
+	exposures := []*model.Exposure{
 		{
 			ExposureKey:    []byte("ABC"),
 			Regions:        []string{"US", "CA", "MX"},
@@ -176,12 +197,12 @@ func TestIterateExposuresCursor(t *testing.T) {
 			Regions:        []string{"MX", "CA"},
 		},
 	}
-	if err := testDB.InsertExposures(ctx, exposures); err != nil {
+	if err := testPublishDB.InsertExposures(ctx, exposures); err != nil {
 		t.Fatal(err)
 	}
 	// Iterate over them, canceling the context in the middle.
-	var seen []*Exposure
-	cursor, err := testDB.IterateExposures(ctx, IterateExposuresCriteria{}, func(e *Exposure) error {
+	var seen []*model.Exposure
+	cursor, err := testPublishDB.IterateExposures(ctx, IterateExposuresCriteria{}, func(e *model.Exposure) error {
 		seen = append(seen, e)
 		if len(seen) == 2 {
 			cancel()
@@ -200,8 +221,8 @@ func TestIterateExposuresCursor(t *testing.T) {
 	// Resume from the cursor.
 	ctx = context.Background()
 	seen = nil
-	cursor, err = testDB.IterateExposures(ctx, IterateExposuresCriteria{LastCursor: cursor},
-		func(e *Exposure) error { seen = append(seen, e); return nil })
+	cursor, err = testPublishDB.IterateExposures(ctx, IterateExposuresCriteria{LastCursor: cursor},
+		func(e *model.Exposure) error { seen = append(seen, e); return nil })
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/database/exposure_model_test.go b/internal/publish/model/exposure_model_test.go
similarity index 99%
rename from internal/database/exposure_model_test.go
rename to internal/publish/model/exposure_model_test.go
index 63630e7..58e72ac 100644
--- a/internal/database/exposure_model_test.go
+++ b/internal/publish/model/exposure_model_test.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package database
+package model
 
 import (
 	"crypto/rand"
diff --git a/internal/verification/verify_test.go b/internal/verification/verify_test.go
index 2ce3c5e..b87de9c 100644
--- a/internal/verification/verify_test.go
+++ b/internal/verification/verify_test.go
@@ -23,7 +23,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/android"
 	authorizedapp "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 )
 
 const (
@@ -33,26 +33,26 @@ const (
 func TestVerifyRegions(t *testing.T) {
 	cases := []struct {
 		name string
-		data *database.Publish
+		data *model.Publish
 		cfg  *authorizedapp.AuthorizedApp
 		err  bool
 	}{
 		{
 			name: "nil_config",
-			data: &database.Publish{Regions: []string{"US"}},
+			data: &model.Publish{Regions: []string{"US"}},
 			cfg:  nil,
 			err:  true,
 		},
 		{
 			name: "nil_regions_allows_all",
-			data: &database.Publish{Regions: []string{"US"}},
+			data: &model.Publish{Regions: []string{"US"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 			},
 		},
 		{
 			name: "empty_regions_allows_all",
-			data: &database.Publish{Regions: []string{"US"}},
+			data: &model.Publish{Regions: []string{"US"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 				AllowedRegions: map[string]struct{}{},
@@ -60,7 +60,7 @@ func TestVerifyRegions(t *testing.T) {
 		},
 		{
 			name: "region_matches_one",
-			data: &database.Publish{Regions: []string{"US"}},
+			data: &model.Publish{Regions: []string{"US"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 				AllowedRegions: map[string]struct{}{
@@ -71,7 +71,7 @@ func TestVerifyRegions(t *testing.T) {
 		},
 		{
 			name: "region_matches_all",
-			data: &database.Publish{Regions: []string{"US", "CA"}},
+			data: &model.Publish{Regions: []string{"US", "CA"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 				AllowedRegions: map[string]struct{}{
@@ -82,7 +82,7 @@ func TestVerifyRegions(t *testing.T) {
 		},
 		{
 			name: "region_matches_some",
-			data: &database.Publish{Regions: []string{"US", "MX"}},
+			data: &model.Publish{Regions: []string{"US", "MX"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 				AllowedRegions: map[string]struct{}{
@@ -94,7 +94,7 @@ func TestVerifyRegions(t *testing.T) {
 		},
 		{
 			name: "region_matches_none",
-			data: &database.Publish{Regions: []string{"MX"}},
+			data: &model.Publish{Regions: []string{"MX"}},
 			cfg: &authorizedapp.AuthorizedApp{
 				AppPackageName: appPkgName,
 				AllowedRegions: map[string]struct{}{
@@ -123,27 +123,27 @@ func TestVerifySafetyNet(t *testing.T) {
 	}
 
 	cases := []struct {
-		Data              *database.Publish
+		Data              *model.Publish
 		Msg               string
 		Cfg               *authorizedapp.AuthorizedApp
 		AttestationResult error
 	}{
 		{
 			// With no configuration, return err.
-			&database.Publish{Regions: []string{"US"}},
+			&model.Publish{Regions: []string{"US"}},
 			"cannot enforce SafetyNet",
 			nil,
 			nil,
 		}, {
 			// Verify when Validate Attestation Passes, return nil.
-			&database.Publish{Regions: []string{"US"}},
+			&model.Publish{Regions: []string{"US"}},
 			"",
 			allRegions,
 			nil,
 		}, {
 			// Verify when ValidateAttestation raises err, with safety check
 			// enabled, return err.
-			&database.Publish{Regions: []string{"US"}},
+			&model.Publish{Regions: []string{"US"}},
 			"android.ValidateAttestation: mocked",
 			allRegions,
 			fmt.Errorf("mocked"),
diff --git a/testing/enclient/enclient.go b/testing/enclient/enclient.go
index 7cc5361..b6046e4 100644
--- a/testing/enclient/enclient.go
+++ b/testing/enclient/enclient.go
@@ -9,7 +9,7 @@ import (
 	"net/http"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/publish/model"
 )
 
 const (
@@ -70,8 +70,8 @@ func NewInterval(time int64) Interval {
 }
 
 // Creates an exposure key.
-func ExposureKey(key string, intervalNumber Interval, intervalCount int32, transmissionRisk int) database.ExposureKey {
-	return database.ExposureKey{
+func ExposureKey(key string, intervalNumber Interval, intervalCount int32, transmissionRisk int) model.ExposureKey {
+	return model.ExposureKey{
 		Key:              key,
 		IntervalNumber:   int32(intervalNumber),
 		IntervalCount:    intervalCount,