diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 0209153a5f182f2d82e1923be88494c531359575..0000000000000000000000000000000000000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-version: 2
-jobs:
-  fmt:
-    docker:
-      - image: nhooyr/websocket-ci@sha256:77e37211ded3c528e947439e294fbfc03b4fb9f9537c4e5198d5b304fd1df435
-    steps:
-      - checkout
-      - restore_cache:
-          keys:
-            - go-v4-{{ checksum "go.sum" }}
-            # Fallback to using the latest cache if no exact match is found.
-            - go-v4-
-      - run: ./ci/fmt.sh
-      - save_cache:
-          paths:
-            - /root/gopath
-            - /root/.cache/go-build
-          key: go-v4-{{ checksum "go.sum" }}
-
-  lint:
-    docker:
-      - image: nhooyr/websocket-ci@sha256:77e37211ded3c528e947439e294fbfc03b4fb9f9537c4e5198d5b304fd1df435
-    steps:
-      - checkout
-      - restore_cache:
-          keys:
-            - go-v4-{{ checksum "go.sum" }}
-            # Fallback to using the latest cache if no exact match is found.
-            - go-v4-
-      - run: ./ci/lint.sh
-      - save_cache:
-          paths:
-            - /root/gopath
-            - /root/.cache/go-build
-          key: go-v4-{{ checksum "go.sum" }}
-
-  test:
-    docker:
-      - image: nhooyr/websocket-ci@sha256:77e37211ded3c528e947439e294fbfc03b4fb9f9537c4e5198d5b304fd1df435
-    steps:
-      - checkout
-      - restore_cache:
-          keys:
-            - go-v4-{{ checksum "go.sum" }}
-            # Fallback to using the latest cache if no exact match is found.
-            - go-v4-
-      - run: ./ci/test.sh
-      - store_artifacts:
-          path: ci/out
-          destination: out
-      - save_cache:
-          paths:
-            - /root/gopath
-            - /root/.cache/go-build
-          key: go-v4-{{ checksum "go.sum" }}
-      - store_test_results:
-          path: ci/out
-
-workflows:
-  version: 2
-  fmt:
-    jobs:
-      - fmt
-  lint:
-    jobs:
-      - lint
-  test:
-    jobs:
-      - test
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8e63eb17437587fd110c8d47420f96a607fd9fe3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,30 @@
+name: ci
+on: [push]
+
+jobs:
+  fmt:
+    runs-on: ubuntu-latest
+    container: docker://nhooyr/websocket-ci@sha256:549cc2716fd1ff08608b39a52af95a67bf9f490f6f31933cccd94e750985e2dc
+    steps:
+      - uses: actions/checkout@v1
+        with:
+          fetch-depth: 1
+      - run: ./ci/fmt.sh
+  lint:
+    runs-on: ubuntu-latest
+    container: docker://nhooyr/websocket-ci@sha256:549cc2716fd1ff08608b39a52af95a67bf9f490f6f31933cccd94e750985e2dc
+    steps:
+      - uses: actions/checkout@v1
+        with:
+          fetch-depth: 1
+      - run: ./ci/lint.sh
+  test:
+    runs-on: ubuntu-latest
+    container: docker://nhooyr/websocket-ci@sha256:549cc2716fd1ff08608b39a52af95a67bf9f490f6f31933cccd94e750985e2dc
+    steps:
+      - uses: actions/checkout@v1
+        with:
+          fetch-depth: 1
+      - run: ./ci/test.sh
+        env:
+          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/README.md b/README.md
index 782251beac926d377fce92c3b3fcf3e8131119ee..f25dc79ecaf619d51084b5e6a2bacfcf288b76a4 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
 [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/nhooyr/websocket?color=6b9ded&sort=semver)](https://github.com/nhooyr/websocket/releases)
 [![GoDoc](https://godoc.org/nhooyr.io/websocket?status.svg)](https://godoc.org/nhooyr.io/websocket)
 [![Codecov](https://img.shields.io/codecov/c/github/nhooyr/websocket.svg?color=65d6a4)](https://codecov.io/gh/nhooyr/websocket)
-[![CI](https://img.shields.io/circleci/build/github/nhooyr/websocket?label=ci&color=brightgreen)](https://github.com/nhooyr/websocket/commits/master)
+[![Actions Status](https://github.com/nhooyr/websocket/workflows/ci/badge.svg)](https://github.com/nhooyr/websocket/actions)
 
 websocket is a minimal and idiomatic WebSocket library for Go.
 
diff --git a/ci/image/Dockerfile b/ci/image/Dockerfile
index 4477d6468f25e212a0bbe152f526793c14f2dfe5..b59bc3af55dc1a5404870076fd548a55fa151710 100644
--- a/ci/image/Dockerfile
+++ b/ci/image/Dockerfile
@@ -4,9 +4,16 @@ ENV DEBIAN_FRONTEND=noninteractive
 ENV GOPATH=/root/gopath
 ENV GOFLAGS="-mod=readonly"
 ENV PAGER=cat
+ENV CI=true
 
 RUN apt-get update && \
   apt-get install -y shellcheck npm && \
   npm install -g prettier
 
 RUN git config --global color.ui always
+
+# Cache go modules.
+COPY . /tmp/websocket
+RUN cd /tmp/websocket && \
+  go mod download && \
+  rm -rf /tmp/websocket
diff --git a/ci/run.sh b/ci/run.sh
index 56da2d9372905c9b626ccd52f102af78d50e362f..8867b860e1525c20989b61685255d4d74b854b14 100755
--- a/ci/run.sh
+++ b/ci/run.sh
@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 
-# This script is for local testing. See .circleci for CI.
+# This script is for local testing. See .github/workflows/ci.yml for CI.
 
 set -euo pipefail
 cd "$(dirname "${0}")"
diff --git a/ci/test.sh b/ci/test.sh
index fca42ff02034d4efb9429aa8c7d35fcb2dfff1f9..83564bab9942fb7f1ac8b682c8062a8961c54a59 100755
--- a/ci/test.sh
+++ b/ci/test.sh
@@ -6,8 +6,6 @@ cd "$(git rev-parse --show-toplevel)"
 
 argv=(
   go run gotest.tools/gotestsum
-  # https://circleci.com/docs/2.0/collect-test-data/
-  "--junitfile=ci/out/websocket/testReport.xml"
   "--format=short-verbose"
   --
   "-vet=off"
@@ -33,5 +31,5 @@ mv ci/out/coverage2.prof ci/out/coverage.prof
 
 go tool cover -html=ci/out/coverage.prof -o=ci/out/coverage.html
 if [[ ${CI:-} ]]; then
-  bash <(curl -s https://codecov.io/bash) -R . -f ci/out/coverage.prof
+  bash <(curl -s https://codecov.io/bash) -Z -R . -f ci/out/coverage.prof
 fi