Reverse proxy
Kiku publishes plain HTTP on port 3000 and bundles no proxy and no ACME client. Your existing proxy terminates TLS. If you do not have one, Caddy is two lines.
HTTPS is effectively required
Session cookies are only marked Secure when APP_URL is https://. Serve the app over
plain HTTP on anything but localhost and the session cookie travels the network in the
clear — anyone who can see the traffic can read it and become that agent, in a product where
every internal note is one request away. The app logs a warning on every boot when APP_URL
is not https; it does not refuse to start, because a local trial run is legitimate.
APP_URL must match the URL you actually browse to, scheme and host, or the session cookie
is issued for a domain you are not on and the webhook endpoint Settings shows is wrong.
TRUSTED_PROXIES
The Contact form limits submissions per client IP. Kiku only sees the TCP peer, which behind a
proxy is the proxy. Set TRUSTED_PROXIES to the CIDR ranges your proxies connect from, and Kiku
reads the client from X-Forwarded-For. It walks the header from the right, skipping addresses
inside those ranges, and takes the first address outside them. A visitor who writes their own
X-Forwarded-For only adds entries on the left, which Kiku never reaches.
| Setup | TRUSTED_PROXIES |
|---|---|
| Compose, with Caddy, nginx or Traefik | 172.16.0.0/12 |
| Docker Swarm | the overlay network’s subnet, or 10.0.0.0/8 |
| Cloudflare in front of your proxy | your proxy’s range plus Cloudflare’s ranges |
On Swarm, publish Traefik’s ports with mode: host. The default ingress mode rewrites the source
address, so Traefik itself never sees the visitor.
An entry that is not a CIDR range stops the app from booting.
If the log says a request from an untrusted peer carried X-Forwarded-For, a proxy is in front
of Kiku that TRUSTED_PROXIES does not cover. Until you add its range, every visitor counts as
that proxy’s address and shares one limit. Kiku logs it once per process.
The Contact form needs APP_URL to be public
Visitors load the form from APP_URL, inside an iframe on your site. An install that keeps the
desk on a private network and fetches mail over IMAP must expose three paths to the internet for
the form to work: /form/*, /api/form/* and /embed.js.
Caddy
help.example.com { reverse_proxy localhost:3000}Correct unconfigured, including the event stream. Certificates are automatic.
Traefik
services: kiku: labels: traefik.enable: 'true' traefik.http.routers.kiku.rule: Host(`help.example.com`) traefik.http.routers.kiku.entrypoints: websecure traefik.http.routers.kiku.tls.certresolver: letsencrypt traefik.http.services.kiku.loadbalancer.server.port: '3000'Also correct unconfigured. Drop ports: from the kiku service if Traefik shares the Compose
network — nothing then needs to be published on the host.
nginx
nginx needs two lines that the other two do not, or the app looks frozen:
server { listen 443 ssl; server_name help.example.com;
# your ssl_certificate / ssl_certificate_key here
client_max_body_size 50M;
location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }
# Realtime updates. Without these two lines nginx buffers the stream and closes it # after 60s, and the inbox stops updating until you reload. location /api/events { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_buffering off; proxy_read_timeout 24h; }}proxy_buffering off and a long proxy_read_timeout on /api/events are the whole point of
this section. Kiku pushes inbox updates over Server-Sent Events; with buffering on, they
arrive in silent batches or not at all, and the symptom is an inbox that never changes while
mail is visibly landing. It is the likeliest “it’s broken” report there is.
client_max_body_size 50M is twice the 25 MB message cap on purpose: on the webhook path a
provider base64s the message inside a JSON envelope, so a legal 25 MB message arrives as
roughly 34 MB of body. Kiku enforces the real cap on the decoded message and answers 413
itself. nginx’s own default is 1 MB, which rejects almost every real attachment.