diff --git a/.codecov.yml b/.codecov.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fb0470754a38f78e7d83c1552037d6abfe888389
--- /dev/null
+++ b/.codecov.yml
@@ -0,0 +1,32 @@
+# To validate this config:
+#
+#     cat codecov.yml | curl --data-binary @- https://codecov.io/validate
+#
+# See https://docs.codecov.io/docs for more info
+
+# https://docs.codecov.io/docs/coverage-configuration
+coverage:
+  precision: 2
+  round: down
+  range: "90..100"
+
+  # https://docs.codecov.io/docs/commit-status
+  status:
+    target: auto
+    threshold: 2
+    patch: no
+    changes: no
+
+parsers:
+  gcov:
+    branch_detection:
+      conditional: yes
+      loop: yes
+      method: no
+      macro: no
+
+# https://docs.codecov.io/docs/pull-request-comments
+comment:
+  layout: "header, diff, files"
+  behavior: default
+  require_changes: no
diff --git a/.gitignore b/.gitignore
index 2e43c5107c49f673854c3442dbc81ca551fa4c31..a3b9b4e02ddcbbefc0610c78de8eca150052e0d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,4 @@ _testmain.go
 *.prof
 
 dist/*
+coverage.txt
diff --git a/.travis.yml b/.travis.yml
index 2827b505a621a039967f8042a3153a770cbda3f5..917169c8a5a4c33e3798163aac5f998160cdb1e7 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -5,9 +5,26 @@ go:
   - '1.10'
   - '1.11'
   - '1.12'
-go_import_path: github.com/mccutchen/go-httpbin
+
 script:
   - make lint
-  - make test
+  - make testci
+
+after_success:
+  # Upload test coverage results to codecov.io
+  # https://github.com/codecov/example-go/blob/b85638743b972bd0bd2af63421fe513c6f968930/README.md
+  - bash <(curl -s https://codecov.io/bash)
+
+# With this filter, we aim to restrict Travis's "Build Pushes" feature to build
+# only pushes to master, while allowing the "Build Pull Requests" to build all
+# incoming pull requests without redundant double-builds.
+#
+# This is confusing on Travis's end; this captures the exact problem we're
+# trying to solve:
+# https://stackoverflow.com/a/31882307
+branches:
+  only:
+    - master
+
 notifications:
   email: false
diff --git a/Makefile b/Makefile
index 4885c36a544ea3e88d6ac3fe130b8571f142fce4..0cdcfcf23c29316ea41b2066983fe5a811731e64 100644
--- a/Makefile
+++ b/Makefile
@@ -1,9 +1,20 @@
-.PHONY: clean deploy deps image imagepush lint run stagedeploy test testcover
+.PHONY: clean deploy deps image imagepush lint run stagedeploy test testci testcover
 
-GCLOUD_PROJECT ?= httpbingo
-TEST_ARGS      ?= -race
+# The version that will be used in docker tags (e.g. to push a
+# go-httpbin:latest image use `make imagepush VERSION=latest)`
 VERSION        ?= $(shell git rev-parse --short HEAD)
 
+# Override this to deploy to a different App Engine project
+GCLOUD_PROJECT ?= httpbingo
+
+# Built binaries will be placed here
+DIST_PATH  	  ?= dist
+
+# Default flags used by the test, testci, testcover targets
+COVERAGE_PATH ?= coverage.txt
+COVERAGE_ARGS ?= -covermode=atomic -coverprofile=$(COVERAGE_PATH)
+TEST_ARGS     ?= -race
+
 GENERATED_ASSETS_PATH := httpbin/assets/assets.go
 
 BIN_DIR   := $(GOPATH)/bin
@@ -15,16 +26,16 @@ GO_SOURCES = $(wildcard **/*.go)
 # =============================================================================
 # build
 # =============================================================================
-build: dist/go-httpbin
+build: $(DIST_PATH)/go-httpbin
 
-dist/go-httpbin: assets $(GO_SOURCES)
-	mkdir -p dist
-	go build -o dist/go-httpbin ./cmd/go-httpbin
+$(DIST_PATH)/go-httpbin: assets $(GO_SOURCES)
+	mkdir -p $(DIST_PATH)
+	go build -o $(DIST_PATH)/go-httpbin ./cmd/go-httpbin
 
 assets: $(GENERATED_ASSETS_PATH)
 
 clean:
-	rm -r dist
+	rm -rf $(DIST_PATH) $(COVERAGE_PATH)
 
 $(GENERATED_ASSETS_PATH): $(GOBINDATA) static/*
 	$(GOBINDATA) -o $(GENERATED_ASSETS_PATH) -pkg=assets -prefix=static static
@@ -42,10 +53,14 @@ $(GENERATED_ASSETS_PATH): $(GOBINDATA) static/*
 test:
 	go test $(TEST_ARGS) ./...
 
-testcover:
-	mkdir -p dist
-	go test $(TEST_ARGS) -coverprofile=dist/coverage.out github.com/mccutchen/go-httpbin/httpbin
-	go tool cover -html=dist/coverage.out
+# Test command to run for continuous integration, which includes code coverage
+# based on codecov.io's documentation:
+# https://github.com/codecov/example-go/blob/b85638743b972bd0bd2af63421fe513c6f968930/README.md
+testci:
+	go test $(TEST_ARGS) $(COVERAGE_ARGS) ./...
+
+testcover: testci
+	go tool cover -html=$(COVERAGE_PATH)
 
 lint: $(GOLINT)
 	test -z "$$(gofmt -d -s -e .)" || (gofmt -d -s -e . ; exit 1)
@@ -63,7 +78,7 @@ stagedeploy: build
 	gcloud app deploy --quiet --project=$(GCLOUD_PROJECT) --version=$(VERSION) --no-promote
 
 run: build
-	./dist/go-httpbin
+	$(DIST_PATH)/go-httpbin
 
 
 # =============================================================================