diff --git a/Procfile b/Procfile
deleted file mode 100644
index 4d0c0cf..0000000
--- a/Procfile
+++ /dev/null
@@ -1 +0,0 @@
-web: gobyexample
diff --git a/README.md b/README.md
index b45f827..e8e5480 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,21 @@
 ## Go by Example
 
-Content, toolchain, and web server for [Go by Example](https://gobyexample.com).
+Content and build toolchain for [Go by Example](https://gobyexample.com),
+site that teaches Go via annotated example programs.
 
 
 ### Overview
 
-The Go by Example site is built by extracting code &
+The Go by Example site is built by extracting code and
 comments from source files in `examples` and rendering
-that data via the site `templates`. The programs
-implementing this build process are in `tools`.
+them via the `templates` into a static `public`
+directory. The programs implementing this build process
+are in `tools`, along with some vendor'd dependencies
+in `vendor`.
 
-The build process produces a directory of static files -
-`public` - suitable for serving by any modern HTTP server.
-We include a lightweight Go server in `server.go`.
+The built `public` directory can be served by any
+static content system. The production site uses S3 and
+CloudFront, for example.
 
 
 ### Building
@@ -32,56 +35,6 @@ $ tools/build-loop
 ```
 
 
-### Local Deploy
-
-To run and view the site locally:
-
-```bash
-$ mkdir -p $GOPATH/src/github.com/mmcgrana
-$ cd $GOPATH/src/github.com/mmcgrana
-$ git clone git@github.com:mmcgrana/gobyexample.git
-$ cd gobyexample
-$ go get
-$ PORT=5000 CANONICAL_HOST=127.0.0.1 FORCE_HTTPS=0 gobyexample
-$ open http://127.0.0.1:5000/
-```
-
-
-### Heroku Deploy
-
-To setup the site on Heroku:
-
-```bash
-$ export DEPLOY=$USER
-$ export APP=gobyexample-$USER
-$ heroku create $APP -r $DEPLOY
-$ heroku config:add -a $APP
-    BUILDPACK_URL=https://github.com/mmcgrana/buildpack-go.git
-    CANONICAL_HOST=$APP.herokuapp.com \
-    FORCE_HTTPS=1 \
-    AUTH=go:byexample
-$ heroku labs:enable dot-profile-d -a $APP
-$ git push $DEPLOY master
-$ heroku open -a $APP
-```
-
-Add a domain + SSL:
-
-```bash
-$ heroku domains:add $DOMAIN
-$ heroku addons:add ssl -r $DEPLOY
-# order ssl cert for domain
-$ cat > /tmp/server.key
-$ cat > /tmp/server.crt.orig
-$ curl https://knowledge.rapidssl.com/library/VERISIGN/ALL_OTHER/RapidSSL%20Intermediate/RapidSSL_CA_bundle.pem > /tmp/rapidssl_bundle.pem
-$ cat /tmp/server.crt.orig /tmp/rapidssl_bundle.pem > /tmp/server.crt
-$ heroku certs:add /tmp/server.crt /tmp/server.key -r $DEPLOY
-# add ALIAS record from domain to ssl endpoint dns
-$ heroku config:add CANONICAL_HOST=$DOMAIN -r $DEPLOY
-$ heroku open -r $DEPLOY
-```
-
-
 ### License
 
 This work is copyright Mark McGranaghan and licensed under a
diff --git a/server.go b/server.go
deleted file mode 100644
index fc420c8..0000000
--- a/server.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package main
-
-import (
-	"encoding/base64"
-	"fmt"
-	"github.com/gorilla/mux"
-	"io/ioutil"
-	"net"
-	"net/http"
-	"os"
-	"os/signal"
-	"strings"
-	"sync/atomic"
-	"syscall"
-	"time"
-)
-
-func check(err error) {
-    if err != nil {
-        panic(err)
-    }
-}
-
-func config(k string) string {
-	v := os.Getenv(k)
-	if v == "" {
-		panic("missing " + k)
-	}
-	return v
-}
-
-func runLogging(logs chan string) {
-	for log := range logs {
-		fmt.Println(log)
-	}
-}
-
-func wrapLogging(f http.HandlerFunc, logs chan string) http.HandlerFunc {
-	return func(res http.ResponseWriter, req *http.Request) {
-		start := time.Now()
-		f(res, req)
-		method := req.Method
-		path := req.URL.Path
-		elapsed := float64(time.Since(start)) / 1000000.0
-		logs <- fmt.Sprintf("request at=finish method=%s path=%s elapsed=%f", method, path, elapsed)
-	}
-}
-
-func wrapCanonicalHost(f http.HandlerFunc, canonicalHost string, forceHttps bool) http.HandlerFunc {
-	return func(res http.ResponseWriter, req *http.Request) {
-		scheme := "http"
-		if h, ok := req.Header["X-Forwarded-Proto"]; ok {
-			if h[0] == "https" {
-				scheme = "https"
-			}
-		}
-
-		hostPort := strings.Split(req.Host, ":")
-		host := hostPort[0]
-
-		if (forceHttps && (scheme != "https")) || host != canonicalHost {
-			if forceHttps {
-				scheme = "https"
-			}
-			hostPort[0] = canonicalHost
-			url := scheme + "://" + strings.Join(hostPort, ":") + req.URL.String()
-			http.Redirect(res, req, url, 301)
-			return
-		}
-
-		f(res, req)
-	}
-}
-
-type Authenticator func(string, string) bool
-
-func testAuth(r *http.Request, auth Authenticator) bool {
-	s := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
-	if len(s) != 2 || s[0] != "Basic" {
-		return false
-	}
-	b, err := base64.StdEncoding.DecodeString(s[1])
-	if err != nil {
-		return false
-	}
-	pair := strings.SplitN(string(b), ":", 2)
-	if len(pair) != 2 {
-		return false
-	}
-	return auth(pair[0], pair[1])
-}
-
-func requireAuth(w http.ResponseWriter, r *http.Request) {
-	w.Header().Set("WWW-Authenticate", `Basic realm="private"`)
-	w.WriteHeader(401)
-	w.Write([]byte("401 Unauthorized\n"))
-}
-
-func wrapAuth(h http.HandlerFunc, a Authenticator) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if testAuth(r, a) {
-			h(w, r)
-		} else {
-			requireAuth(w, r)
-		}
-	}
-}
-
-var reqCount int64 = 0
-
-func wrapReqCount(h http.HandlerFunc, reqCountPtr *int64) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		atomic.AddInt64(reqCountPtr, 1)
-		h(w, r)
-		atomic.AddInt64(reqCountPtr, -1)
-	}
-}
-
-func static(res http.ResponseWriter, req *http.Request) {
-	http.ServeFile(res, req, "public"+req.URL.Path)
-}
-
-func notFound(res http.ResponseWriter, req *http.Request) {
-	http.ServeFile(res, req, "public/404.html")
-}
-
-func checkAuth(user, pass string) bool {
-	auth := os.Getenv("AUTH")
-	if auth == "" {
-		return true
-	}
-	return auth == strings.Join([]string{user, pass}, ":")
-}
-
-func routerHandlerFunc(router *mux.Router) http.HandlerFunc {
-	return func(res http.ResponseWriter, req *http.Request) {
-		router.ServeHTTP(res, req)
-	}
-}
-
-func router() *mux.Router {
-	router := mux.NewRouter()
-	router.HandleFunc("/", static).Methods("GET")
-	router.HandleFunc("/favicon.ico", static).Methods("GET")
-	router.HandleFunc("/play.png", static).Methods("GET")
-	router.HandleFunc("/site.css", static).Methods("GET")
-	entries, err := ioutil.ReadDir("public")
-	check(err)
-	for _, f := range entries {
-		if !strings.Contains(f.Name(), ".") {
-			router.HandleFunc("/" + f.Name(), static).Methods("GET")
-		}
-	}
-	router.NotFoundHandler = http.HandlerFunc(notFound)
-	return router
-}
-
-func main() {
-	logs := make(chan string, 10000)
-	go runLogging(logs)
-
-	handler := routerHandlerFunc(router())
-	if os.Getenv("AUTH") != "" {
-		handler = wrapAuth(handler, checkAuth)
-	}
-	handler = wrapCanonicalHost(handler, config("CANONICAL_HOST"), config("FORCE_HTTPS") == "1")
-	handler = wrapLogging(handler, logs)
-	handler = wrapReqCount(handler, &reqCount)
-
-	server := &http.Server{Handler: handler}
-	listener, listenErr := net.Listen("tcp", ":"+config("PORT"))
-	if listenErr != nil {
-		panic(listenErr)
-	}
-
-	stop := make(chan bool, 1)
-	sig := make(chan os.Signal, 1)
-	go func() {
-		signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
-		logs <- "trap at=start"
-		<-sig
-		for {
-			reqCountCurrent := atomic.LoadInt64(&reqCount)
-			if reqCountCurrent > 0 {
-				logs <- fmt.Sprintf("trap at=draining remaining=%d", reqCountCurrent)
-				time.Sleep(time.Second)
-			} else {
-				logs <- fmt.Sprintf("trap at=finish")
-				stop <- true
-				return
-			}
-		}
-	}()
-
-	go func() {
-		logs <- "serve at=start"
-		server.Serve(listener)
-		logs <- "serve at=finish"
-	}()
-
-	<-stop
-	logs <- "close at=start"
-	closeErr := listener.Close()
-	if closeErr != nil {
-		panic(closeErr)
-	}
-	logs <- "close at=finish"
-}
diff --git a/tools/upload-site b/tools/upload-site
new file mode 100755
index 0000000..b1eda68
--- /dev/null
+++ b/tools/upload-site
@@ -0,0 +1,42 @@
+#!/usr/bin/env ruby
+
+# Upload the contents in public/ to the S3 bucket from which we serve
+# gobyexample.com. We use this instead of `aws iam sync` because that command
+# doesn't correctly guess the text/html mime time of the extension-less files.
+# We didn't write this in Go because we had already written it in Ruby for
+# another website and didn't want to re-write it.
+
+require "aws-sdk"
+require "set"
+
+s3 = Aws::S3::Client.new(
+  region: "us-east-1",
+  credentials: Aws::Credentials.new(ENV["AWS_ACCESS_KEY_ID"], ENV["AWS_SECRET_ACCESS_KEY"])
+)
+
+# (Re-)upload each file to S3. We're not worried about what's currently there.
+Dir.glob("./public/**/**").each do |local_path|
+  next if File.directory?(local_path)
+
+  # Derive final path.
+  s3_path = local_path.sub("./public/", "")
+
+  # Infer content type, including for HTML files that need pretty URLs.
+  content_type =
+    case s3_path
+    when /\.ico$/ then "image/x-icon"
+    when /\.png$/ then "image/png"
+    when /\.css$/ then "text/css"
+    else "text/html"
+    end
+ 
+  puts("Uploading #{s3_path} (#{content_type})")
+
+  File.open(local_path, "rb") do |local_file|
+    s3.put_object(
+      bucket: "gobyexample.com",
+      key: s3_path,
+      content_type: content_type,
+      body: local_file)
+  end
+end