Operations & troubleshooting
Deploying with deploy.sh, Cloudflare Tunnel and Access, upgrades, logs and fixes for common problems.
Deploying and updating with deploy.sh
deploy.sh installs or updates the hub on a server over SSH from your workstation. Every run:
- Copies the codeto
DEPLOY_DIR(default/opt/htech-hub) with tar over SSH. No rsync is needed, and build output, the iOS project and git history are left out, so the transfer stays small. The new code is unpacked next to the old and swapped in, so an interrupted transfer never leaves a half-copied folder. - Creates
.envwith a randomHUB_SECRET_KEY, only on the first deploy. It's never overwritten. - Writes
docker-compose.server.ymlwhich publishesHUB_PORT(default 8756) and the apps gateway ports, mounts the Docker socket, setsHUB_AUTO_LOCAL=1and the origin, and opts out of Watchtower (the image is built locally). - Builds and starts
docker compose up -d --build, prunes old images and waits for/healthz.
Accounts, environments, settings and stacks live in the htech-hub_hub-data volume and survive every deploy.
One-time setup
ssh-keygen -t ed25519 -f ~/.ssh/htech_deploy -N ""
ssh-copy-id -i ~/.ssh/htech_deploy.pub root@SERVER # you type the password
Deploy or update
DEPLOY_HOST=root@SERVER ./deploy.sh
| Variable | Default |
|---|---|
DEPLOY_DIR | /opt/htech-hub |
HUB_PORT | 8756 |
DEPLOY_KEY | ~/.ssh/htech_deploy |
HUB_PUBLIC_URL | – (see below) |
Non-root users need passwordless sudo for Docker.
UniFi IPS and big transfers. If your UniFi gateway runs intrusion prevention, a large or unusual SSH transfer can be flagged and your workstation blocked. If SSH suddenly times out after a deploy, check UniFi → Insights / Security for a block on your IP and allow it. Keeping deploys small (the default) avoids this.
Public https address
By default deploy.sh sets HUB_ORIGIN=http://<ip>:<port>. Once the hub is behind HTTPS, deploy with:
HUB_PUBLIC_URL=https://hub.example.com DEPLOY_HOST=root@SERVER ./deploy.sh
This sets HUB_ORIGIN, HUB_RP_ID and secure cookies, which passkeys need. From then on, sign in through the public URL.
Cloudflare Tunnel
- Add a public hostnameCloudflare Zero Trust → Networks → Tunnels → your tunnel → Public Hostname → Add: hostname
hub.example.com, serviceHTTP→htech-hub:8080. - Run cloudflaredon the hub's Docker network:
If cloudflared already runs elsewhere on your LAN, skip this and set the service todocker run -d --name cloudflared --restart unless-stopped --network htech-hub_default \ cloudflare/cloudflared:latest tunnel --no-autoupdate run --token <TUNNEL_TOKEN>http://<server-ip>:8756. - Apps gateway (optional)Add a second hostname, e.g.
apps.example.com→http://<server-ip>:8757, and enter it under Settings → General → Apps gateway address. - Redeploywith
HUB_PUBLIC_URL=https://hub.example.com.
WebSockets (terminals, agents, Hawser Edge, live map and camera video) work through tunnels out of the box.
Cloudflare Access
Protecting the hostname with Cloudflare Access is recommended: nobody reaches the hub's sign-in page without passing your identity provider first. Machines still need a way through:
| Client | How |
|---|---|
| Edge agents, Hawser Edge | A Bypass policy for /api/agent/ws and /api/hawser/connect. These are authenticated by the hub's own tokens. |
| iPhone / iPad / Mac app | A Service Auth policy with a service token, entered in the app (see the apps). |
| n8n, MCP clients, scripts | A service token sent as CF-Access-Client-Id / CF-Access-Client-Secret headers, rather than bypassing /api or /mcp. |
| Google / Apple sign-in | Nothing extra. The callback happens in your browser, after Access. |
| Apps gateway | Put its hostname in the same Access application, so one sign-in covers both. |
Access has its own session length (e.g. 24 hours). The hub's own session is 12 hours by default (HUB_SESSION_HOURS). Whichever ends first sends you to sign in again. That's expected, not a sign the hub is unprotected.
Backing up the hub
- Easiest: a configuration backup, daily to Google Drive or as a downloaded
.hhbak, restorable from the setup screen. - By hand: back up both the data volume and
.env:
To restore, putdocker run --rm -v htech-hub_hub-data:/d -v $PWD:/b alpine tar czf /b/hub-data.tgz -C /d . cp /opt/htech-hub/.env ~/hub.env.backup.envback, untar the volume, then deploy.
Upgrades
The database is SQLite (/data/hub.db). New tables and columns are added automatically at start-up, so upgrades need no manual steps. Downgrades aren't supported. If the page looks odd after an update, hard-refresh. Static files are cache-busted, but some proxies cache aggressively.
Logs and health
| Task | Command |
|---|---|
| Follow logs | docker logs -f htech-hub |
| Health | curl http://SERVER:8756/healthz |
| Shell inside the hub | docker exec -it htech-hub sh |
| Resource use | docker stats htech-hub |
Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
| Environment offline: Agent not connected | The edge agent can't reach the hub. Check HUB_URL, the firewall, WebSocket support on your proxy, and the Access bypass. Use AGENT_INSECURE=1 / TLS_SKIP_VERIFY=true for self-signed certificates. |
| SSH host key changed | The host was rebuilt. Verify it, then ⋯ → Reset pinned SSH key. |
| Synology: certificate changed | DSM's certificate was replaced or renewed. With SSH set up, renewals are accepted automatically; otherwise tick Forget the saved certificate. |
| Synology: 2-step verification needed | The trusted-device token was removed or expired. Enter a code on the NAS card. |
| Stacks / updates fail on socket or API environments | The compose helper image (docker:cli) couldn't be pulled. Check the host's internet/registry access, or set HUB_COMPOSE_IMAGE. |
| Docker API via curl failed | Install curl on the host (SSH/agent image updates use it). |
| Image check shows ? check failed | Registry rate limits or no internet from the host. Hover the badge for details. |
| Passkey button does nothing | The page isn't HTTPS on HUB_RP_ID (or localhost). Passkeys can't work on a bare IP. |
| Device stuck in Not linked on the map | The hub can't match it by MAC/IP/hostname. Add UniFi, or make the environment's hostname match. |
| Terminal opens then closes | The container has no sh, or the connection doesn't support terminals (use the command console). |
| An app opens in a refresh loop through the hub | The app wants to be served at /. Use the LAN route, or ✎ the tile to open it directly. Admin apps like Nginx Proxy Manager open on their admin port automatically. |
| App unreachable remotely | External app access is off, or no apps gateway address is set. |
| No alert when something went down | Check the webhook's event list and minimum severity, and look for notify.failed in the event log. Use the webhook's Test button. |
| Cameras: 429 Too Many Requests | Protect rate-limited the hub. It backs off by itself. If it persists, check for other tools polling Protect at the same time. |
| App: HTTP error 403 through Cloudflare | Cloudflare's bot protection or an Access policy blocked the request. Add the service token policy, and check Security → Events in Cloudflare. |
| Signed out after some hours | The hub or Access session ended (12 h by default). Sign in again, or raise HUB_SESSION_HOURS. |
FAQ
Does it phone home?
No. There's no telemetry and no cloud account. The only outbound connections are the ones you configure (registries for image checks, your notification services, cloud backup destinations, Google/Apple sign-in), plus dashboard-icons for app icons, which are cached.
Can I run it without Docker socket access?
Yes. Don't mount the socket and manage the hub's own host over SSH like any other environment.
How many environments can it handle?
Dozens. Polling is concurrent and lightweight. A lab with Proxmox, two NAS units, UniFi, CrowdSec, cameras and a dozen app integrations uses about 140 MB of RAM.
Does it replace Portainer / Uptime Kuma / Dockhand?
It covers the day-to-day parts of all three in one place, and can work alongside them: it reads Portainer- and Dockhand-deployed stacks and talks to Hawser agents.