ESPHome is usually paired with Home Assistant, but nothing in the firmware requires it. A device can serve its own web page, answer HTTP requests from scripts and recover from a lost Wi-Fi network by opening a hotspot of its own. That is useful for a workshop relay, a sensor in a holiday cottage or a test board that should work before any server exists.

This guide builds such a device with three ESPHome components: web_server, captive_portal and the Wi-Fi fallback access point. It also covers the security settings that matter when nothing else stands between the device and the network. All option names and defaults below come from the ESPHome documentation for release 2026.10.

What you get

  • A web interface at the device's IP address or at <node_name>.local/ (with the trailing slash) through mDNS. It lists entities, lets you switch them, shows live logs and draws a small graph when you click a sensor.
  • A REST API on the same port: a GET request to /<domain>/<entity_name> returns the state as JSON, and a POST request to /<domain>/<entity_name>/<action> changes it.
  • An event stream at /events (Server-Sent Events) with state, log and ping events, which is what the web page itself uses.
  • A fallback hotspot: when the device cannot reach the configured router, it starts its own access point, and the captive portal at http://192.168.4.1/ lets you enter new Wi-Fi credentials or upload firmware.

According to the documentation, the web server is meant for viewing and changing states, not for editing the configuration. Changing the YAML still means compiling and uploading new firmware.

The configuration

The example is an ESP32 with one relay output and a Wi-Fi signal sensor. Replace GPIO26 with a free pin on your board, and drive the relay through a ready-made relay module rather than straight from the pin.

esphome:
  name: workshop-relay
  friendly_name: Workshop Relay

esp32:
  variant: esp32

logger:

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "Workshop-Relay-Fallback"
    password: !secret workshop_relay__ap_password

captive_portal:

web_server:
  port: 80
  version: 3
  local: true
  ota: false
  auth:
    username: admin
    password: !secret workshop_relay__web_password
    type: digest

ota:
  - platform: esphome
    encryption:
      key: !secret workshop_relay__ota_esphome_key

switch:
  - platform: gpio
    pin: GPIO26
    name: "Relay"

sensor:
  - platform: wifi_signal
    name: "WiFi Signal"
    update_interval: 60s

The matching secrets.yaml holds the Wi-Fi credentials, the hotspot password, the web password and the OTA key. Note what is missing: there is no api: block, because nothing will connect over the native API.

Why each option is there

version: 3 selects the newest web interface. The default is version 2. Version 1 is deprecated and, according to the documentation, will be removed in ESPHome 2027.1.0.

local: true builds the page's JavaScript and CSS into the firmware. Without it the browser downloads them from oi.esphome.io, so the page would not render on a network without internet access, which is exactly where a standalone device often ends up. The cost is more flash space.

auth with type: digest. The web server supports basic and digest. With basic the password travels in an easily reversible Base64 form on every request; digest sends only hashes, and the documentation recommends it. The current default is basic, and it changes to digest in ESPHome 2027.1.0. The ESPHome security guide is blunt about the alternative: without authentication, anyone on the local network can read the device and switch its outputs.

ota: false under web_server turns off firmware upload through the normal web page. The captive_portal component loads the web server's OTA platform automatically, and this option keeps the upload page available only in the captive portal, that is, only while the fallback hotspot is active. The web server documentation warns that web OTA without authentication lets anyone who can reach the device upload firmware.

ota with platform: esphome and encryption keeps network updates from the esphome command line tool working, encrypted. Normally the OTA block reuses the native API key, but this device has no api: block, so the OTA block needs its own key. According to the documentation, it is a 32-byte base64-encoded string; one way to generate it is:

openssl rand -base64 32

The ESPHome docs explain why encryption is preferred over the older password option: the firmware image contains your Wi-Fi credentials, and a password only authenticates the uploader without hiding the image. The two options cannot be combined. If the device already runs firmware without encryption, read the "Enabling Encryption on an Existing Device" section of the OTA documentation before adding the block, because the command line tool refuses to send an unencrypted image once encryption: is configured.

ap with a password. A fallback hotspot without a password is an open door: anyone nearby could connect while your router is down and reach the device. The security guide says to always set one, or to remove ap: entirely on devices that do not need it.

First upload and later updates

The first upload goes over USB, with the esphome command line tool installed:

esphome run workshop-relay.yaml

Choose the serial port when asked. Later runs of the same command offer the network address and use the encrypted OTA upload. To watch the logs over the network:

esphome logs workshop-relay.yaml

This works without the native API because the web server streams logs on /events while its log option is enabled, which is the default.

Using the REST API

With type: digest, every client has to request digest authentication. In curl that is the --digest flag; a client that only sends basic authentication is rejected.

Read the Wi-Fi signal (a space in an entity name becomes %20 in a URL):

curl --digest -u admin:YOUR_PASSWORD "http://workshop-relay.local/sensor/WiFi%20Signal"

The answer is a JSON object with id, a text state and a numeric value. Since ESPHome 2026.8.0 the id has the form domain/entity_name, here sensor/WiFi Signal. Scripts written for older firmware may expect the previous format.

Switch the relay:

curl --digest -u admin:YOUR_PASSWORD -X POST "http://workshop-relay.local/switch/Relay/turn_on"
curl --digest -u admin:YOUR_PASSWORD -X POST "http://workshop-relay.local/switch/Relay/toggle"

Switches accept turn_on, turn_off and toggle, and the server answers 200 OK on success. Lights accept extra URL parameters, for example /light/Desk/turn_on?brightness=128&transition=2. Add ?detail=all to a GET request for a more verbose answer.

To follow live changes from a terminal, keep a connection open to the event stream:

curl --digest -u admin:YOUR_PASSWORD -N "http://workshop-relay.local/events"

Browsers, other websites and cross-origin requests

Since ESPHome 2026.7.0 the web server rejects cross-origin browser requests to its state and control endpoints unless the origin is listed in allowed_origins. That stops a random website from driving the device through your browser. Requests without an Origin header, such as curl and scripts, are always allowed. The documentation calls this defense in depth rather than a security boundary, and notes that it does not cover the web OTA /update endpoint. Authentication and a trusted, separated network remain the real protection. If you build your own dashboard page hosted elsewhere, add its exact origin to allowed_origins instead of using "*".

How the fallback hotspot behaves

  • The hotspot only starts when the device cannot connect to the configured network. The Wi-Fi documentation gives a default ap_timeout of 90 seconds before that happens, while the captive portal page describes it as "after 1 minute". Expect roughly that range.
  • Join the hotspot from a phone or laptop and open http://192.168.4.1/ if the portal page does not pop up by itself.
  • Wi-Fi settings entered in the portal override the ones in the firmware. According to the captive portal documentation, any later upload over USB overwrites them, so update the YAML as well. If the YAML contains no Wi-Fi credentials at all, the saved ones also survive OTA updates, according to the Wi-Fi documentation.
  • The Wi-Fi option reboot_timeout (default 15 minutes) restarts a device that cannot connect, but it does not apply in AP mode.

Static address instead of mDNS

.local names depend on mDNS support on the client and do not cross routers or VLANs. If your phone or a script cannot resolve workshop-relay.local, either reserve an address for the device in the router's DHCP settings or set it in ESPHome:

wifi:
  manual_ip:
    static_ip: 192.168.1.50
    gateway: 192.168.1.1
    subnet: 255.255.255.0

These keys go inside the existing wifi: block. Adjust them to your network.

Security checklist

ESPHome's own threat model assumes devices on a trusted home or business network, behind a firewall and not exposed to the internet. For a standalone device, that means:

  • Never forward the device's port 80 on your router.
  • Set web authentication with type: digest, and a password on the fallback hotspot.
  • Use encrypted OTA and keep ota: false under web_server unless you need uploads from the browser.
  • Keep a unique key and password per device, stored in secrets.yaml, and keep that file out of public repositories.
  • Put IoT devices in a separate network segment if your router supports it.