Skip to main content
Trinity
Getting Started/Deploying Trinity/Hardening a Marketplace Install

Hardening a Marketplace Install

Take a one-click Trinity droplet from a bare public IP to an instance you would leave running: a real domain, then a Cloudflare Tunnel, a private network, or both, with the public ports closed behind you.

When to Run This

You created a Trinity instance from a cloud marketplace listing, or with the DigitalOcean install script, and it answers at a bare IP address.
The instance is about to hold real work, real credentials, or other people's data.
You want a memorable address instead of an IP, and an ordinary long-lived certificate instead of the short-lived IP one.

Not for you if Trinity already runs on a private network — the managed fleet's own shape — or behind a reverse proxy you operate. Those are finished postures, not compromises.

What You Start With

ComponentState on first boot
Caddy on ports 80 and 443A browser-trusted Let's Encrypt certificate for the droplet's IP address, so there is no warning and no domain required. Certificates on this profile last about six days and renew while the server runs — an instance switched off for longer comes back to a browser warning until renewal catches up.
Host firewallInbound 22, 80 and 443 only.
Container portsNot reachable from off-box. Docker publishes past ordinary firewall rules, so Trinity installs its own rules that drop anything arriving at a container from outside. The backend, MCP server and log collector are reachable only through Caddy, or from the droplet itself.
Admin accountOn a 1-Click droplet created without a password: none until someone claims it in a browser — whoever opens it first becomes the admin. The install script, or a password supplied at create time, provisions it at first boot instead.

Everything except the web interface is already closed. What stays open is Trinity itself: the web UI and the API answer anyone on the internet who finds the address, and your login is the only thing in the way.

Pre-flight

The instance is claimed — if nobody has created the admin account yet, do that first.
You own a domain and can edit its DNS records.
For the tunnel path: that domain's DNS is hosted by Cloudflare, and you can sign in to the Cloudflare Zero Trust dashboard.
For the private-network path: nothing outside needs to call your instance — see Step 2.
For both: you are willing to keep the tunnel's published paths up to date as you add channels.
Shell access to the droplet for Step 2 — over SSH, or the provider's web console.

Step 1: Give it a real name

Create an Arecord for the name you want, pointing at the droplet's IPv4 address, and confirm it resolves:

dig +short your-domain.com
# Expected: the droplet's IP address

Then, in Trinity: Settings → General → Public URL, enter the full address including https://, and save. No terminal step, no certificate to install.

What saving it does:

Trinity hands out that name instead of the IP everywhere it publishes an address — Telegram, WhatsApp and VoIP callbacks, Slack's OAuth return, public chat links, workspace links, file downloads.
It authorises the web server in front to obtain a certificate for that one name. Caddy asks Trinity whether a name is allowed before requesting a certificate, and Trinity answers yes only for the saved name — so nobody else can point a domain at your droplet and have certificates issued on your account.
It re-registers existing Telegram webhooks and rewrites WhatsApp binding URLs to the new base immediately. On a name that is not live yet, working bots move to an address that answers nothing, which is why the DNS record comes first.

The certificate is obtained on the first request that arrives for the name, so visiting the site is what completes this step. Until someone does, Trinity says so: Settings reads saved, waiting for the first visit, and the first-run Secure this instance step shows Domain saved instead of Domain reached. (Saving alone marks that step done in the setup rail, so the step does not re-open.)

Step 2: Choose how it is reached

All three end with nothing listening on the public interface. They differ in who can still reach Trinity, and in what keeps working.

Tunnel onlyTailnet onlyBoth
Web UI, Workspace, MCPAnyone with the addressYour devices onlyYour devices only
Telegram, WhatsApp, VoIPWorkBrokenWork
Public chat links, agent websites, webhook triggers, paid chat, inbound agent-to-agentWorkBrokenWork
SlackWorksWorksWorks
NeedsA domain on CloudflareA Tailscale account and a device to connect fromBoth

Tunnel only — everything keeps working, and the address answers anyone who has it, with your login as the only thing in the way. Enough while you are trying Trinity out.

Tailnet only — the interface is yours alone, and anything that calls in stops working. Pick it when nothing does.

Both — the tunnel carries what calls in, the tailnet carries you. Pick it if you use Telegram, WhatsApp, voice, public chat links, agent websites or webhooks, and you want the interface private. Follow Step 2a, then Step 2b, then Step 2c for the one setting that differs.

Everything Broken above is a third party making a request to your instance, which is exactly what a private network stops. Slack is unaffected because Trinity connects outward to Slack. Telegram is on the broken list because Trinity registers a webhook for it.

Step 2a: Cloudflare Tunnel

cloudflared runs beside Trinity and dials outward to Cloudflare; traffic arrives back down that connection.

The Cloudflare-side setup — creating the tunnel, the ingress rules, the DNS record — is identical on a marketplace droplet. Follow Public Access → Cloudflare Tunnel Setup, then return here for the two things that differ on this install.

1. Put the token in .env:

sudo nano /opt/trinity/.env
# add or edit:
# TUNNEL_TOKEN=eyJ...

2. Restart through the installer, not docker compose:

cd /opt/trinity
sudo ./scripts/deploy/start.sh --hosted

A non-empty TUNNEL_TOKEN is treated as intent: the installer starts the tunnel profile and records it in .env, so later docker compose ... stop and ... logs act on the tunnel too. This is the same command used to update the instance, so it is safe to re-run.

Use docker compose restart, not down/up. docker compose down removes the trinity-agent-network, which orphans every running agent container — they keep running but lose their network and have to be removed and recreated. restart preserves both the agents and the network. The only times to use down are: (1) intentional full teardown, (2) recovering from a corrupted compose state.

Step 2b: Private network (Tailscale)

Trinity has no Tailscale integration — this is a host-level install that Trinity neither configures nor monitors. There is no one-click or invite mechanism to lean on: neither DigitalOcean nor Vultr lists a Tailscale app, and both apply marketplace images at create time only, so nothing can be added to a droplet that is already running. The install is two commands.

Reaching the web UI over a tailnet takes one setting — see Reaching the UI over the tailnet below, after the install.

Before you start, in the Tailscale admin console → KeysGenerate auth key: make it reusable, set an expiry (90 days is the maximum Tailscale allows), tick pre-approved if your tailnet has device approval on, and leave ephemeral off — ephemeral devices are removed 30–60 minutes after they go quiet, which would evict a server.

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --auth-key=tskey-auth-XXXX --ssh --hostname=trinity
tailscale ip -4

--ssh matters: it is what still gives you a shell after the next step closes port 22, and it must be opted into per device.

Then turn off key expiry for this machine — admin console → Machines → the device's menu → Disable key expiry. New tailnets expire node keys after 180 days by default, and a server that silently drops off the tailnet with port 22 already closed leaves the provider's recovery console as the only way back in. This is a required step, not a nicety.

Only once tailscale ip -4 answers and you have confirmed you can SSH over the tailnet:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0
sudo ufw delete allow 22/tcp
sudo ufw enable

Order matters — the tailnet rule has to exist before the deny takes effect. (Tailscale's own guide enables the firewall first and writes the delete without allow, which does not match the rule as added.)

Two more things specific to this image:

Install Tailscale after provisioning, never before. The installer resets the host firewall when it provisions a machine, discarding rules you added by hand. If you re-run provisioning later, re-add these.
Container ports stay unreachable over the tailnet. 8000 for the API, 8080 for MCP, 8686 for the log collector — a firewall rule cannot open them, because Trinity's container rules are evaluated first.

Worth knowing: a new tailnet's default policy lets every device on your tailnet reach this one, not only the laptop you joined from. And Slack is the one integration that survives this path, because Trinity connects outward to Slack over a WebSocket — though installing the Slack app the first time still needs a public address for the OAuth callback.

Reaching the UI over the tailnet

By default there is nothing to browse to once the public ports are closed, and one setting fixes it.

Why the default leaves you stranded.The web server picks which site to serve by the hostname in the request, not by the interface it arrived on, and it has exactly two: the droplet's public IP, which it holds a certificate for, and the domain you saved, which it obtains one for on demand. A tailnet address matches neither. Trinity is asked whether a certificate may be issued for it and answers no — that refusal is what stops your instance being an open certificate requester for anyone who points a name at it — and the connection fails. It could not succeed anyway: tailnet addresses live in carrier-grade NAT space, which no public certificate authority can validate. The saved domain does not help either, because public DNS resolves it to the address you just closed off.

The fix: serve plain HTTP to the private network. The VPN already encrypts the transport, so there is nothing for TLS to add. In /opt/trinity/.env:

PRIVATE_NETWORK_CIDRS="100.64.0.0/10 fd7a:115c:a1e0::/48"

Those two ranges are the ones Tailscale hands out, so they are correct whatever address your droplet ends up with. WireGuard, Nebula and ZeroTier use different space — use your own. Then re-render the web server's configuration:

cd /opt/trinity
sudo ./scripts/deploy/start.sh --provision --cloud digitalocean --caddy-only

--caddy-onlyrewrites the web-server config and nothing else. A plain restart does not pick the variable up, and re-running the full provisioning would also rewrite this instance's recorded address and install provenance, so it is the wrong tool for a one-variable change. The generated configuration is checked before the web server is reloaded — an invalid one leaves the running config alone rather than taking the site down.

Now http://<tailnet-ip> serves Trinity. Requests from anywhere else keep redirecting to HTTPS exactly as before.

The rule matches the source address of the connection, not a header, so a request from the public internet cannot claim to be local to get the login page in cleartext. Setting the value to 0.0.0.0/0 is refused for the same reason.

If you would rather not serve any HTTP at all, an SSH tunnel over the tailnet works with no configuration: ssh -L 8443:127.0.0.1:8081 root@<tailnet-ip>, then browse http://localhost:8443. The tunnel exits on the droplet as a local connection, so it needs no certificate.

Why you cannot just browse the container port. Trinity's container firewall drops anything reaching a container from off-box, tailnet traffic included, so http://<tailnet-ip>:8081 is dropped. Ports 80 and 443 work because the web server in front is a host process, and host ports never pass through those rules.

Step 2c: Both — the tunnel carries the callbacks, the tailnet carries you

Do Step 2a and Step 2b, then change one thing about what the tunnel publishes.

In Cloudflare, publish paths rather than the whole hostname. Step 2a's quickest setup routes everything to the frontend, web interface included. Use the “Narrower: path-split rules” table in Public Access instead, and do not add the / catch-all row. The tunnel then carries webhooks, public chat links, agent websites and MCP, and has no route to the web interface at all.

Reach the interface over the tailnet, with PRIVATE_NETWORK_CIDRS set as in Step 2b.

You now have callbacks arriving over Cloudflare, the interface answering your devices only, and nothing listening on the public interface.

One thing to keep up. Connect a channel that needs a new inbound path and you have to publish that path too, or it stops delivering with no error here — the request never arrives. Verify after every channel you add.

To keep the interface public but behind a sign-in instead, Cloudflare Access challenges visitors before the request reaches the tunnel. It is set up on the Cloudflare side and their documentation covers it. Every machine caller then needs an exception, or it is challenged and fails.

Step 3: Close the public ports

Once the tunnel is connected, or you can reach the instance over the tailnet, add a cloud firewall blocking inbound 80 and 443. Leave 22 reachable from your own address only, or use the provider's console for shell access.

One cost to know about.Closing 80 and 443 also stops Caddy renewing the certificates it holds for the droplet's IP and for your domain — renewal needs those ports. Behind a tunnel that does not matter day to day, because visitors arrive over Cloudflare's certificate rather than Caddy's, but the local ones expire and the logs will say so. If you later reopen the ports and connect directly, expect a browser warning until renewal catches up.

Verify

Run these as you go. If a check fails, do not close the ports — see Recovery.

CheckCommandExpected
DNS points heredig +short your-domain.comThe droplet's IP
The domain serves TrinityOpen https://your-domain.com in a browserTrinity loads, valid padlock, no warning
Trinity agrees it is liveSettings → General → Public URLThe address with a tick, not “saved, waiting for the first visit”
Tunnel connected (2a)docker logs trinity-cloudflared | tail -20Registered tunnel connection
Traffic is on the tunnel (2a)Block inbound 80/443, reload the siteThe site still loads
Platform healthycurl -s http://localhost:8000/health{"status":"healthy",...}

The full six-probe check is in Monitoring — run it if anything above looks wrong.

Recovery

SymptomCauseFix
Certificate error on the domainThe request is not reaching this droplet, so no certificate was ever obtainedRe-check the A record, then that 80 and 443 are open to the internet (a cloud firewall added during the claim window is the usual cause)
Settings still says saved, waiting for the first visitNothing has arrived at that name yetLoad the site in a browser. If it still does not flip, treat as the row above
Telegram or WhatsApp stopped delivering after saving the domainThe webhooks were re-pointed at the new address before it was liveConfirm the domain loads, then re-save the Public URL to re-register them
Site unreachable after closing 80/443The tunnel is not carrying trafficReopen the ports, check docker logs trinity-cloudflared, re-check the ingress rules, then close them again
MCP client cannot connect over the tailnetIt is pointed at a container port, which the container firewall dropsPoint it at https://your-domain.com/mcp, or tunnel to 127.0.0.1:8081 over SSH
Browser cannot reach the UI after joining a tailnet and closing the portsA tailnet address has no certificate and cannot get oneSet PRIVATE_NETWORK_CIDRS and re-render — Reaching the UI over the tailnet

Clearing the Public URL stops Trinity handing the name out, but if the address was also baked into the server's environment at install time, the web server keeps serving certificates for it. Settings then reads not configured while that is still true — change the environment value and restart if you need it genuinely gone.

The Short Version

The marketplace default is for evaluation. It answers anyone who finds the address.
Add a domain as soon as the instance is more than a test, and confirm it by loading the site.
Keep a real instance off the open internet. A tunnel leaves the interface answering anyone with the address; a tailnet makes it yours but stops anything calling in. Run both to get both.
Closing 80 and 443 afterwards is the step that makes it real.