Self-hosting the bridge
The bridge is one small service in a container. It talks to your Protect console, keeps the door state machine, serves the app and runs the alert rules. It runs on any always-on machine with Docker that can reach your console, a NAS, a Raspberry Pi or a VPS, on amd64 and arm64.
You don't have to run it. The app connects straight to your Protect console and works on your home network with nothing else installed. Run the bridge if you want family invites that never hand out your Protect key, an activity log by name, alert rules that run on your own hardware, or background-reliable Shortcuts. The two ways to connect, compared.
Checklist
Have to hand: your console's address, a new Protect API key, the LAN address of the machine that will run the bridge, and which relay output drives the door. Then work down the list; each step links to its section.
- Relay output set to Pulse, sensor mounted as Garage, API key created
- Bridge can reach the console on 443; console and phone can reach the bridge on 8787
- Compose file and
.envdownloaded, two secrets generated, bridge started /healthzsays"ready":true, from the host and from another machine- Phone joined with the invite from the container log
- Two Alarm Manager rules created, and a door movement shows up in the activity log
- ntfy set up, if you want alerts on a phone that is asleep or away
- Data volume in your backups,
.envin a password manager
Before you start
- Set one relay output to Pulse. In Protect, open the relay and configure the output that is wired to your opener's push-button terminals as Pulse, type garage door. Note which output it is: the numbering in the app starts at 1, but the API's own ids start at 0.
- Mount the sensor and set its mount type to Garage. This is what the bridge reads to decide whether the door is open. If the mount type is anything else, auto-discovery will not pair it.
- Create a Protect API key. Console → Settings → Control Plane → Integrations → Create
API Key. Copy it once; it is not shown again.
This key can read every camera on your console. Treat it like a password to your house, because functionally that is what it is.
How the bridge finds your door. With exactly one relay output of type garage door and
one sensor mounted as Garage, the bridge finds both on its own and you configure nothing. With more than
one candidate it cannot guess, so pin them: DOOR_RELAY_ID, DOOR_OUTPUT_ID (the
API id, so output 1 in the app is 0) and DOOR_SENSOR_ID. A second door is
always written down, never guessed; see two doors. Once the bridge is running,
it lists every relay and sensor with its id:
curl -s -H "Authorization: Bearer <BRIDGE_TOKENS>" http://<bridge-host>:8787/v1/discovery Relay press. On Protect 7.2.x the relay toggles instead of pulsing, so the bridge
presses and releases it itself. That is the default, RELAY_PULSE_MODE=emulated. Only if
your relay genuinely pulses on its own, set RELAY_PULSE_MODE=native.
What has to reach what
Everything stays on your network. If the bridge and the console sit on different VLANs, your firewall has to allow both of the first two rows, in the direction shown.
| From | To | Port | Why |
|---|---|---|---|
| Bridge | Protect console | TCP 443 (HTTPS) | Reading the sensor, pressing the relay. Required. |
| Protect console | Bridge | TCP 8787 (HTTP) | Alarm Manager webhooks, so the bridge reacts instantly. Recommended; it polls anyway. |
| Phone | Bridge | TCP 8787 (HTTP) | The app, widgets, Watch and Shortcuts. Required: on your Wi-Fi, or over a VPN or tunnel when away. |
| Phone | Bridge | UDP 5353 (mDNS) | Finding the bridge without typing its address. Optional; needs host networking and does not cross VLANs unless your gateway reflects mDNS. |
| Bridge | ntfy (your own, or ntfy.sh) | TCP 8788, or 443 for ntfy.sh | Push alerts to a phone that is asleep or away. Optional; see alerts. |
| Bridge | ghcr.io | TCP 443 | Downloading the image, when you install or update. |
The phone must be on a network that can reach the bridge, with client isolation off on that Wi-Fi. Never forward a port from the internet to the bridge; see from outside the house.
Run it
The image is ghcr.io/pcrausaz/garage-opener-bridge. The reference docker-compose.yml
and .env.example live in the bridge
repository. Put both in one directory:
mkdir garage-bridge && cd garage-bridge
curl -fsSLO https://raw.githubusercontent.com/pcrausaz/garage-opener-bridge/main/selfhost/docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/pcrausaz/garage-opener-bridge/main/selfhost/.env.example -o .env
Open .env and fill in the values at the top. Two of them are secrets you generate, not
invent. Run this twice and paste each output into place:
openssl rand -hex 24 # .env: the values that matter
GARAGE_BRIDGE_TAG=latest # see "updates and backups" below
PROTECT_URL=https://192.168.1.1
PROTECT_API_KEY=… # the key you just created
BRIDGE_TOKENS=… # first openssl output: your admin token
WEBHOOK_SECRET=… # second openssl output: the Alarm Manager path secret
TZ=Europe/Zurich .env is read as plain values, not run as a shell script, so a line like
BRIDGE_TOKENS=$(openssl rand -hex 24) would store those characters literally. Paste the
output, not the command.
docker compose up -d
curl -s localhost:8787/healthz
You should get {"ok":true,"ready":true,…}, with the version that is
actually running. If ready is false, the bridge is up but hasn't reached the
console yet, and the message says why. Run the same curl from another machine, with the
bridge host's address instead of localhost, to check the network path too.
Using Dockhand, Portainer or another stack manager
Paste the reference docker-compose.yml as the stack and set the same variables in the
stack's environment instead of a .env file. The file lists every setting explicitly, so
nothing else is needed. A variable that is not in the file never reaches the bridge.
Two doors on one bridge
From bridge 0.6, one bridge drives two doors, and no second bridge is needed. Each door needs its own
relay output, on the same relay or on another one, and its own sensor mounted as Garage. The first door
keeps its DOOR_* settings; the second is described by DOOR2_*:
# .env: two doors (bridge 0.6 or later)
DOOR_NAME=Left door
DOOR_RELAY_ID=…
DOOR_OUTPUT_ID=0 # API id: output 1 in Protect
DOOR_SENSOR_ID=…
DOOR2_NAME=Right door
DOOR2_RELAY_ID=… # the same relay, or a second one
DOOR2_OUTPUT_ID=1 # API id: output 2 in Protect
DOOR2_SENSOR_ID=… # this door's own sensor
# DOOR2_INTERIOR_CAMERA_ID=… # optional: the picture on this door's screen
# DOOR2_DRIVEWAY_CAMERA_ID=… # optional: the camera that sees the driveway
With two garage outputs and two sensors the bridge does not pair them on its own, because guessing which
sensor watches which door is how the wrong door ends up opening. It logs a suggested pairing, by name,
at startup, and waits for you to write it down as above. The ids come from the same
/v1/discovery call. DOOR_NAME and DOOR2_NAME are what the app,
the notifications and the activity log call each door.
- Compose file. The reference compose passes only the variables it lists, so use the
one published with 0.6 or later, or add the
DOOR2_*lines to your own copy. - Alarm Manager. The second sensor needs its own opened and closed rules, posting to the same address. The bridge tells the doors apart by the sensor that sent the event.
- Cameras. Two doors may name the same camera.
DOOR_INTERIOR_CAMERA_IDandDOOR2_INTERIOR_CAMERA_IDchoose the picture shown on each door's screen in the app. - Phones. Nobody has to pair again. Each person picks their default door in the app (version 1.1 or later); an older app keeps driving the first door, exactly as before.
We have one door ourselves, so a second door is tested against the simulator only. A real second relay output and a second sensor's Alarm Manager events are unverified until someone with two doors reports back. If that is you, tell us how it went.
A note on tokens
BRIDGE_TOKENS is the credential that opens your garage door. The bridge refuses to start on
anything shorter than 24 characters, or on anything guessable. Generate it. Your family's phones do
not need an entry here. They join by invite, below.
Host networking, or not
The app finds the bridge over Bonjour, and multicast does not cross Docker's bridge network, so the
reference compose uses network_mode: host. If that isn't available to you, for instance on
Docker Desktop for macOS or Windows, publish 8787:8787 instead, set
BONJOUR=false, and set PUBLIC_URL=http://<host-address>:8787. Without
host networking the bridge only sees its container's address, and that is what invite links would
otherwise carry.
Pair your phone
Don't copy your admin token into the app. While no phone has joined, the bridge prints a single-use invite at startup:
docker logs garage-bridge | tail -20
On the app's first screen, tap Join with an invite and paste the
garageopener://join… link or just the code. Opening the link on the phone does the same.
The invite carries the bridge's address: PUBLIC_URL if you
set it, otherwise the host's LAN address. It's good for fifteen minutes; restart the container for a
fresh one. Once a phone has joined, the banner stops and any further phones are invited from the app
itself.
Let the console tell the bridge about the door
The bridge polls, but webhooks make it react within a second. Create two rules on the console:
- Open Protect → Alarm Manager → Create Alarm.
-
Name it
garage-door-opened. Trigger: Sensor → Contact → Opened, on your garage door sensor. -
Under Actions, add Webhook, choose POST, untick
Send thumbnail, and paste:
http://<bridge-host>:8787/v1/webhooks/alarm-manager/<WEBHOOK_SECRET> - Repeat with
garage-door-closedand Contact → Closed. Keep the words opened and closed in the names.
Check it worked by moving the door (or just the sensor). The app's activity log shows a Sensor entry within a second. Protect's Test alarm button sends a generic sample, not the real event, so it is not a useful test.
If nothing arrives: a wrong secret returns a bare 404, so check the secret first, and that nothing trailed it when you pasted. Then check that the console can reach the bridge host (see what has to reach what).
Alerts on a sleeping phone
The bridge evaluates the alert rules around the clock. The app shows them whenever it is running or refreshing in the background, but iOS decides when that happens. For a notification that reliably reaches a phone that is asleep or away from home, have the bridge publish to ntfy and subscribe to the topic in the ntfy app:
- ntfy.sh: set
NTFY_URL=https://ntfy.shand a long randomNTFY_TOPIC(openssl rand -hex 16). The topic name is the only thing keeping it private. Restart the bridge and subscribe to the topic in the ntfy app. - Your own ntfy: the reference compose includes one, which admits nobody until you
create a user. In
.envsetNTFY_URL=http://127.0.0.1:8788(where the bridge publishes;http://ntfy:80if you published ports instead of host networking),NTFY_BASE_URL=http://<host-address>:8788(where your phone reaches it) andNTFY_TOPIC. Then:
Put the token indocker compose --profile ntfy up -d docker exec -it garage-ntfy ntfy user add --role=user garage # asks for a password docker exec -it garage-ntfy ntfy access garage '<NTFY_TOPIC>' read-write docker exec -it garage-ntfy ntfy token add garage # prints tk_….envasNTFY_TOKENand rundocker compose --profile ntfy up -dagain. In the ntfy app, add the server atNTFY_BASE_URL, sign in asgarage, and subscribe to the topic.
With PUBLIC_URL set, ntfy notifications carry Close now and Hold 2h buttons
that call your bridge directly.
Certificates
Your console presents a self-signed certificate, so the default is PROTECT_TLS=insecure,
which is fine on a LAN. Pin it properly once you're running:
openssl s_client -connect 192.168.1.1:443 -servername 192.168.1.1 </dev/null 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256
Put the result in PROTECT_TLS. You can paste openssl's output as it is
(sha256 Fingerprint=AB:CD:…); colons and case are ignored, and it is stored as
fingerprint:abcd…. If your console has a real certificate, use system. The
bridge refuses to start with insecure if PROTECT_URL is not a
LAN address, because that would expose your API key to anyone on the path.
Reaching it from outside the house
Set PUBLIC_URL to whatever address the phone will use, since that is what invite links carry. Then
pick one:
| Approach | Notes |
|---|---|
| VPN back home (WireGuard on the UDM, or Tailscale) | Recommended. Nothing is exposed; the phone simply joins your network. |
| Cloudflare Tunnel | Real hostname, real certificate, no inbound port. Set TRUST_PROXY=true. |
| Your own reverse proxy | Caddy, nginx or Traefik terminating TLS. Set TRUST_PROXY=true, and turn off response buffering on /v1/events or the door state will lag. |
| Port forwarding | No. This puts a device that opens your garage on the public internet behind one token, usually without TLS. |
TRUST_PROXY matters more than it looks. Without it, every request appears to come from
the proxy, so the per-address limit on invite claims becomes five a minute for your entire household.
With it set when nothing in front strips X-Forwarded-For, a caller can forge its own
address, so set it only when a proxy you control is genuinely there.
Updates and backups
latest is the default and is the right choice for most people. Docker never updates a
container on its own, so latest simply means you get the current release the next time you
choose to pull. That keeps you on security fixes without having to follow releases.
docker compose pull && docker compose up -d
curl -s localhost:8787/healthz # check the version that came down
Pin an exact version instead, GARAGE_BRIDGE_TAG=0.6.0, if you want upgrades to be a
decision rather than a side effect of pulling, or if you run something that pulls automatically such as
Watchtower. With latest and an automatic updater together, a release that needs a
configuration change would restart your bridge into a refusal to start while you are not looking.
Either way, read the changelog before a pull. Anything that requires you to touch your configuration is listed under Action required, and the bridge fails loudly at startup rather than running in a degraded state.
Back up the garage-bridge-data volume. It holds the family members, open
invites and the activity log. Lose it and every phone has to pair again. Your .env is the
only other thing to keep, and it belongs in a password manager rather than a shared backup.
Source and licence
The bridge and its OpenAPI contract are open source under Apache-2.0. You are running it on your own hardware with a credential to your console, so you should be able to read it rather than take it on trust, including the security review carried out before it was published.
The iPhone and Android apps are not open source, and neither is the cloud-alerts service. That service is something we operate rather than something you run, it holds no credential to your console, and it cannot open or close anything. What it stores is set out in full on the privacy page.
Out of scope
More than two doors per bridge. Live video; the app shows camera snapshots instead. UniFi Protect only, with no other relays and no other hubs. No Home Assistant integration. A non-Docker install (Node 22 directly) will run but is not supported: Docker is the tested path and the only one the instructions cover.
Stuck? The FAQ covers the common ones, and support@garageopener.app reaches a person.
Last updated: