Skip to content

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.

SetupTRUSTED_PROXIES
Compose, with Caddy, nginx or Traefik172.16.0.0/12
Docker Swarmthe overlay network’s subnet, or 10.0.0.0/8
Cloudflare in front of your proxyyour 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.