Garage Opener

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.

Before you start

  1. 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.
  2. 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.
  3. 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.

FromToPortWhy
BridgeProtect consoleTCP 443 (HTTPS)Reading the sensor, pressing the relay. Required.
Protect consoleBridgeTCP 8787 (HTTP)Alarm Manager webhooks, so the bridge reacts instantly. Recommended; it polls anyway.
PhoneBridgeTCP 8787 (HTTP)The app, widgets, Watch and Shortcuts. Required: on your Wi-Fi, or over a VPN or tunnel when away.
PhoneBridgeUDP 5353 (mDNS)Finding the bridge without typing its address. Optional; needs host networking and does not cross VLANs unless your gateway reflects mDNS.
Bridgentfy (your own, or ntfy.sh)TCP 8788, or 443 for ntfy.shPush alerts to a phone that is asleep or away. Optional; see alerts.
Bridgeghcr.ioTCP 443Downloading 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_ID and DOOR2_INTERIOR_CAMERA_ID choose 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:

  1. Open Protect → Alarm Manager → Create Alarm.
  2. Name it garage-door-opened. Trigger: Sensor → Contact → Opened, on your garage door sensor.
  3. Under Actions, add Webhook, choose POST, untick Send thumbnail, and paste:
    http://<bridge-host>:8787/v1/webhooks/alarm-manager/<WEBHOOK_SECRET>
  4. Repeat with garage-door-closed and 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.sh and a long random NTFY_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 .env set NTFY_URL=http://127.0.0.1:8788 (where the bridge publishes; http://ntfy:80 if you published ports instead of host networking), NTFY_BASE_URL=http://<host-address>:8788 (where your phone reaches it) and NTFY_TOPIC. Then:
    docker 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_…
    Put the token in .env as NTFY_TOKEN and run docker compose --profile ntfy up -d again. In the ntfy app, add the server at NTFY_BASE_URL, sign in as garage, 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:

ApproachNotes
VPN back home (WireGuard on the UDM, or Tailscale)Recommended. Nothing is exposed; the phone simply joins your network.
Cloudflare TunnelReal hostname, real certificate, no inbound port. Set TRUST_PROXY=true.
Your own reverse proxyCaddy, nginx or Traefik terminating TLS. Set TRUST_PROXY=true, and turn off response buffering on /v1/events or the door state will lag.
Port forwardingNo. 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: