Everything needed to stand up your own Entrixy server: the web part, the service that holds the live connections with phones, the database and the certificate. This is not a trimmed build and not a demo — objects, guest keys, passing a key down a chain, revocation, the log, network controllers: all of it is here. Run it for yourself or for your clients; the app is pointed at your server in its settings.
There are two ways in. With Docker — three commands on an empty machine, and it brings the right version of everything with it. Without Docker — the web part goes on ordinary hosting with PHP and a database, and one ready-made program runs beside it to hold the live connections. Docker is convenient, not required.
- Project site — https://entrixy.com
- How the open model works — https://entrixy.com/open
- Documentation — https://entrixy.com/docs
- The app for Android — https://entrixy.com/download/android
- Browser client — https://entrixy.com/app
- Controller protocols: Bluetooth — https://entrixy.com/ble, network — https://entrixy.com/socket
Russian version of this file: README.ru.md.
A machine with Docker and a domain pointing at it: the certificate is issued by itself and nothing has to be set up by hand. Or, if you would rather do without Docker, ordinary hosting with PHP and a database, plus some way to keep one program running — see "Installing without Docker" below.
git clone https://github.com/entrixy/server.git entrixy-server
cd entrixy-server
cp .env.example .env
nano .env # domain, database passwords, secrets
docker compose up -d --build
The first start takes a few minutes: images are built and the database schema
is loaded. The containers listen on the loopback address only — the web part
on 127.0.0.1:8082, the websocket on 127.0.0.1:8095.
Two values in .env have to be generated rather than invented:
openssl rand -hex 32 # JWT_SECRET
The database passwords can be anything: they never leave the machine.
APP_ATTEST_SECRET already carries the value that matches the app from the
store — leave it as it is unless you build the app yourself.
Three checks, in this order. The first says the web part is up and the database is in place:
curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8082/key
A 200 is what you want. Then, once the domain is proxied, the page should
come up with its styling and its tab icon — if it is bare, the panel is
serving the static files instead, and the section below says what to do. And
the websocket, which has a trap of its own:
python3 tools/ws_check.py wss://your-domain/ws # expect 101 and pong
Out of the box the door is open: any app told your address will create a device. That is deliberate — a server for yourself usually needs nothing more. If it is meant for a circle of people, close it with a code or with invitations before you hand the address out; see "A server for your own people only".
If nginx already runs on the machine — which is usually the case on a server hosting other sites — ports 80 and 443 stay with it. Create a subdomain, issue a certificate the way your panel normally does, then add the proxying. There are two samples, and the choice between them matters:
nginx-entrixy.conf.example— for an nginx you edit by hand: a wholeserverblock with its own certificate;nginx-entrixy-panel.conf.example— for a control panel (Hestia, ISPmanager). There the include is inserted into an existingserverblock wherelocation /already belongs to the panel, so the root is declared as the regexlocation ~ ^/and/wsas the prefixlocation ^~ /ws, which beats that regex. A wholeserver {}must not go into such a file: nginx refuses withduplicate location.
Your existing configs are left untouched either way.
If the machine is clean, the server can hold TLS itself:
docker compose --profile tls up -d --build
Caddy comes up, takes 80 and 443 and issues a certificate for the domain from
DOMAIN with no manual setup.
This is the trap that costs the most time. A panel's own config usually ends with a regex for static extensions — css, js, png, ico and the rest — that serves such files from its own document root. Yours are not there, they are inside the container, so the pages come up while the stylesheet and the icons answer 404: a bare page with no design and an empty tab icon.
The prefix blocks in the sample cover the directories, and the exact location = blocks cover the files that lie in the root: assets.css, the three
favicons, apple-touch-icon.png and robots.txt. Both kinds are there for
this reason. An exact match beats any regex in nginx whatever the order of the
includes, while a prefix with ^~ beats it as well — that is what makes the
sample work without touching the panel's own config.
If you build the config yourself and a file still answers 404, check it with
curl -I https://your-domain/assets.css
A 404 with the panel's own server header means the request never reached the
container: add a location = for that file the way the sample does.
PHP errors go to the container log, never into the answer: a warning shown to a visitor prints absolute paths and the shape of the code. The version is kept out of the response headers for the same reason. Both are set in the image, so there is nothing to configure.
The websocket worker's sources are removed from the web container at build
time — it runs in its own container and has nothing to say over HTTP. The URL
rules refuse /w/ as well, which covers an image built by hand.
If you see a PHP error in a browser, you are looking at an image built before
this was fixed: rebuild with git pull && docker compose up -d --build.
Docker is known for writing its own iptables rules and stepping around
existing ones. That does not happen here: ports are published on 127.0.0.1
rather than on every address, so the containers are invisible from outside and
the machine's own protection does not apply to them. Do not change HTTP_BIND
to 0.0.0.0 without a reason — the server is already reachable through the
proxy.
In the app: Settings → server address → your domain. Guest links issued by this server lead back to it: a guest who opens such a link is connected to your server automatically.
The app works with several servers at once. Keys issued to you from other servers — by neighbours, by a management company — keep working, while new objects are created on the server selected in settings.
One setting matters here. Keys the owner marked as "app only" are signed by
the app with a secret baked into its build, and the server checks that
signature with the same secret. So the value in .env has to match the build
you connect from. For the app from the store it is
APP_ATTEST_SECRET=eNtR1xy_App_Att3st_2026_9f4Kq2Lm7Xb!
The value is not a secret in any real sense: it sits inside the installable file and can be extracted from it. If you build the app yourself, use your own on both sides. Use mismatched values and those keys stop opening, while ordinary keys keep working.
| Service | What it does |
|---|---|
caddy |
certificate, ports 80 and 443, /ws goes to the worker |
web |
Apache and PHP: app requests, the guest key page |
ws |
websocket worker: live connections with phones and boards |
db |
MariaDB, data lives in the db_data volume |
The web part and the worker talk only through the database, so either one survives a restart of the other.
The service that holds the live connections is a separate program, a single
file. Its source is in ws/, and it is what makes the installation below
possible.
The web part is written in PHP, the service holding the live connections in Go. Both were chosen deliberately and we consider the pair optimal for this server.
PHP runs on the hosting people already have. Go builds into a single file that needs nothing installed beside it, which is what lets this server run without Docker.
The two meet only at the database, and the protocol between the server and a phone is written down with test vectors, so either half can be replaced without touching the other.
Docker is here for the worker, not for PHP. The web half is an ordinary PHP site and runs on any hosting that serves PHP and MySQL; what a shared host will not give you is a program that stays running, which is what the service that holds the live connections is. As a single file, it needs none.
There is a script that does the whole of it, asking before each step that touches the machine:
sudo sh tools/install-native.sh
It writes the settings with fresh secrets, loads the schema, builds the worker, installs it as a service and tells you what to put in your web server. It can be run again; nothing is overwritten without a word.
By hand, the same six steps:
-
Put the contents of
dist/where your web root is, and loadsql/01-schema.sqlinto a MySQL or MariaDB database. -
Copy
dist/_config.local.example.phptodist/_config.local.phpand fill it in — the database, the address, two secrets fromopenssl rand -hex 32. Do not edit_config.php: it takes its values from the environment, which is how the containers are given them, and an update overwrites it. The local file is read afterwards, wins over the environment, and is never touched bygit pull. -
Build the worker once, anywhere with Go installed, and copy the binary over. It needs nothing from the network — its dependencies are in
ws/vendor— and the result is a static binary, so the machine that builds it need not be the machine that runs it:cd ws && CGO_ENABLED=0 go build -mod=vendor -trimpath -o entrixy-ws . -
Write the worker's own settings.
ws/ws-config.phpprints them from the configuration you have just filled in, so the two halves cannot disagree. The file holds the database password: it is owned by root and read by the worker's group, and by nobody else.groupadd --system entrixy useradd --system --no-create-home --gid entrixy entrixy mkdir -p /etc/entrixy php ws/ws-config.php > /etc/entrixy/ws-config.json chown root:entrixy /etc/entrixy/ws-config.json chmod 640 /etc/entrixy/ws-config.json -
Keep it running.
docs/entrixy-ws.serviceis a systemd unit ready for that:cp docs/entrixy-ws.service /etc/systemd/system/ systemctl daemon-reload && systemctl enable --now entrixy-wsIf it refuses to start after you have fixed whatever was wrong, systemd may still be holding the earlier failures against it:
systemctl reset-failed entrixy-wsand start it again. -
Point
/wsat it in your web server, exactly as the nginx examples show.
The worker needs the database and nothing else. It listens on the loopback address, and only your proxy talks to it.
When it is up, ask the server itself:
curl https://YOUR-DOMAIN/healthz
{"status":"ok","database":true,"version":"2026.09.28", ...}
git pull
docker compose up -d --build
Your .env stays where it is — it is not tracked. The database schema catches
up on its own: on start the web part compares the database with the schema
that came with the code and adds whatever is missing — tables, columns,
indexes. The log says exactly what was added:
docker compose logs web | tail
It only adds and never removes or reshapes anything: a column left over from a previous version does no harm, while data from a dropped one cannot be recovered. If a release ever needs an existing column reworked, that is stated in the release notes separately.
Your data stays where it is: it lives in the db_data volume, while the
images are rebuilt beside it.
It sends no mail: there is no mail part here at all — see "Accounts and mail".
It does not build firmware in a browser. That needs build machines of ours and stays on entrixy.com; controllers themselves work with your server normally.
It sends push notifications only once you supply a Firebase project and an app build of your own — the next section explains why and how.
It has nothing to do with opening over Bluetooth. That works phone to controller, whether your server is up or not.
Two things reach us rather than you, and neither carries openings, keys or
objects: the app updates from the store, and it sends crash reports and
announcements to entrixy.com. For the same reason the guest key page points at
our address for installing the app and for the browser client — dist/key.php
carries comments right next to those links saying what to replace if you would
rather host them yourself.
It does not answer /api/version_check.php, and that is deliberate: updates,
crash reports and announcements go to entrixy.com and nowhere else, whatever
server the app is pointed at. A server people trust with their gates must not
be able to hand them a new client. The endpoint is absent rather than empty, so
nothing can quietly start answering it.
The server works without them: while the app holds a connection, a guest's call arrives immediately. A push is for the case when that connection is gone — the phone was asleep, the network blinked, the system unloaded the app.
Any server can send a push, but only the one whose Firebase project is baked
into that particular app build can deliver it to that phone. The app from the
store carries our project, and your key cannot reach it. So push on your own
server is a pair of things: your Firebase project and your build of the app
carrying its google-services.json. The app sources are published separately.
Once the project exists, take a service account key from its settings (Project settings → Service accounts → Generate new private key) and put the file here:
secrets/fcm-service-account.json
then bring the server up again. The secrets/ directory is already mounted
into the containers read-only, nothing else needs changing. If the file is not
picked up the log will say so, and notifications simply stay the way they were
without it.
The server sends no mail at all, and carries no sign-in page or registration form: how people get accounts is left entirely to you.
A device needs no account: the app registers itself and works without any
sign-in, with limits taken from the default row of the plans table. If you
want to tie devices to people — so that limits survive a reinstall, say —
create rows in the users table any way that suits you and attach the device
through host_account. How you confirm an address or reset a password is
entirely yours to decide; nothing here forces a particular scheme.
The companies feature works here too: an organisation registers, publishes
/.well-known/entrixy.json with its public key on its own domain and signs
every request with it. Your server verifies and stores those signatures.
How trust is arranged matters. The domain is verified by the server, not by the app, and that is done for the client's sake: if the phone fetched the key, the company would see its address and the exact moment of the check — that is, "this person is deciding right now whether to let me in". The server goes instead, and the company sees only the server.
The price is that the authenticity badge reads as "verified by Entrixy": the app draws the logo and the highlighted domain only for records from entrixy.com. On your server companies work fully — registration, requests, keys, revocation — but appear in the app by name, without marks of authenticity. Otherwise any server could pass itself off as a well-known company, and a person would have no way to check.
A controller that loses power cannot say goodbye: the server learns about it
from silence. DEVICE_OFFLINE_S is how many seconds of silence turn a
controller offline in the app. The controller says "I am alive" three times as
often — the server tells it the period when it connects — so one signal lost on
a weak Wi-Fi does not flip the indicator. The server checks every second.
The default is 30 seconds, with almost no traffic even with thousands of controllers. With a few dozen you can set 10. Lower than 5 makes no sense: the indicator would flicker on any Wi-Fi hiccup. Controllers with firmware older than 2026-10-06 do not take the period from the server; they keep pinging every 30 seconds and go offline after 90.
Every endpoint has a ceiling, and it is applied the moment a request arrives
rather than endpoint by endpoint: whatever a new endpoint forgets to declare,
the floor is already under it. The floor is REQUESTS_PER_MIN — requests from
one address to one endpoint per minute, 300 by default.
The sensitive points are held tighter in the code and are not affected by that
setting: registering a device is five a minute, claiming a controller twenty,
creating a key thirty, and so on. Opening is limited separately, per key, by
RATE_LIMIT_PER_MIN.
Do not set the floor low without a reason. Thousands of subscribers sit behind one carrier address, and a strict number would cut the neighbours off along with whoever you meant to stop. Counting happens in APCu inside the web container, so a restart clears it — which is a property, not a bug: the limits exist to bound a flood, not to punish anyone.
Over the limit the server answers 429 with retry_after, and the app waits
that out by itself.
By default the server is open: any app that is told your address will create a device on it. That gives away no data — objects and keys are encrypted, and a stranger's device sees precisely nothing — but it does take up room in your database. There are two ways to close the door, and the choice depends on who the server is for.
ACCESS_CODE=any_reasonably_long_string
A device is created only if the app sent the same code. The code is one for everybody and travels together with the address, so it keeps out the street, not acquaintances: whoever learns the code is in. For a home server that is enough.
An invitation is a code for a single device, with an expiry date and a note saying who it was issued to. Registration burns it, so a code someone peeked at is useless — it has already been used. Filling the server with devices on one code does not work either: each one needs its own.
docker compose exec web entrixy-invites add --count 5 --days 30 --note "Ivanov"
The command prints ready strings like 5wvv7qbu6cdx@gate.example.com. That
whole string is what you hand over: the person pastes it into the server
address field and the app separates the code from the address itself. There
are no extra fields to fill in.
docker compose exec web entrixy-invites list
shows the invitations you issued: how many entries are left, until when, and how many devices came in on each.
docker compose exec web entrixy-invites revoke 5wvv7qbu6cdx
closes an invitation for the future. Devices created on it earlier keep
working, and that is deliberate: a code is usually revoked because it spread,
not because a person became unwelcome. If it is the person, add
--with-devices — their devices are then deleted along with everything they
managed to create. The command first says how many there are and asks for
confirmation, because this cannot be undone.
The very first invitation closes the server: from then on a device is created either with the shared code or with a valid invitation. Revoke every invitation and leave the shared code empty, and the server is open again.
It decides who gets in, and nothing more. Inside, everything works as usual:
limits come from plans, and a guest holding an issued key is not asked for a
code at all — the key itself is their access. It does not touch anyone's data
either: even someone who got in sees only their own, and neighbouring records
are unreadable to them.
Out of the box a phone registers anonymously: the server issues the device a
pair of credentials and counts limits against the device itself. The default
is 20 objects and 20 guest keys per device, the default row in the
plans table. They are changed right there:
UPDATE plans SET max_numbers = 50, max_keys = 100 WHERE name = 'default';
The numbers are baked neither into the app nor into the firmware: the server sends them on sync, and the app both displays them and refuses additions by them.
Room for accounting by person rather than by device is left in the server.
- the app can send a sign-in token at registration — the
account_tokenfield inapi/register_host.php; - a device attaches to a person through the
hosts.user_idcolumn; - limits are then taken from that person's plan —
users.plan_id→plans, with all of the logic inlib/account.php, functionaccount_limits(); - attaching or detaching an already registered phone —
api/host_account.php.
An example of a second plan sits in the same table as the account row.
Create your own rows and hand them out through users.plan_id — that alone is
enough for different people to get different limits.
Beyond that it is your territory. Who your users are, how they pay, what the cabinet shows, how a subscription is renewed: that is your server and your rules. The mechanism is complete and working; the billing scheme and the interface on top of it are not dictated here.
Accounts do not affect keys in any way: keys are encrypted on the phone, and the server does not see them with or without an account.
A controller goes to the address baked into its program at build time. By
default the builder inserts the address of the server it is built on, so
firmware built on yours connects back to yours. The address is set explicitly
by the ws_host field (and ws_port) in the request to
api/esp_compile.php; the configurator interface deliberately has no such
field, so that an ordinary person cannot send their controller to someone
else's machine with a stray click.
In the firmware the address is the WS_HOST constant; in the Raspberry and
Node scripts it is the WS_URL string. If you build firmware your own way,
change them there.
The apcu sources sit in the repository itself (vendor/), the worker's Go
dependencies sit in ws/vendor, and the build queries no external hosts beyond
the distribution repositories and the image registry. On a machine that may
only reach mirrors, that is enough.
apt-get update cannot resolve names. This happens on Ubuntu 24.04 and
other systems with systemd-resolved: containers cannot be handed the address
127.0.0.53, so Docker substitutes the gateway, which serves no queries. Fix
it with explicit name servers:
cat > /etc/docker/daemon.json <<'JSON'
{ "dns": ["1.1.1.1", "8.8.8.8"] }
JSON
systemctl restart docker
Ordinary containers resolve names, but the build still fails. The new builder has a network of its own and does not read the daemon settings. Build with the old one:
DOCKER_BUILDKIT=0 docker compose up -d --build
With an HTTP/1.1 client only — this matters. Through nginx the domain is
served over HTTP/2, where the Upgrade and Connection headers are
forbidden, so the upgrade is impossible by definition. Both curl and
websocat negotiate HTTP/2 over TLS on their own, and a perfectly configured
server looks broken. A client free of that trap is included:
python3 tools/ws_check.py wss://your-domain/ws # expect 101 and pong
python3 tools/ws_check.py ws://127.0.0.1:8095/ws # the same without the proxy
The same check with curl is fine, but pass --http1.1 explicitly.
The worker answers a handshake with Upgrade: websocket. Some panel setups
hide response headers, and the answer reaches the client without it. Browsers
and the Android client refuse such a handshake by the standard and reconnect
forever, while the page and the API keep working — which makes it look like a
problem in the app rather than in the proxy. In the app's log it reads as
Expected 'Upgrade' header value 'websocket' but was 'null'
tools/ws_check.py reports it explicitly, and the samples pass the header on
with proxy_pass_header Upgrade;. If you wrote the config yourself, add that
line to the /ws location, or remove whatever clears response headers there.
Almost all of the weight is the PHP base image; our part adds about thirty
megabytes to it. The same php:8.3-apache tag points at different builds at
different times, and the difference between them reaches two hundred
megabytes. Comparing sizes with another machine is therefore meaningless until
the base digest matches:
docker image inspect php:8.3-apache --format '{{index .RepoDigests 0}}'
docker history <image> # layer by layer: our layers cost next to nothing
The tag is deliberately not pinned to a digest: security updates in the base image matter more than reproducing a size down to the byte.
docker compose exec db mariadb-dump -u root -p"$DB_ROOT_PASS" entrixy > backup.sql
Keys are stored encrypted: the server does not read them, and nothing needs decrypting when moving to another machine.
spec/GUEST-PROTOCOL.md describes what a client must do to act as a guest:
how the signing pair is derived from a key, what a signed request looks like,
what the greeting on the live connection carries, and what the encrypted bundle
holds. It is written so that another client can be built from it alone.
Test vectors come with it — spec/guest-protocol-vectors.json — and
spec/check_vectors.py walks an implementation through them step by step:
python3 spec/check_vectors.py
spec/THREAT-MODEL.md says who the system defends against and who it does
not — the server operator, someone holding the link, someone on the network,
someone holding the phone — and names the places where this version is weakest.
The key point of the design is stated there plainly: the server never receives the string that opens a gate, neither when a key is issued nor when it is used. It keeps a fingerprint to find the key by and a public half to verify with.
The server is distributed under the GNU AGPL 3.0 — the full text is in
LICENSE. In short: use it however you like, including for your clients and
for money, but if you modify the server and people work with your modified
version over a network, the source of those modifications has to be available
to them. Terms for those to whom that does not suit are a separate
conversation with the author.
The protocol with its test vectors and the reference application are published separately and under Apache 2.0: their job is to let other implementations appear freely.
The name Entrixy and the logo are not granted by the license. Your own build gets its own name.