fix: puter self host guides + default bug (#3281)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

* fix: oss dev center html_encode hardening

* fix: puter self host guides + default bug
This commit is contained in:
Daniel Salazar
2026-06-21 22:32:43 -07:00
committed by GitHub
parent 24d700673c
commit 954f8b7b8d
6 changed files with 148 additions and 20 deletions
+26
View File
@@ -199,6 +199,32 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
"s3": { "s3Config": { "publicEndpoint": "https://s3.puter.local", ... } }
```
## Running behind your own reverse proxy
The bundled `puter-nginx` mirrors production and is the supported default — it terminates TLS (Step 3) and forwards every Host to Puter. But plenty of self-hosters already run their own edge proxy (Caddy, Traefik, HAProxy, a cloud load balancer, another nginx). You can put Puter behind it instead; Puter doesn't terminate TLS itself in any setup, so "your proxy does TLS" is just a matter of pointing it at the Puter container and getting a handful of details right.
You can keep the bundled nginx as the single hop your proxy talks to, or bypass it and forward straight to the Puter container on port `4100` (uncomment the `4100:4100` mapping under the `puter` service in [docker-compose.yml](../docker-compose.yml), or attach your proxy to the compose network). Either works — what matters is the rules below.
**The rules that actually matter** (getting any of these wrong is what causes the redirect loops and "Invalid Host header" failures people hit):
1. **Don't rewrite the `Host` header.** Puter routes entirely on Host — `api.<domain>`, `site.<domain>`, `app.<domain>` are all distinguished by the incoming host. Forward the original host through unchanged. Do **not** rewrite external hostnames to an internal name like `puter.local`; that forces you to remap every subdomain by hand and still breaks signed-URL and CORS checks. Most proxies preserve Host by default — just don't override it.
2. **The external domain must equal `domain` in your `config.json`.** If users reach the box at `puter.example.com`, then `domain` must be `puter.example.com` and the hosting domains must be its real subdomains (`site.puter.example.com`, `app.puter.example.com`, …) — exactly as the installer/`config.json` lays them out. Puter rejects hosts it doesn't recognize with `Invalid Host header`.
3. **Set `protocol` to match the public scheme.** When your proxy terminates TLS, set `"protocol": "https"` (installer: `PUTER_PROTOCOL=https`). Puter builds its origins, redirects, signed S3 URLs, and OIDC callback URLs from this — leave it `http` behind an HTTPS proxy and you get redirect loops, broken logins, and mixed-content errors.
4. **Forward the standard proxy headers.** Puter needs:
- `Host` — the original request host (rule 1).
- `X-Forwarded-Proto` — the **external** scheme (`https`), so Puter knows TLS was terminated upstream.
- `X-Forwarded-For` — the real client IP (rate limiting + audit logs).
- `Upgrade` / `Connection` — passed through for WebSocket / socket.io upgrades, or the realtime connection silently fails.
5. **Set `trust_proxy` to the number of hops in front of Puter.** One external proxy talking directly to Puter → `"trust_proxy": 1` (installer: `PUTER_TRUST_PROXY=1`). A second proxy in front (e.g. Cloudflare → your proxy → Puter) → `2`. This is the count of proxies between the client and Puter, **including** the bundled nginx if you keep it. Too low and `req.ip` becomes a proxy address (breaks rate limiting); never set `true` (it trusts every hop and makes `X-Forwarded-For` forgeable).
6. **Route the wildcard to Puter.** Your proxy must forward `*.<domain>` (covering `api.*`, `app.*`, and `s3.<domain>`) and `*.site.<domain>` (and any other hosting domains) to Puter — same wildcard DNS as Step 2. Puter does the per-subdomain routing internally; the proxy just needs to hand it the traffic with the Host intact.
With those in place the rest of the stack is unchanged — `docker compose up -d` as below.
## Step 4 — Bring it up
```bash
+22 -10
View File
@@ -16,11 +16,20 @@
# the compose file + brings the stack up. Set PUTER_FORCE=1 to overwrite.
#
# Tunable env vars:
# PUTER_DIR install directory (default: ./puter-selfhosted)
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
# PUTER_PORT HTTP port for nginx (default: 80)
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
# PUTER_DIR install directory (default: ./puter-selfhosted)
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
# PUTER_PORT HTTP port for nginx (default: 80)
# PUTER_PROTOCOL public scheme: http | https (default: http)
# Set https when a TLS-terminating reverse proxy (Caddy,
# Traefik, nginx, a cloud LB) sits in front — Puter then
# builds https:// origins, S3 URLs, and redirects. See
# "Running behind your own reverse proxy" in doc/self-hosting.md.
# PUTER_TRUST_PROXY number of reverse-proxy hops in front (default: 1)
# 1 = the bundled nginx (or a single external proxy);
# 2 = two hops (e.g. Cloudflare → your proxy → Puter).
# PUTER_ENV prod | dev (default: prod)
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
set -eu
@@ -28,6 +37,9 @@ PUTER_DIR="${PUTER_DIR:-puter-selfhosted}"
PUTER_URL="${PUTER_URL:-https://raw.githubusercontent.com/HeyPuter/puter/main}"
PUTER_DOMAIN="${PUTER_DOMAIN:-puter.localhost}"
PUTER_PORT="${PUTER_PORT:-80}"
PUTER_PROTOCOL="${PUTER_PROTOCOL:-http}"
PUTER_TRUST_PROXY="${PUTER_TRUST_PROXY:-1}"
PUTER_ENV="${PUTER_ENV:-prod}"
PUTER_FORCE="${PUTER_FORCE:-0}"
log() { printf '\033[1;36m[puter-install]\033[0m %s\n' "$*"; }
@@ -117,9 +129,9 @@ EOF
cat > puter/config/config.json <<EOF
{
"domain": "$PUTER_DOMAIN",
"protocol": "http",
"protocol": "$PUTER_PROTOCOL",
"pub_port": $PUTER_PORT,
"env": "prod",
"env": "$PUTER_ENV",
"static_hosting_domain": "site.$PUTER_DOMAIN",
"static_hosting_domain_alt": "host.$PUTER_DOMAIN",
@@ -159,7 +171,7 @@ EOF
"s3": {
"s3Config": {
"endpoint": "http://s3:9000",
"publicEndpoint": "http://s3.$PUTER_DOMAIN",
"publicEndpoint": "$PUTER_PROTOCOL://s3.$PUTER_DOMAIN",
"accessKeyId": "puter",
"secretAccessKey": "$S3_SECRET_KEY",
"region": "us-east-1",
@@ -173,7 +185,7 @@ EOF
"ollama": { "enabled": false }
},
"trust_proxy": 1
"trust_proxy": $PUTER_TRUST_PROXY
}
EOF
fi
@@ -187,6 +199,6 @@ log "stack starting. first boot takes ~30s while MariaDB initialises."
log "follow puter logs:"
log " cd $PUTER_DIR && docker compose logs -f puter"
log ""
log "open http://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
log "open $PUTER_PROTOCOL://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
log "first-boot admin password is logged once — grab it with:"
log " cd $PUTER_DIR && docker compose logs puter | grep password"
@@ -264,6 +264,79 @@ describe('createUserSubdomainRedirect', () => {
]);
});
const selfHosted = {
domain: 'puter.localhost',
static_hosting_domain: 'site.puter.localhost',
static_hosting_domain_alt: 'host.puter.localhost',
private_app_hosting_domain: 'app.puter.localhost',
private_app_hosting_domain_alt: 'dev.puter.localhost',
} as IConfig;
it('still redirects a bare subdomain on the main domain to the hosting domain (self-hosted)', () => {
const { out, next } = run(
createUserSubdomainRedirect(selfHosted),
makeReq({
subdomains: ['localhost', 'puter', 'foo'],
host: 'foo.puter.localhost',
originalUrl: '/bar?x=1',
protocol: 'http',
}),
);
expect(out.redirectArgs).toEqual([
302,
'http://foo.site.puter.localhost/bar?x=1',
]);
expect(next).not.toHaveBeenCalled();
});
it('passes through hosts already on the static hosting domain (no redirect loop)', () => {
const { out, next } = run(
createUserSubdomainRedirect(selfHosted),
makeReq({
subdomains: ['localhost', 'puter', 'site', 'foo'],
host: 'foo.site.puter.localhost',
originalUrl: '/',
}),
);
expect(out.redirectArgs).toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
});
it('passes through hosts on the alt / private-app hosting domains too', () => {
for (const host of [
'foo.host.puter.localhost',
'foo.app.puter.localhost',
'foo.dev.puter.localhost',
]) {
const { out, next } = run(
createUserSubdomainRedirect(selfHosted),
makeReq({
subdomains: [
'localhost',
'puter',
host.split('.')[1],
'foo',
],
host,
}),
);
expect(out.redirectArgs).toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
}
});
it('passes through the hosting-domain root itself (exact match, no loop)', () => {
const { out, next } = run(
createUserSubdomainRedirect(selfHosted),
makeReq({
subdomains: ['localhost', 'puter', 'site'],
host: 'site.puter.localhost',
}),
);
expect(out.redirectArgs).toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
});
it("passes through when the request host has a port the configured domain doesn't", () => {
// Edge case worth pinning: the suffix check is exact-`endsWith`,
// so a port mismatch silently bypasses the redirect. Documenting
@@ -88,6 +88,15 @@ export const createUserSubdomainRedirect = (
if (!domain || !target) {
return (_req, _res, next) => next();
}
const hostingDomains = [
config.static_hosting_domain,
config.static_hosting_domain_alt,
config.private_app_hosting_domain,
config.private_app_hosting_domain_alt,
]
.map((d) => (d ?? '').toLowerCase().split(':')[0])
.filter((d) => d.length > 0);
return (req, res, next) => {
const active = (
req.subdomains?.[req.subdomains.length - 1] ?? ''
@@ -95,6 +104,14 @@ export const createUserSubdomainRedirect = (
if (active === '' || RESERVED_SUBDOMAINS.has(active)) return next();
const host = (req.headers.host ?? '').toLowerCase();
const hostName = host.split(':')[0];
if (
hostingDomains.some(
(d) => hostName === d || hostName.endsWith(`.${d}`),
)
) {
return next();
}
if (!host.endsWith(domain)) return next();
// host ends in domain — swap the domain suffix for the hosting one,
+5 -5
View File
@@ -253,12 +253,12 @@ function generate_website_card (website) {
return `
<tr class="website-card" data-name="${html_encode(website.subdomain)}">
<td style="width:30px; vertical-align: middle; line-height: 1;">
<input type="checkbox" class="website-checkbox" data-website-name="${website.subdomain}">
<input type="checkbox" class="website-checkbox" data-website-name="${html_encode(website.subdomain)}">
</td>
<td style="font-family: monospace; font-size: 14px; vertical-align: middle;"><a href="https://${website.subdomain}.puter.site" target="_blank">${website.subdomain}.puter.site</a></td>
<td style="font-size: 14px; vertical-align: middle;"> <span class="root-dir-name" data-root-dir-path="${website.root_dir ? html_encode(website.root_dir.path) : ''}">${website.root_dir ? website.root_dir.name : ''}</span></td>
<td style="font-size: 14px; vertical-align: middle;">${website.created_at}</td>
<td style="vertical-align: middle;"><img class="options-icon options-icon-website" data-website-name="${website.subdomain}" src="./img/options.svg"></td>
<td style="font-family: monospace; font-size: 14px; vertical-align: middle;"><a href="https://${html_encode(website.subdomain)}.puter.site" target="_blank">${html_encode(website.subdomain)}.puter.site</a></td>
<td style="font-size: 14px; vertical-align: middle;"> <span class="root-dir-name" data-root-dir-path="${website.root_dir ? html_encode(website.root_dir.path) : ''}">${website.root_dir ? html_encode(website.root_dir.name) : ''}</span></td>
<td style="font-size: 14px; vertical-align: middle;">${html_encode(website.created_at)}</td>
<td style="vertical-align: middle;"><img class="options-icon options-icon-website" data-website-name="${html_encode(website.subdomain)}" src="./img/options.svg"></td>
</tr>
`;
}
+5 -5
View File
@@ -271,12 +271,12 @@ function generate_worker_card (worker) {
return `
<tr class="worker-card" data-name="${html_encode(worker.name)}">
<td style="width:50px; vertical-align: middle; line-height: 1;">
<input type="checkbox" class="worker-checkbox" data-worker-name="${worker.name}">
<input type="checkbox" class="worker-checkbox" data-worker-name="${html_encode(worker.name)}">
</td>
<td style="font-family: monospace; font-size: 14px; vertical-align: middle;">${worker.name}</td>
<td style="font-family: monospace; font-size: 14px; vertical-align: middle;"><span class="worker-file-path" data-worker-file-path="${html_encode(worker.file_path)}">${worker.file_path ? worker.file_path : ''}</span></td>
<td style="font-size: 14px; vertical-align: middle;">${worker.created_at}</td>
<td style="vertical-align: middle;"><img class="options-icon options-icon-worker" data-worker-name="${worker.name}" src="./img/options.svg"></td>
<td style="font-family: monospace; font-size: 14px; vertical-align: middle;">${html_encode(worker.name)}</td>
<td style="font-family: monospace; font-size: 14px; vertical-align: middle;"><span class="worker-file-path" data-worker-file-path="${html_encode(worker.file_path)}">${worker.file_path ? html_encode(worker.file_path) : ''}</span></td>
<td style="font-size: 14px; vertical-align: middle;">${html_encode(worker.created_at)}</td>
<td style="vertical-align: middle;"><img class="options-icon options-icon-worker" data-worker-name="${html_encode(worker.name)}" src="./img/options.svg"></td>
</tr>
`;
}