7.5 KiB
blinds_express — Architecture & Design Record
What this is
Node.js/Express API for the BlindMaster smart blind controller. Manages users,
devices, peripherals, groups, scheduled jobs, email auth, and real-time
WebSocket events. Served at blindmaster.wahwa.com via Cloudflare Tunnel.
Infrastructure overview
Cloudflare Tunnel ──► 127.0.0.1:3002 (backend container)
│
┌─────────────┴──────────────┐
▼ ▼
postgres:5432 (bridge) mongo:27017 (bridge)
postgres_data volume mongo_data volume
All three containers live on a Docker bridge network created by Compose. Nothing
is exposed on a host port except the backend's 127.0.0.1:3002 loopback bind.
Key design decisions
Cloudflare Tunnel replaces nginx
Express serves the static public/ directory via express.static, handles all
API routes, and upgrades WebSocket connections. A Cloudflare Tunnel pointing
directly at http://127.0.0.1:3002 is sufficient — nginx is not needed.
WebSocket proxying must be enabled in the Cloudflare dashboard for the tunnel.
Dedicated postgres container (not host postgres)
The host runs a native postgresql@16-main.service on 127.0.0.1:5432 used by
other services. LabWise also runs its own labwise-postgres-1 container, but
that container does not expose a host port — it lives on LabWise's bridge
network only. A third, isolated postgres container for blinds_express costs
~150 MB RAM but gives clean isolation, easy pg_dump migration, and full
parity with LabWise's container model.
To migrate existing data from the host postgres:
pg_dump -U <host_user> blinds_db | \
docker compose exec -T postgres psql -U blinds blinds_db
Idempotent schema migration
db/schema.sql uses CREATE TABLE IF NOT EXISTS for every table and a tail
block of ALTER TABLE … ADD COLUMN IF NOT EXISTS for columns added after
initial creation. db/migrate.js runs this file on every container start via
the Dockerfile CMD:
CMD ["sh", "-c", "node db/migrate.js && node index.js"]
If the build fails, docker compose build exits non-zero and docker compose up
is never reached — existing containers keep running. This matches LabWise's
migration pattern exactly.
APNs push notifications (no third-party library)
Replaced Firebase Admin (firebase-admin) with hand-rolled APNs over HTTP/2
using only Node built-ins (http2, crypto). The reference implementation was
~/LockInBroAPI/app/services/push.py, which was known-working.
Critical detail: Node's crypto.sign defaults to DER encoding for ECDSA. APNs
requires P1363 (IEEE) format (raw r‖s concatenation). Fixed with:
crypto.sign('sha256', Buffer.from(signingInput), {
key: keyObj,
dsaEncoding: 'ieee-p1363',
});
JWT is cached 55 minutes (APNs invalidates tokens older than 60 min). HTTP/2 session is opened per request (connection reuse is a future optimisation).
A 410 Unregistered response means the device token is dead — the handler
nulls it in the database immediately:
if (result.status === 410 && pool) {
await pool.query("UPDATE users SET apns_token=NULL WHERE apns_token=$1", [token]);
}
Env vars (matching LockInBroAPI naming):
APNS_KEY_ID— the 10-char key ID from the.p8filenameAPNS_TEAM_ID— Apple Developer team IDAPNS_P8_PATH— container-internal path to the key file (e.g./app/AuthKey.p8)APPLE_BUNDLE_ID— app bundle identifierAPNS_SANDBOX—truefor development, omit orfalsefor production
APNs key file mounting
The .p8 file is never committed (.gitignore includes *.p8). It is mounted
read-only into the container via a Compose volume:
- ${APNS_P8_HOST_PATH:-./AuthKey.p8}:${APNS_P8_PATH:-/app/AuthKey.p8}:ro
Set both in .env:
APNS_P8_HOST_PATH=./AuthKey_A7ASKB9B7V.p8(actual filename on the host)APNS_P8_PATH=/app/AuthKey.p8(path inside the container, read bypush.js)
Mailgun replaces AWS SES
Removed @aws-sdk/client-sesv2 and nodemailer; added mailgun.js + form-data.
mailer.js uses mg.messages.create() matching LabWise's email.ts pattern.
Verification emails link to blindmaster.wahwa.com.
NODE_VERSION build arg
.node-version contains the bare version (22.11.0). The Gitea Actions workflow
appends -alpine and writes it into .env before docker compose build:
NODE_VER=$(cat .node-version)
sed -i '/^NODE_VERSION=/d' .env || true
echo "NODE_VERSION=${NODE_VER}-alpine" >> .env
docker compose build
Dockerfile.backend consumes it as a build arg:
ARG NODE_VERSION=22-alpine
FROM node:${NODE_VERSION}
CI/CD (Gitea Actions)
.gitea/workflows/deploy.yaml mirrors LabWise's workflow exactly:
- Alpine runner container with
openssh-client,iproute2,git - SSH key from
secrets.DEPLOY_SSH_KEY→~/.ssh/id_rsa - Host IP discovered from the Docker bridge gateway (
ip route | awk '/default/') - SSH into host,
git pull, patchNODE_VERSIONin.env,docker compose build,docker compose up -d,docker image prune -f
set -e in the remote SSH block means a failed build aborts before up -d,
leaving existing containers untouched.
Dependencies removed
| Package | Reason |
|---|---|
firebase-admin |
Replaced by hand-rolled APNs in push.js |
@aws-sdk/client-sesv2 |
Replaced by Mailgun |
nodemailer |
No longer needed |
Dependencies added
| Package | Reason |
|---|---|
mailgun.js |
Mailgun API client |
form-data |
Required peer dep of mailgun.js |
cron-parser |
Already used internally, made explicit |
Files added / changed
| File | Status | Notes |
|---|---|---|
Dockerfile.backend |
New | Alpine Node image, production deps only |
docker-compose.yml |
New | postgres + mongo + backend |
.gitea/workflows/deploy.yaml |
New | Replaces .github/workflows/deploy.yml |
.github/workflows/deploy.yml |
Deleted | Replaced by Gitea Actions |
db/schema.sql |
New | Full idempotent schema |
db/migrate.js |
New | Reads schema.sql, runs via pg Pool |
mailer.js |
Rewritten | SES/nodemailer → Mailgun |
push.js |
New | Hand-rolled APNs, no library |
index.js |
Modified | FCM → APNs, port from env, listen on 0.0.0.0 |
db.js |
Modified | Mongo URI from MONGO_URI env var |
package.json |
Modified | Deps swapped, "start" script added |
.env.example |
New | Documents all required env vars |
.dockerignore |
New | Excludes node_modules, .env, .git, etc. |
.node-version |
New | 22.11.0 — consumed by deploy workflow |
Port reference
| Service | Host binding | Notes |
|---|---|---|
| Backend API | 127.0.0.1:3002 |
Cloudflare Tunnel → here |
| postgres | none | Bridge-only |
| mongo | none | Bridge-only |
| Gitea | :3000 |
Separate service, not part of this compose |
First-time setup checklist
- Copy
.env.example→.envand fill in all values - Set
APNS_P8_HOST_PATHto the actual.p8filename (e.g../AuthKey_A7ASKB9B7V.p8) - Place the
.p8file in the project root (it is gitignored) - Add
DEPLOY_SSH_KEY(private key foradipu@host) to Gitea repository secrets - Point the Cloudflare Tunnel for
blindmaster.wahwa.com→http://127.0.0.1:3002 - Enable WebSocket proxying in Cloudflare dashboard for that tunnel
- Push to
main— the Gitea workflow builds, migrates, and starts everything