Before you start
- Postern opens two ports, and it opens both to this computer only. Only this computer can reach these addresses. Nothing on your Wi-Fi, and nothing on the internet, can.
- The Console port has no password. Which computers can reach it is the whole of its protection.
- The protection is one line in
docker-compose.yml. Bothports:lines for theappservice must begin127.0.0.1:. - One route is meant to be reachable from outside. The push-ingest route, and it carries its own secret.
The two ports, and the one Postern never opens
The database gets no port in the compose file Postern ships. Only Postern reaches
it, over the private network Docker Compose creates. A port there would buy you
psql -h localhost and cost you a listener that holds every row Postern has
cached. If you need one to inspect the data, add it temporarily and keep it on
this computer: ports: ["127.0.0.1:5432:5432"]. Postern serves the Console, the
Console’s backend and the agent port from one address.
The Console has no authentication
Port 8787 serves the Console and the admin routes together. Three of those routes matter most: onboarding, credential paste, and the route that creates an agent key. The group carries no key, no password and no session. Theports: line in docker-compose.yml is what protects it. Open that file in
the folder you unpacked, find the app service, and look under ports:. Both
lines must begin 127.0.0.1:.
Host check sits in front of those routes: localhost, 127.0.0.1, ::1,
plus anything you add to REST_ALLOWED_HOSTS in .env. It checks the address
your browser used, and it stops a malicious web page that tries to reach Postern
through your own browser.
It is not a password. The Host header is a string the caller chooses, so it
identifies nobody. Anyone who can open a connection to that port can send
Host: localhost:8787 and create an agent key in one request. That request needs
no credential, and the key comes back in the answer. REST_ALLOWED_HOSTS only
widens which Host values Postern accepts, so it cannot make the port safe. Add a
tailnet name to it and every admin route opens to every device on your tailnet.
To reach the Console from another computer, leave the ports: line alone. Put
something that authenticates in front of it. It has to land on this computer: an
SSH tunnel, tailscale serve, or a reverse proxy that authenticates first.
Set up remote access is the procedure for each.
A tunnel does not move the Console. Your browser connects to your own computer.
SSH carries that connection to the machine Postern runs on, and it arrives from
the machine itself — the only kind of request Postern answers. The port stays
local and the tunnel carries the identity.
The rule is per route, not per port. Four other route groups share port 8787, and
Postern protects each one differently.
The agent port authenticates every call
Port 8788 is the agent port. MCP is the standard way AI assistants connect to outside tools. Postern speaks it, so Claude, ChatGPT and Cursor connect the same way. Every call carries a per-agent key in anAuthorization: Bearer header, and
Postern resolves it to a grant before anything runs. Postern refuses a missing,
unknown or revoked key at the handshake, before any tool exists to call.
Port 8788 is opened to this computer only. To let an agent reach it from
elsewhere, go to Console → Settings → Remote access. Do not edit the
ports: line.
A grant is the sector list attached to one agent’s key. A sector is one area of
your life: finance, mail, calendar, contacts, health, home. It is the smallest
thing you can grant. A new key can reach nothing until you tick a sector.
Postern denies an ungranted sector before it issues any SQL. It also refuses a
target whose first word is not one of the six, rather than map it onto a sector
you did grant.
A granted sector covers reads and actions together. Today only home has anything
to act on. What a grant covers
is the full model.
There is no rate limit and no lockout
A wrong key costs the sender nothing, on the agent port or on the REST read mirror. Nothing counts failures, nothing locks out an agent or an address, and nothing slows the next attempt. The defence is the 256 bits of randomness in the key. There is no second layer behind it. Postern records refused keys rather than drops them silently. The agent port writes a deny line for every key a caller presents and Postern refuses. The cap is 30 a minute, and one combined line stands in for whatever the cap swallowed. A caller that presents no key at all leaves no line, unless the OAuth bridge is on. The REST read mirror records no refused key at all — what does not get a row has the split. Postern has exactly one rate limiter and it is not here. It sits on the OAuth bridge’s own routes — registration, authorize, token — at 120 requests a minute. Postern sized it for availability, not for defence. Behind a Tailscale Funnel there is no client address to count against. The tunnel lands locally and Docker rewrites every connection to one source, so a per-caller limit becomes one global limit for the whole internet.MCP_TRUSTED_PROXIES in
.env changes that only when you run your own proxy in front, and only when that
proxy supplies a real client address. Leave it unset otherwise.
The cost is narrow: a stranger who finds your address can disrupt connection
setup. That exposes no data and puts no key at risk, and every read and action
still needs a key.
The push-ingest route
POST /api/ingest/:connectionId sits outside the Host check on purpose. The
device that pushes to it is a phone, and a phone is never the machine Postern
runs on. So whatever address you put in front of the path works. Three route
groups on 8787 carry no Host check: this one, /healthz, and the agent read
mirror. This is the only one of the three with neither the check nor a key.
A per-connection secret stands in for both. It is 32 random bytes, made the same
way an agent key is, and stored only as its SHA-256. A device sends it in either
an Authorization: Bearer header or an X-PCI-Webhook-Secret header. The Console
shows it once when it creates it. Postern cannot show you this secret again — not
the connection screen, not the log, not the database. If you lose it, press
Rotate secret on the connection. That creates a new one, and it stops every
device that still sends the old one.
Four properties make an internet-reachable route acceptable here:
- Postern checks the secret before it reads the body. Send a bad secret with a huge or malformed payload and Postern refuses it. It never holds that payload and never parses it.
- The comparison takes the same time whether the secret is right or wrong, so a guesser learns nothing from the speed of the answer. Postern compares two 32-byte digests, never two strings. A stored hash of the wrong length fails closed rather than throws.
- Every failure answers identically. Five causes all return the same
401and the same body,{"error":"unauthorized"}. They are an unknown connection id, a paused connection, a connection with no secret set, a wrong secret, and a malformed id. Nobody can use the route to learn what exists. That is also why a stuck device tells you nothing: the answer is in the record, on the machine Postern runs on. - Ownership comes from the connection row, never from the payload. Postern reads the user, the sector and the destination table from the database, against the id in the path. A pusher cannot choose whose account its records land in, or which table.
Host check.
What Postern connects out to
Postern pulls from your sources. No source has to reach the machine Postern runs on, and Postern opens no port for one. The push-ingest route above is the exception. Behind a strict outbound firewall, iCloud needs*.icloud.com and
imap.mail.me.com:993. A fixed list of hostnames is not enough. Apple spreads
accounts across partition hosts, and Postern follows Apple’s own discovery onto
p##-caldav.icloud.com and p##-contacts.icloud.com, where ## is your
account’s partition. Postern re-discovers that host on every call and stores none.
Postern checks every request address against *.icloud.com before it attaches
the app-specific password. That covers Apple’s internal discovery hops too. It
takes redirects one at a time, and it refuses any redirect that leaves
*.icloud.com rather than send the password again. Postern requires HTTPS and
refuses a downgrade to HTTP.
Reaching Postern from another network
Your tailnet is the private network Tailscale builds between the devices signed into your account. Nothing else can reach it. Postern documents Tailscale and nothing else, for four reasons:- Tailscale encrypts it end to end;
- it needs no port forwarded on your router;
- it works on internet connections that block traffic from outside;
- the certificate keys never leave the machine Postern runs on.
Three costs stay with you: that permanent hostname, a Tailscale account to keep,
and an address that stays live until you turn it off.
Set up remote access is the procedure.
Confirm it works
- Both
ports:lines for theappservice indocker-compose.ymlbegin127.0.0.1:. - On the machine Postern runs on,
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8787/healthzprints200. - From another computer on your network, the same command with that machine’s LAN
address in place of
127.0.0.1fails to connect. - Console → Agents & keys lists every key that can reach port 8788, and no others.
If something went wrong
What you have now
Two ports, both opened to this computer only, and a database with no port at all. The Console port’s protection is its address, so anything that widens which computers can reach it removes the protection entirely. The agent port asks for a key on every call. One route — push-ingest — is meant to be reachable from outside, and it carries its own secret.Next
Set up remote access
Tailscale + optional Funnel · about 20 minutes, once · off by default
Agent keys
what a key is, when it dies, and the four operator verbs