diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000000000000000000000000000000000000..d2eae33e60e0c0afa0cf68ee2ac65987f6880db3
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @nhooyr
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2cc69828b6450adb5b3b1876803e215171612f87..865c67f041e939b0d913cc6be313e8233cf65ec5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,22 +4,48 @@ on: [push, pull_request]
 jobs:
   fmt:
     runs-on: ubuntu-latest
-    container: nhooyr/websocket-ci@sha256:8a8fd73fdea33585d50a33619c4936adfd016246a2ed6bbfbf06def24b518a6a
     steps:
       - uses: actions/checkout@v1
-      - run: make fmt
+      - uses: actions/cache@v1
+        with:
+          path: ~/go/pkg/mod
+          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+          restore-keys: |
+            ${{ runner.os }}-go-
+      - name: make fmt
+        uses: ./ci/image
+        with:
+          args: make fmt
+
   lint:
     runs-on: ubuntu-latest
-    container: nhooyr/websocket-ci@sha256:8a8fd73fdea33585d50a33619c4936adfd016246a2ed6bbfbf06def24b518a6a
     steps:
       - uses: actions/checkout@v1
-      - run: make lint
+      - uses: actions/cache@v1
+        with:
+          path: ~/go/pkg/mod
+          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+          restore-keys: |
+            ${{ runner.os }}-go-
+      - name: make lint
+        uses: ./ci/image
+        with:
+          args: make lint
+
   test:
     runs-on: ubuntu-latest
-    container: nhooyr/websocket-ci@sha256:8a8fd73fdea33585d50a33619c4936adfd016246a2ed6bbfbf06def24b518a6a
     steps:
       - uses: actions/checkout@v1
-      - run: make test
+      - uses: actions/cache@v1
+        with:
+          path: ~/go/pkg/mod
+          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+          restore-keys: |
+            ${{ runner.os }}-go-
+      - name: make test
+        uses: ./ci/image
+        with:
+          args: make test
         env:
           COVERALLS_TOKEN: ${{ secrets.github_token }}
       - name: Upload coverage.html
diff --git a/Makefile b/Makefile
index 8c8e1a084d5aaf9c84748e84154d8c7add499d09..ad1ba25789fdc6760b173bddcab615d25e0f185e 100644
--- a/Makefile
+++ b/Makefile
@@ -11,7 +11,3 @@ SHELL = bash
 include ci/fmt.mk
 include ci/lint.mk
 include ci/test.mk
-
-ci-image:
-	docker build -f ./ci/Dockerfile -t nhooyr/websocket-ci .
-	docker push nhooyr/websocket-ci
diff --git a/ci/Dockerfile b/ci/image/Dockerfile
similarity index 52%
rename from ci/Dockerfile
rename to ci/image/Dockerfile
index 0f0fc7d95d7b13a574c2f1dea98c3e7c7d70ccab..ccfac109f02c96f7bdd4bcc95397c9c5ad3494e5 100644
--- a/ci/Dockerfile
+++ b/ci/image/Dockerfile
@@ -5,8 +5,6 @@ RUN apt-get install -y chromium
 RUN apt-get install -y npm
 RUN apt-get install -y jq
 
-ENV GOPATH=/root/gopath
-ENV PATH=$GOPATH/bin:$PATH
 ENV GOFLAGS="-mod=readonly"
 ENV PAGER=cat
 ENV CI=true
@@ -18,14 +16,3 @@ RUN go get golang.org/x/tools/cmd/goimports
 RUN go get golang.org/x/lint/golint
 RUN go get github.com/agnivade/wasmbrowsertest
 RUN go get github.com/mattn/goveralls
-
-# Cache go modules and build cache.
-COPY . /tmp/websocket
-RUN cd /tmp/websocket && \
-  CI= make && \
-  rm -rf /tmp/websocket
-
-# GitHub actions tries to override HOME to /github/home and then
-# mounts a temp directory into there. We do not want this behaviour.
-# I assume it is so that $HOME is preserved between steps in a job.
-ENTRYPOINT ["env", "HOME=/root"]
diff --git a/conn_test.go b/conn_test.go
index 992c886199cf63a1f82ab02e5494e8d5bc98d495..1014dbf3d6bbad362e9cc797e71dcedf5b15bfc9 100644
--- a/conn_test.go
+++ b/conn_test.go
@@ -17,6 +17,41 @@ import (
 	"nhooyr.io/websocket"
 )
 
+func TestConn(t *testing.T) {
+	t.Parallel()
+
+	t.Run("json", func(t *testing.T) {
+		s, closeFn := testServer(t, func(w http.ResponseWriter, r *http.Request) {
+			c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
+				Subprotocols:       []string{"echo"},
+				InsecureSkipVerify: true,
+			})
+			assert.Success(t, err)
+			defer c.Close(websocket.StatusInternalError, "")
+
+			err = echoLoop(r.Context(), c)
+			assertCloseStatus(t, websocket.StatusNormalClosure, err)
+		}, false)
+		defer closeFn()
+
+		wsURL := strings.Replace(s.URL, "http", "ws", 1)
+
+		ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+		defer cancel()
+
+		opts := &websocket.DialOptions{
+			Subprotocols: []string{"echo"},
+		}
+		opts.HTTPClient = s.Client()
+
+		c, _, err := websocket.Dial(ctx, wsURL, opts)
+		assert.Success(t, err)
+
+		assertJSONEcho(t, ctx, c, 2)
+	})
+}
+
+
 func testServer(tb testing.TB, fn func(w http.ResponseWriter, r *http.Request), tls bool) (s *httptest.Server, closeFn func()) {
 	h := http.HandlerFunc(fn)
 	if tls {
@@ -108,37 +143,3 @@ func echoLoop(ctx context.Context, c *websocket.Conn) error {
 		}
 	}
 }
-
-func TestConn(t *testing.T) {
-	t.Parallel()
-
-	t.Run("json", func(t *testing.T) {
-		s, closeFn := testServer(t, func(w http.ResponseWriter, r *http.Request) {
-			c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
-				Subprotocols:       []string{"echo"},
-				InsecureSkipVerify: true,
-			})
-			assert.Success(t, err)
-			defer c.Close(websocket.StatusInternalError, "")
-
-			err = echoLoop(r.Context(), c)
-			assertCloseStatus(t, websocket.StatusNormalClosure, err)
-		}, false)
-		defer closeFn()
-
-		wsURL := strings.Replace(s.URL, "http", "ws", 1)
-
-		ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
-		defer cancel()
-
-		opts := &websocket.DialOptions{
-			Subprotocols: []string{"echo"},
-		}
-		opts.HTTPClient = s.Client()
-
-		c, _, err := websocket.Dial(ctx, wsURL, opts)
-		assert.Success(t, err)
-
-		assertJSONEcho(t, ctx, c, 2)
-	})
-}