Back to home

Install

Put Atria in front of your backend

Five steps on a Debian or Ubuntu host: install the package, place the proxy certificate, name your agents in policy, start the service. Nothing to compile, and no call to us on the request path.

This page is for the team running the proxy. If you are writing an agent that needs to call one, you want the sandbox walkthrough instead — no install, no account.

  1. 1

    Install the package

    On Debian or Ubuntu, add the apt repository — signed with Atria’s own key, pinned to this one source with signed-by, since a repository key should only ever be trusted for its own repository. On RHEL, AlmaLinux or Rocky Linux 9 and later, install the .rpm directly; upgrade later by running the same two commands. Both packages install the same files and the same service, so every step below applies to either.

    Debian, Ubuntu — apt
    curl -fsSL https://apt.trustatria.com/trustatria.gpg.key \
      | sudo gpg --dearmor -o /usr/share/keyrings/trustatria.gpg
    
    echo "deb [arch=amd64 signed-by=/usr/share/keyrings/trustatria.gpg] \
    https://apt.trustatria.com atria main" \
      | sudo tee /etc/apt/sources.list.d/atria.list
    
    sudo apt update && sudo apt install atria-proxy
    RHEL, AlmaLinux, Rocky Linux 9+ — dnf
    curl -fsSLO https://apt.trustatria.com/rpm/atria-proxy-latest.x86_64.rpm
    sudo dnf install ./atria-proxy-latest.x86_64.rpm

    The service is enabled but deliberately does not start. It has no certificates and no real policy yet, and a proxy that started anyway would either refuse everything or — worse — accept traffic it cannot properly judge. The package tells you exactly which files are missing.

  2. 2

    Place the proxy certificate

    The Atria CA ships with the package — it is what lets the proxy verify agents offline, with no call to us on the request path. The proxy’s own certificate is issued by the same CA: generate it on dash.trustatria.com with the hostname your agents connect to and the destinations it may forward to (both default to localhost). It is the proxy’s TLS identity and the key it signs its own calls to us with — the only certificate the proxy holds. It lives two weeks: generate the next one there and run sudo systemctl reload atria-proxy.

    /etc/atria/
    server-cert.pem    # the proxy's certificate — generate on dash.trustatria.com
    server-key.pem     # its key — shown once when generated, kept only here
    # the Atria CA is already at /usr/share/atria/atria-ca.pem
    
    sudo chown root:atria /etc/atria/server-key.pem
    sudo chmod 0640 /etc/atria/server-key.pem

    The package creates the atria system user and locks /etc/atria to 0750 on install, so the private key is readable by the service account and nobody else. A renewed certificate is applied with sudo systemctl reload atria-proxy — no restart.

  3. 3

    Point it at your backend

    Read by systemd as an EnvironmentFile, so plain KEY=value — no shell quoting, no expansion. dpkg keeps your edits across upgrades. Set the default route; the defaults are sane for everything else.

    /etc/atria/atria-proxy.env
    ATRIA_LISTEN_ADDR=0.0.0.0:8443        # where agents reach you
    ATRIA_UPSTREAM_URL=http://127.0.0.1:9090   # the default route — set it: unset
                                               # means the public sandbox, not you
    
    # Optional: let an agent pick a backend per request with a
    # TA-Proxy-Pass: <host> header. The host must be in BOTH the
    # certificate's scope set (fixed when its API key was issued) and
    # the list below. Unset = *, i.e. trust whatever the certificate allows.
    #ATRIA_ALLOWED_DESTINATIONS=*.internal.acme.com,api.partner.com
    
    ATRIA_CRL_URL=https://api.trustatria.com/crl
    CRL_PULL_INTERVAL=60s
    
    # Loopback on purpose — this listener has no authentication of its own.
    ATRIA_ADMIN_ADDR=127.0.0.1:8081

    Without ATRIA_CRL_URL the proxy cannot learn which certificates were revoked, and stamps crl_stale on every affected audit line rather than refusing traffic. Revocation fails open; policy fails closed.

  4. 4

    Say what each agent may do

    Keyed by agent id, taken from the subject of the agent’s Atria certificate. An agent with no entry here is denied everything — there is no implicit allow, so nothing works until you name one.

    /etc/atria/policies.yaml
    agents:
      acme-procurement-v2:
        allowed_endpoints:
          - method: POST
            path: /api/v1/orders      # this path, not the subtree
          - method: GET
            path: /api/v1/orders/*    # write /* if you mean below it
    
        rate_limit_per_minute: 100        # per agent, across all its certificates
        max_transaction_value_usd: 1000.0 # any transaction_value in the body, in USD

    A path that only matches after normalisation — //api/v1/orders, /api/v1/../admin — is rejected rather than guessed at, so the proxy and your backend can never disagree about what a request addresses.

  5. 5

    Start it

    The proxy validates its whole configuration before it touches the network, so a missing file or a mismatched certificate and key is reported as that — not as a confusing connection error later.

    shell
    sudo systemctl start atria-proxy
    journalctl -u atria-proxy -n 20      # one line says why, if it did not

Behind nginx

When something in front of you already terminates TLS

The agent’s side does not change. It holds the same Atria certificate and opens the same mutual TLS connection it would to a proxy on its own port. What changes is only whether that handshake reaches the proxy — and there are two ways to put nginx in front, of which the first needs nothing from anybody.

1. nginx forwards TCP — mTLS reaches the proxy untouched

If nginx is there to share port 443, route by hostname, or sit at the edge — not to inspect HTTP — let it forward the connection without opening it. The handshake then happens between the agent and the proxy exactly as it does without nginx: same certificate, same client auth, same per-request revocation check. The only proxy setting that changes is where it listens.

shell — the stream module, a separate package on both families
sudo apt install libnginx-mod-stream     # Debian, Ubuntu
sudo dnf install nginx-mod-stream        # RHEL, AlmaLinux, Rocky
/etc/nginx/nginx.conf — top level, beside http { }, never in conf.d/
# A stream block, not an http one: nginx never opens the connection.
stream {
    map $ssl_preread_server_name $atria_backend {
        agents.example.com  127.0.0.1:8443;   # the proxy
        default             127.0.0.1:8444;   # every other HTTPS site
    }

    server {
        listen 443;
        listen [::]:443;
        ssl_preread on;        # routes by SNI without terminating TLS
        proxy_pass $atria_backend;
    }
}

# Port 443 is the stream block's now: each existing HTTPS site's
# http server { } moves to the default route —
#     listen 127.0.0.1:8444 ssl;
# (no other HTTPS sites on this host: drop the default line)
/etc/atria/atria-proxy.env
ATRIA_LISTEN_ADDR=127.0.0.1:8443        # only nginx reaches the proxy
shell — apply (RHEL family with SELinux: the first two lines)
sudo setsebool -P httpd_can_network_connect 1      # nginx connects onward
sudo semanage port -a -t http_port_t -p tcp 8444   # and may listen on 8444

sudo nginx -t && sudo systemctl reload nginx
sudo systemctl restart atria-proxy

conf.d/ is included inside http { } on both families, where a stream block is an error — hence nginx.conf itself. The proxy still terminates TLS with its own certificate, so that certificate must name the hostname agents dial (entered when you generate it). Leave proxy_protocol off: the proxy does not read it, and the handshake would fail.

2. nginx terminates TLS — the certificate travels in a header

Sometimes it must: a WAF, a CDN, an ALB, or an nginx already serving other paths on that hostname. Then whatever completed the handshake saw the client certificate, and the proxy behind it sees plaintext HTTP with nothing attached. The agent still uses the same certificate — it presents it as a proof in the TA-Authorization header, signed per request and bound to the method, the URL and the body, so a copied header is useless anywhere else. Verified against the same pinned CA and the same revocation list as before; nothing about issuance changes, and neither does what the proxy decides.

/etc/atria/atria-proxy.env
ATRIA_AUTH_MODE=proof
ATRIA_CA_CERT=/usr/share/atria/atria-ca.pem     # still required — ships with the package
ATRIA_PUBLIC_URL=https://agents.example.com    # what your agents address
ATRIA_LISTEN_ADDR=127.0.0.1:8443               # nginx is the only caller

# ATRIA_TLS_CERT / ATRIA_TLS_KEY: keep them. Not served here — nginx
# holds TLS — but still the proxy's identity: it signs its calls to us
# and names where the proxy may forward. The CA it pins is required too.

ATRIA_PUBLIC_URL is the one easy thing to get wrong. Every request carries a proof bound to the URL it was minted for, and the proxy compares against this value — never against the request’s own Host header, which the caller controls. Point it at the internal address and every request is refused.

nginx.conf
server {
    listen 443 ssl;
    server_name agents.example.com;

    ssl_certificate     /etc/ssl/example/fullchain.pem;
    ssl_certificate_key /etc/ssl/example/privkey.pem;

    # Off, deliberately: nginx folds // into / by default, which would hide
    # an ambiguous path from the proxy. The proxy refuses those rather than
    # guessing, so that it and your backend can never disagree about what a
    # request addresses.
    merge_slashes off;

    location / {
        # No trailing slash, no rewrite, no sub_filter. The proof binds the
        # method, the URL and a hash of the body — anything that edits one
        # of them invalidates it, and the request is refused as forged.
        proxy_pass http://127.0.0.1:8443;
        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # Authorization carries the proof and is forwarded by default.
        # Only set this if something above cleared it:
        # proxy_set_header Authorization $http_authorization;

        # At least ATRIA_UPSTREAM_TIMEOUT, or nginx gives up first and your
        # audit log records a decision the caller never saw.
        proxy_read_timeout 60s;
    }
}

Two things this mode deliberately does not do. It does not accept a client certificate that nginx forwarded in a header ($ssl_client_escaped_cert) — a header any intermediary can set is not proof of possession, and the signed proof exists precisely because it is. And it does not fall back to mTLS: the header is required on every request, so there is no weaker path to strip down to. Having nginx verify the certificate as well (ssl_verify_client on against the Atria CA) is worth doing — it turns anything without an Atria certificate away at your edge — but it is a gate in front, not a substitute.

Day to day

Changing policy, and seeing what happened

Policy edits apply without a restart. A document that fails to parse is refused and the previous policy keeps enforcing — so check the reply rather than assuming it applied.

shell
curl -X POST http://127.0.0.1:8081/admin/policy/reload

curl http://127.0.0.1:8081/admin/policy    # what it currently believes
curl http://127.0.0.1:8081/admin/crl       # revocation list status

Every decision, allowed or blocked, is one JSON line in /var/log/atria/audit.log — the agent id from the certificate subject, the reason code, and whether the revocation list was stale when the call was judged. It is append-only, and it is deliberately not removed when the package is purged: it is your record of every authorisation decision the proxy made, and deleting it should be something you decide to do.

The admin listener has no authentication of its own. Keep it on loopback — /admin/crl/pull in particular is a request amplifier pointed at Atria Cloud.