diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000000000000000000000000000000000000..ee33faf47a114875e3b6fd893597df992c42052b
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,6 @@
+{
+  "printWidth": 100,
+  "tabWidth": 2,
+  "singleQuote": true,
+  "bracketSameLine": true
+}
diff --git a/assets/icon-1024.avif b/assets/icon-1024.avif
new file mode 100644
index 0000000000000000000000000000000000000000..31a20b087a5e66cf844b99736cc4ef986d7e85a9
Binary files /dev/null and b/assets/icon-1024.avif differ
diff --git a/requirements.txt b/requirements.txt
index 3b1c8ac02782d1d3d938d7e6245906cc4dbb0bb9..1c3ed3898ed9fb6955fd8d9309854fa5facbe551 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
 adafruit_debouncer==2.0.5
-adafruit_ticks==1.0.9
 adafruit_logging==5.2.1
+adafruit_ticks==1.0.9
+adafruit_httpserver==2.3.0
 adafruit_minimqtt==7.2.2
diff --git a/scripts/certificates b/scripts/certificates
new file mode 100755
index 0000000000000000000000000000000000000000..467785dd2d182fe75282a4b684270cccdeca3201
--- /dev/null
+++ b/scripts/certificates
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Derived from https://www.linode.com/docs/guides/create-a-self-signed-tls-certificate/
+openssl req -new -newkey rsa:1024 -x509 -sha256 -days 36525 -nodes \
+  -subj '/CN=garagesensor.local' \
+  -out "$script_directory/../src/certificates/certificate-chain.pem" \
+  -keyout "$script_directory/../src/certificates/key.pem"
diff --git a/src/boot.py b/src/boot.py
index ca15b5a206919979329a4004872f48cf08c3e3de..0287c1203cacdad2af47f8e1ea5f1b21011d7bc6 100644
--- a/src/boot.py
+++ b/src/boot.py
@@ -1,8 +1,5 @@
-import os
-
 import storage
 
-if os.getenv("LOG_FILE"):
-  # Allow CircuitPython to write to its flash storage
-  # https://learn.adafruit.com/circuitpython-essentials/circuitpython-storage
-  storage.remount("/", readonly=False, disable_concurrent_write_protection=True)
+# Allow CircuitPython to write to its flash storage
+# https://learn.adafruit.com/circuitpython-essentials/circuitpython-storage
+storage.remount("/", readonly=False, disable_concurrent_write_protection=True)
diff --git a/src/certificates/certificate-chain.pem b/src/certificates/certificate-chain.pem
new file mode 100644
index 0000000000000000000000000000000000000000..c8f077cc379aa11afcd110f24eccf071be8e2094
--- /dev/null
+++ b/src/certificates/certificate-chain.pem
@@ -0,0 +1,12 @@
+-----BEGIN CERTIFICATE-----
+MIIBszCCARwCCQCj+re8WZ4+aDANBgkqhkiG9w0BAQsFADAdMRswGQYDVQQDDBJn
+YXJhZ2VzZW5zb3IubG9jYWwwIBcNMjMwMzA1MTk0ODIxWhgPMjEyMzAzMDYxOTQ4
+MjFaMB0xGzAZBgNVBAMMEmdhcmFnZXNlbnNvci5sb2NhbDCBnzANBgkqhkiG9w0B
+AQEFAAOBjQAwgYkCgYEAxSb4Ao/A6wxE/a4w9YOkHY+mzmnO9naqE/DDasJYQO5+
+MgLnpDgOcStQojfTJHHu8V2o0TqSadYZuY75c6A1oji0kG5CcpYOyhtpKdKjvEQ1
+3WPt+vBOCU3mmL1W7pS1RiNy3f/g3xC7qPEvW6nZZonafCO5f9edupNsBfbgSDUC
+AwEAATANBgkqhkiG9w0BAQsFAAOBgQAP51g4jkm2YQ3ADmNesKewHK2vSw248PS3
+SNAaxaj0n+6PEUZO/COOD5Of6yHZVDa4DAxKdGUdL/YIBkf8mceLfzNFbW8Le+t5
+OQ/YNFMXLobDf7HRkVzIfVXAwtoJqUY/DSEI0cMfpHIcNC4ThYgdreiANn2f1WDK
+YDe8O9X5UA==
+-----END CERTIFICATE-----
diff --git a/src/certificates/key.pem b/src/certificates/key.pem
new file mode 100644
index 0000000000000000000000000000000000000000..e88c97207f424f460b36f3b99cfa0ff534feefd1
--- /dev/null
+++ b/src/certificates/key.pem
@@ -0,0 +1,16 @@
+-----BEGIN PRIVATE KEY-----
+MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAMUm+AKPwOsMRP2u
+MPWDpB2Pps5pzvZ2qhPww2rCWEDufjIC56Q4DnErUKI30yRx7vFdqNE6kmnWGbmO
++XOgNaI4tJBuQnKWDsobaSnSo7xENd1j7frwTglN5pi9Vu6UtUYjct3/4N8Qu6jx
+L1up2WaJ2nwjuX/XnbqTbAX24Eg1AgMBAAECgYAiN0cnuqcyo+h9VnPsyDH9Z2b9
+v+NJZwLRfyGLL7t9WWbRayuklo37Ghdeb+3XD2b2wNiBp3ato5jHWYb1iEKGXPGg
+biF4gYAasjB3HiauOeG3UNLOM27+yI04K6XQZSV3s6CrM+C1ILWGHU0S7LQxjeBA
+hsMdH18jVjUP1RS6QQJBAOI8pJ4x5fCp7KbnBY6lwBMn5Q2KxAqQHqCL6brrgeqh
+Bzp1fR/OnzsXcn6Cb5BMs4cMWxeV3DyfBTFCwO51wu0CQQDfFssAl84TcRrNyriq
+fuZBth5z9auGmS38FdbrDqXPzgDa2MM4e4/LUYBvF7FQtneiekHXKis5fJlxoytT
+fQlpAkAwSKcNiDK9+VYjjNy3xBJJRFNzX3FVm8qdkx7QIOE6VSG4zUhmGHANaYSr
+EWWEE4qhQPbUAszdN0cha1DH0+RFAkAKz6f212R9PLX30yMv4AZ4mMLRC87MLxAz
+bzuDGKqgb3NLJ8YOLq7BQ6nduGA3cSBLF3GpY7nEh21IPIgU+7JBAkBjyMupNQHW
+HHll7Fa4YtBfEXK2sIB/wvdtroT05s2YM1F2XUwLfLyepSSniyLMVnd+gOZwr4c/
+KRbIBmqaBrh9
+-----END PRIVATE KEY-----
diff --git a/src/code.py b/src/code.py
index 7eeb815720ac730129effed3f0c32d447ca87017..d0e125a1eb5b236e86a7bed90059e2a442d5998b 100644
--- a/src/code.py
+++ b/src/code.py
@@ -1,5 +1,6 @@
 import binascii
 import os
+import ssl
 import time
 import traceback
 
@@ -13,9 +14,15 @@ import wifi
 import adafruit_debouncer
 import adafruit_logging
 import adafruit_minimqtt.adafruit_minimqtt as adafruit_minimqtt
+from adafruit_httpserver.methods import HTTPMethod
+from adafruit_httpserver.mime_type import MIMEType
+from adafruit_httpserver.server import HTTPServer
+from adafruit_httpserver.request import HTTPRequest
+from adafruit_httpserver.response import HTTPResponse
 
 from logging import create_logger
 from mqtt import publish_homeassistant_discovery_message, publish_sensor_state_message
+from tls import SSLServerSocketPool
 
 
 def main(logger: adafruit_logging.Logger) -> None:
@@ -27,6 +34,9 @@ def main(logger: adafruit_logging.Logger) -> None:
     switch_io.switch_to_input()
     switch = adafruit_debouncer.Debouncer(switch_io)
 
+    # The switch is open when the door is closed
+    led.value = not switch.value
+
     # Connect to the Wi-Fi network
     logger.info("Connecting to the local Wi-Fi network...")
     wifi.radio.hostname = os.getenv("WIFI_HOSTNAME")
@@ -42,31 +52,59 @@ def main(logger: adafruit_logging.Logger) -> None:
     pool = socketpool.SocketPool(wifi.radio)
 
     # Connect to the MQTT broker
-    logger.info("Connecting to the MQTT broker...")
-    mqtt = adafruit_minimqtt.MQTT(
-        broker=os.getenv("MQTT_HOSTNAME"),
-        username=os.getenv("MQTT_USERNAME"),
-        password=os.getenv("MQTT_PASSWORD"),
-        is_ssl=False,
-        socket_pool=pool,
-    )
-    mqtt.logger = logger
-    mqtt.connect()
-    logger.info("Connected to the MQTT broker")
+    if os.getenv("MQTT_ENABLED"):
+        logger.info("Connecting to the MQTT broker...")
+        mqtt = adafruit_minimqtt.MQTT(
+            broker=os.getenv("MQTT_HOSTNAME"),
+            username=os.getenv("MQTT_USERNAME"),
+            password=os.getenv("MQTT_PASSWORD"),
+            is_ssl=False,
+            socket_pool=pool,
+        )
+        mqtt.logger = logger
+        mqtt.connect()
+        logger.info("Connected to the MQTT broker")
 
-    mqtt_device_id = binascii.hexlify(microcontroller.cpu.uid).decode("utf-8")
-    mqtt_state_topic = f"door/{mqtt_device_id}/state"
-    publish_homeassistant_discovery_message(mqtt, mqtt_device_id, mqtt_state_topic)
+        mqtt_device_id = binascii.hexlify(microcontroller.cpu.uid).decode("utf-8")
+        mqtt_state_topic = f"door/{mqtt_device_id}/state"
+        publish_homeassistant_discovery_message(mqtt, mqtt_device_id, mqtt_state_topic)
+        publish_sensor_state_message(mqtt, mqtt_state_topic, not switch.value)
+        logger.info(
+            "Advertised Home Assistant discovery message and current door state"
+        )
+    else:
+        mqtt = None
 
-    # The switch is open when the door is closed
-    led.value = not switch.value
-    publish_sensor_state_message(mqtt, mqtt_state_topic, not switch.value)
-    logger.info("Advertised Home Assistant discovery message and current door state")
+    # Listen to HTTP requests and serve static files
+    ssl_context = ssl.create_default_context()
+    # The Pico is the server and does not require a certificate from the client, so disable
+    # certificate validation by explicitly specifying no verification CAs
+    ssl_context.load_verify_locations(cadata="")
+    ssl_context.load_cert_chain(
+        "certificates/certificate-chain.pem", "certificates/key.pem"
+    )
+    ssl_pool = SSLServerSocketPool(pool, ssl_context)
+
+    server = HTTPServer(ssl_pool)
+    host = str(wifi.radio.ipv4_address)
+    server.start(host, port=443, root_path="public_html")
+    logger.info(f"Listening to HTTP requests at https://{host}")
+    logger.info(f"Serving the website from https://{wifi.radio.hostname}.local")
 
     logger.info("Monitoring door sensor...")
     while True:
         switch.update()
-        mqtt.loop()
+
+        if mqtt is not None:
+            mqtt.loop()
+
+        try:
+            server.poll()
+        except OSError as error:
+            if error.strerror.startswith("MBEDTLS_ERR_"):
+                logger.info("TLS library error %s with code %d", error.strerror, error.errno)
+            else:
+                raise
 
         if switch.rose or switch.fell:
             is_door_open = not switch.value
@@ -74,7 +112,8 @@ def main(logger: adafruit_logging.Logger) -> None:
                 "Detected the door open" if is_door_open else "Detected the door closed"
             )
             led.value = is_door_open
-            publish_sensor_state_message(mqtt, mqtt_state_topic, is_door_open)
+            if mqtt is not None:
+                publish_sensor_state_message(mqtt, mqtt_state_topic, is_door_open)
 
 
 logger = create_logger()
diff --git a/src/public_html/icon-192.avif b/src/public_html/icon-192.avif
new file mode 100644
index 0000000000000000000000000000000000000000..9bdcee6c84d1f5dcde9b393798fa046036900f46
Binary files /dev/null and b/src/public_html/icon-192.avif differ
diff --git a/src/public_html/icon-512.avif b/src/public_html/icon-512.avif
new file mode 100644
index 0000000000000000000000000000000000000000..f82377c1e8c5437984d9c102b45f3a5fd26322e7
Binary files /dev/null and b/src/public_html/icon-512.avif differ
diff --git a/src/public_html/index.html b/src/public_html/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..cb5afa718881d0d3de9a23bc13111d688a24cf1c
--- /dev/null
+++ b/src/public_html/index.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en-US">
+  <head>
+    <title>Garage Door Sensor</title>
+    <meta name="viewport" content="width=device-width" />
+    <link rel="manifest" href="/manifest.webmanifest" />
+    <link rel="icon" type="image/avif" href="/icon-192.avif" />
+    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter" />
+    <link rel="stylesheet" href="/style.css" />
+    <script type="module" src="/script.js"></script>
+  </head>
+  <body>
+    <h1>My Garage</h1>
+    <button id="subscribe-button">Get Notified</button>
+  </body>
+</html>
diff --git a/src/public_html/manifest.webmanifest b/src/public_html/manifest.webmanifest
new file mode 100644
index 0000000000000000000000000000000000000000..02e946d81fca657b74e0859f9dc1136ce141e7ad
--- /dev/null
+++ b/src/public_html/manifest.webmanifest
@@ -0,0 +1,21 @@
+{
+  "$schema": "https://json.schemastore.org/web-manifest-combined.json",
+  "name": "Garage Door Sensor",
+  "short_name": "Garage",
+  "start_url": "/",
+  "display": "fullscreen",
+  "background_color": "#252526",
+  "description": "Check on whether your garage door is open",
+  "icons": [
+    {
+      "src": "icon-192.avif",
+      "sizes": "192x192",
+      "type": "image/avif"
+    },
+    {
+      "src": "icon-512.avif",
+      "sizes": "512x512",
+      "type": "image/avif"
+    }
+  ]
+}
diff --git a/src/public_html/script.js b/src/public_html/script.js
new file mode 100644
index 0000000000000000000000000000000000000000..c9528ffec56e9541058fad1714d860dc1e253880
--- /dev/null
+++ b/src/public_html/script.js
@@ -0,0 +1,16 @@
+// Keep the service worker up to date
+navigator.serviceWorker.register('/service-worker.js', { type: 'module' }).then((registration) => {
+  registration.update();
+});
+
+// Subscribe to notifications when the user wishes to do so
+const subscribeButton = document.getElementById('subscribe-button');
+subscribeButton.addEventListener('click', async () => {
+  const serviceWorkerRegistration = await navigator.serviceWorker.ready;
+  const pushSubscription = await serviceWorkerRegistration.pushManager.subscribe({
+    userVisibleOnly: true,
+    applicationServerKey:
+      'BP1ZvYiUs2YDFtbJu2weWZdBS4DFA0kKw3pFK6iMVV7zue-fmg_gJM8lEshHkbJO5KkiO8wYh15Xn8y0BZNpRCs',
+  });
+  console.log(pushSubscription.toJSON());
+});
diff --git a/src/public_html/service-worker.js b/src/public_html/service-worker.js
new file mode 100644
index 0000000000000000000000000000000000000000..b1e2819cad3bd5f593af46ea2f27fc577aa2645d
--- /dev/null
+++ b/src/public_html/service-worker.js
@@ -0,0 +1,22 @@
+// Keep the service worker up to date by letting the most recent script become the active service
+// worker even if another is registered. This is acceptable since there is no API contract that
+// could break between the web page and this service worker in the event a newer version of the
+// service worker becomes active while the user is viewing an older version of the web page.
+self.addEventListener('install', (event) => {
+  event.waitUntil(self.skipWaiting());
+});
+
+self.addEventListener('push', (event) => {
+  const message = event.data.json();
+  console.log(message);
+  event.waitUntil(
+    self.registration.showNotification(message.title, {
+      body: message.body,
+      icon: '/icon-192.avif',
+      tag: message.tag,
+      timestamp: message.timestamp ? message.timestamp * 1000 : undefined,
+    })
+  );
+});
+
+self.addEventListener('pushsubscriptionchange', (event) => {});
diff --git a/src/public_html/style.css b/src/public_html/style.css
new file mode 100644
index 0000000000000000000000000000000000000000..c867eeda9336e865074622417f14592245726007
--- /dev/null
+++ b/src/public_html/style.css
@@ -0,0 +1,6 @@
+html {
+  background: #252526;
+  color: #fff;
+  font-family: 'Inter', sans-serif;
+}
+
diff --git a/src/tls.py b/src/tls.py
new file mode 100644
index 0000000000000000000000000000000000000000..c0298ae4dea1a73b674c81e075c7c12ee86230d4
--- /dev/null
+++ b/src/tls.py
@@ -0,0 +1,48 @@
+import ssl
+
+import socketpool
+
+
+class SSLServerSocketPool:
+    def __init__(self, pool: socketpool.SocketPool, ssl_context: ssl.SSLContext):
+        self._pool = pool
+        self._ssl_context = ssl_context
+
+    @property
+    def AF_INET(self) -> int:
+        return self._pool.AF_INET
+
+    @property
+    def AF_INET6(self) -> int:
+        return self._pool.AF_INET6
+
+    @property
+    def SOCK_STREAM(self) -> int:
+        return self._pool.SOCK_STREAM
+
+    @property
+    def SOCK_DGRAM(self) -> int:
+        return self._pool.SOCK_DGRAM
+
+    @property
+    def SOCK_RAW(self) -> int:
+        return self._pool.SOCK_RAW
+
+    @property
+    def EAI_NONAME(self) -> int:
+        return self._pool.EAI_NONAME
+
+    @property
+    def TCP_NODELAY(self) -> int:
+        return self._pool.TCP_NODELAY
+
+    @property
+    def IPPROTO_TCP(self) -> int:
+        return self._pool.IPPROTO_TCP
+
+    def socket(self, family: int = None, type: int = None) -> ssl.SSLSocket:
+        socket = self._pool.socket(family, type)
+        return self._ssl_context.wrap_socket(socket, server_side=True)
+
+    def getaddrinfo(self, *args, **kwargs):
+        return self._pool.getaddrinfo(*args, **kwargs)