diff --git a/.github/workflows/continuous_integration.yaml b/.github/workflows/continuous_integration.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..96f3ebceb88bf90edd8d92c5539f0a8cd6725332
--- /dev/null
+++ b/.github/workflows/continuous_integration.yaml
@@ -0,0 +1,53 @@
+name: CI
+
+on: [pull_request]
+
+jobs:
+  lint:
+    name: Lint
+    runs-on: ubuntu-latest
+    steps:
+    - name: Setup
+      uses: actions/setup-go@v2
+      with:
+        go-version: '1.14'
+    - name: Checkout
+      uses: actions/checkout@v2
+    - name: Lint
+      run: make lint
+
+  test:
+    name: Test
+    runs-on: ubuntu-latest
+    steps:
+    - name: Setup
+      uses: actions/setup-go@v2
+      with:
+        go-version: '1.14'
+    - name: Checkout
+      uses: actions/checkout@v2
+    - name: Test
+      run: make testci
+    - name: Code coverage
+      uses: codecov/codecov-action@v1
+      with:
+        file: ./coverage.txt
+
+  regression_test:
+    name: Regression Tests
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        go_version:
+        - '1.11'
+        - '1.12'
+        - '1.13'
+    steps:
+    - name: Setup
+      uses: actions/setup-go@v2
+      with:
+        go-version: ${{matrix.go_version}}
+    - name: Checkout
+      uses: actions/checkout@v2
+    - name: Test
+      run: make test
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 2176f307c4b7862535cfa74513683de328e35bbb..0000000000000000000000000000000000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,29 +0,0 @@
----
-language: go
-go:
-  - '1.11'
-  - '1.12'
-  - '1.13'
-
-script:
-  - make lint
-  - 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 b2759bd15a935d420a5e88025aba2c681c48c1ba..2ab6c8b7bfd6a470fad385febb940e4f35a5ec21 100644
--- a/Makefile
+++ b/Makefile
@@ -16,14 +16,16 @@ 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
-GOLINT    := $(BIN_DIR)/golint
-GOBINDATA := $(BIN_DIR)/go-bindata
+# Tool dependencies
+TOOL_BIN_DIR     ?= $(shell go env GOPATH)/bin
+TOOL_GOBINDATA   := $(TOOL_BIN_DIR)/go-bindata
+TOOL_GOLINT      := $(TOOL_BIN_DIR)/golint
+TOOL_STATICCHECK := $(TOOL_BIN_DIR)/staticcheck
 
 GO_SOURCES = $(wildcard **/*.go)
 
+GENERATED_ASSETS_PATH := httpbin/assets/assets.go
+
 # =============================================================================
 # build
 # =============================================================================
@@ -38,8 +40,8 @@ assets: $(GENERATED_ASSETS_PATH)
 clean:
 	rm -rf $(DIST_PATH) $(COVERAGE_PATH)
 
-$(GENERATED_ASSETS_PATH): $(GOBINDATA) static/*
-	$(GOBINDATA) -o $(GENERATED_ASSETS_PATH) -pkg=assets -prefix=static static
+$(GENERATED_ASSETS_PATH): $(TOOL_GOBINDATA) static/*
+	$(TOOL_GOBINDATA) -o $(GENERATED_ASSETS_PATH) -pkg=assets -prefix=static static
 	# reformat generated code
 	gofmt -s -w $(GENERATED_ASSETS_PATH)
 	# dumb hack to make generate code lint correctly
@@ -63,10 +65,11 @@ testci:
 testcover: testci
 	go tool cover -html=$(COVERAGE_PATH)
 
-lint: $(GOLINT)
-	test -z "$$(gofmt -d -s -e .)" || (gofmt -d -s -e . ; exit 1)
-	$(GOLINT) -set_exit_status ./...
+lint: $(TOOL_GOLINT) $(TOOL_STATICCHECK)
+	test -z "$$(gofmt -d -s -e .)" || (echo "Error: gofmt failed"; gofmt -d -s -e . ; exit 1)
 	go vet ./...
+	$(TOOL_GOLINT) -set_exit_status ./...
+	$(TOOL_STATICCHECK) ./...
 
 
 # =============================================================================
@@ -94,18 +97,16 @@ imagepush: image
 
 # =============================================================================
 # dependencies
+#
+# Deps are installed outside of working dir to avoid polluting go modules
 # =============================================================================
-deps: $(GOLINT) $(GOBINDATA)
+deps: $(TOOL_GOBINDATA) $(TOOL_GOLINT) $(TOOL_STATICCHECK)
 
-# Can't install from working dir because of go mod issues:
-#
-#     go get -u github.com/kevinburke/go-bindata/...
-#     go: finding github.com/kevinburke/go-bindata/... latest
-#     go get github.com/kevinburke/go-bindata/...: no matching versions for query "latest"
-#
-# So we get out of the go modules path to install.
-$(GOBINDATA):
+$(TOOL_GOBINDATA):
 	cd /tmp && go get -u github.com/kevinburke/go-bindata/...
 
-$(GOLINT):
-	go get -u golang.org/x/lint/golint
+$(TOOL_GOLINT):
+	cd /tmp && go get -u golang.org/x/lint/golint
+
+$(TOOL_STATICCHECK):
+	cd /tmp && go get -u honnef.co/go/tools/cmd/staticcheck
diff --git a/httpbin/digest/digest_test.go b/httpbin/digest/digest_test.go
index dc25086eee65468dc544bae1effe0328a41496cf..97d55ec2eeeb7530d535fa4f1624237f568abc63 100644
--- a/httpbin/digest/digest_test.go
+++ b/httpbin/digest/digest_test.go
@@ -13,11 +13,6 @@ const (
 	exampleUsername = "Mufasa"
 	examplePassword = "Circle Of Life"
 
-	exampleChallenge string = `Digest realm="testrealm@host.com",
-            qop="auth,auth-int",
-            nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
-            opaque="5ccc069c403ebaf9f0171e9517f40e41"`
-
 	exampleAuthorization string = `Digest username="Mufasa",
 			realm="testrealm@host.com",
 			nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
diff --git a/httpbin/handlers.go b/httpbin/handlers.go
index 5828599b046eb2aedadbad7158543d7c546b54c2..8e0d068f3a8ba76a95e5a0d701e4f5052dd33eeb 100644
--- a/httpbin/handlers.go
+++ b/httpbin/handlers.go
@@ -228,7 +228,7 @@ func (h *HTTPBin) ResponseHeaders(w http.ResponseWriter, r *http.Request) {
 	args := r.URL.Query()
 	for k, vs := range args {
 		for _, v := range vs {
-			w.Header().Add(http.CanonicalHeaderKey(k), v)
+			w.Header().Add(k, v)
 		}
 	}
 	body, _ := json.Marshal(args)
diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go
index e554695e8b10de4fe785738cf387b8b9f49a281f..7873586a4ad9fde5cd2a8a307860ee6560427946 100644
--- a/httpbin/handlers_test.go
+++ b/httpbin/handlers_test.go
@@ -15,7 +15,6 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
-	"os"
 	"reflect"
 	"regexp"
 	"strconv"
@@ -31,7 +30,7 @@ const alphanumLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012
 var app = New(
 	WithMaxBodySize(maxBodySize),
 	WithMaxDuration(maxDuration),
-	WithObserver(StdLogObserver(log.New(os.Stderr, "", 0))),
+	WithObserver(StdLogObserver(log.New(ioutil.Discard, "", 0))),
 )
 
 var handler = app.Handler()
@@ -216,7 +215,7 @@ func TestHEAD(t *testing.T) {
 	assertStatusCode(t, w, 200)
 	assertBodyEquals(t, w, "")
 
-	contentLengthStr := w.HeaderMap.Get("Content-Length")
+	contentLengthStr := w.Header().Get("Content-Length")
 	if contentLengthStr == "" {
 		t.Fatalf("missing Content-Length header in response")
 	}
@@ -772,7 +771,7 @@ func TestResponseHeaders__OK(t *testing.T) {
 	assertContentType(t, w, jsonContentType)
 
 	for k, expectedValues := range headers {
-		values, ok := w.HeaderMap[k]
+		values, ok := w.Header()[k]
 		if !ok {
 			t.Fatalf("expected header %s in response headers", k)
 		}
@@ -1021,7 +1020,7 @@ func TestDeleteCookies(t *testing.T) {
 
 	for _, c := range w.Result().Cookies() {
 		if c.Name == toDelete {
-			if time.Now().Sub(c.Expires) < (24*365-1)*time.Hour {
+			if time.Since(c.Expires) < (24*365-1)*time.Hour {
 				t.Fatalf("expected cookie %s to be deleted; got %#v", toDelete, c)
 			}
 		}
@@ -1248,7 +1247,7 @@ func TestGzip(t *testing.T) {
 	assertHeader(t, w, "Content-Encoding", "gzip")
 	assertStatusCode(t, w, http.StatusOK)
 
-	zippedContentLengthStr := w.HeaderMap.Get("Content-Length")
+	zippedContentLengthStr := w.Header().Get("Content-Length")
 	if zippedContentLengthStr == "" {
 		t.Fatalf("missing Content-Length header in response")
 	}
@@ -1292,7 +1291,7 @@ func TestDeflate(t *testing.T) {
 	assertHeader(t, w, "Content-Encoding", "deflate")
 	assertStatusCode(t, w, http.StatusOK)
 
-	contentLengthHeader := w.HeaderMap.Get("Content-Length")
+	contentLengthHeader := w.Header().Get("Content-Length")
 	if contentLengthHeader == "" {
 		t.Fatalf("missing Content-Length header in response")
 	}
@@ -1415,7 +1414,7 @@ func TestDelay(t *testing.T) {
 			w := httptest.NewRecorder()
 			handler.ServeHTTP(w, r)
 
-			elapsed := time.Now().Sub(start)
+			elapsed := time.Since(start)
 
 			assertStatusCode(t, w, http.StatusOK)
 			assertHeader(t, w, "Content-Type", jsonContentType)
@@ -1515,7 +1514,7 @@ func TestDrip(t *testing.T) {
 			w := httptest.NewRecorder()
 			handler.ServeHTTP(w, r)
 
-			elapsed := time.Now().Sub(start)
+			elapsed := time.Since(start)
 
 			assertHeader(t, w, "Content-Type", "application/octet-stream")
 			assertStatusCode(t, w, test.code)
@@ -1676,7 +1675,7 @@ func TestRange(t *testing.T) {
 		w := httptest.NewRecorder()
 		handler.ServeHTTP(w, r)
 
-		t.Logf("headers = %v", w.HeaderMap)
+		t.Logf("headers = %v", w.Header())
 		assertStatusCode(t, w, http.StatusPartialContent)
 		assertHeader(t, w, "ETag", "range26")
 		assertHeader(t, w, "Content-Length", "5")
@@ -2256,19 +2255,19 @@ func TestBase64(t *testing.T) {
 		},
 		{
 			"/base64/",
-			"No input data",
+			"no input data",
 		},
 		{
 			"/base64/decode/",
-			"No input data",
+			"no input data",
 		},
 		{
 			"/base64/decode/dmFsaWRfYmFzZTY0X2VuY29kZWRfc3RyaW5n/extra",
-			"Invalid URL",
+			"invalid URL",
 		},
 		{
 			"/base64/unknown/dmFsaWRfYmFzZTY0X2VuY29kZWRfc3RyaW5n",
-			"Invalid operation: unknown",
+			"invalid operation: unknown",
 		},
 	}
 
diff --git a/httpbin/helpers.go b/httpbin/helpers.go
index fe62be345069fdec5f985de34f2696442d45d914..3b27f9810b41658c7cb13c6e1ceca117bb19d50f 100644
--- a/httpbin/helpers.go
+++ b/httpbin/helpers.go
@@ -262,7 +262,7 @@ func newBase64Helper(path string) (*base64Helper, error) {
 	parts := strings.Split(path, "/")
 
 	if len(parts) != 3 && len(parts) != 4 {
-		return nil, errors.New("Invalid URL")
+		return nil, errors.New("invalid URL")
 	}
 
 	var b base64Helper
@@ -277,15 +277,15 @@ func newBase64Helper(path string) (*base64Helper, error) {
 		// - /base64/encode/input_str
 		b.operation = parts[2]
 		if b.operation != "encode" && b.operation != "decode" {
-			return nil, fmt.Errorf("Invalid operation: %s", b.operation)
+			return nil, fmt.Errorf("invalid operation: %s", b.operation)
 		}
 		b.data = parts[3]
 	}
 	if len(b.data) == 0 {
-		return nil, errors.New("No input data")
+		return nil, errors.New("no input data")
 	}
 	if len(b.data) >= Base64MaxLen {
-		return nil, fmt.Errorf("Input length - %d, Cannot handle input >= %d", len(b.data), Base64MaxLen)
+		return nil, fmt.Errorf("input length - %d, Cannot handle input >= %d", len(b.data), Base64MaxLen)
 	}
 
 	return &b, nil
diff --git a/httpbin/middleware.go b/httpbin/middleware.go
index 6576610a1b4d90af590bd42a62df6e6c32ff245c..e35a5a9df5ec6b5834c37582fda74801251e9e6d 100644
--- a/httpbin/middleware.go
+++ b/httpbin/middleware.go
@@ -127,7 +127,7 @@ func observe(o Observer, h http.Handler) http.Handler {
 			Method:   r.Method,
 			URI:      r.URL.RequestURI(),
 			Size:     mw.Size(),
-			Duration: time.Now().Sub(t),
+			Duration: time.Since(t),
 		})
 	})
 }