Guides
Subfolder hosting
https://example.com/newsletter, instead of a subdomain. Your site's reverse proxy forwards that path to FlockStack with a token, and the pages come back with every link under the path.How it works
A reader opens https://example.com/newsletter/p/some-issue. Your proxy strips /newsletter, forwards the rest to the newsletter's upstream with an X-FlockStack-Mount header carrying the mount's token, and FlockStack answers as if the request had come to the newsletter's own host. In the HTML it sends back, root-relative addresses (its scripts, styles, images, forms and the page counter) get /newsletter in front, so they come back through your proxy too and never meet your site's own files. Absolute addresses are left as they are.
The links in emails don't move: tracking, unsubscribe, confirmation and the other links stay on FlockStack's address, so an email already sent keeps working if your proxy breaks or you remove the subfolder.
Set it up
- In the newsletter's settings, under Subfolder on your site, enter the address and choose Set up, or call
set_newsletter_mount. You get the token once: store it in your proxy's secret store, never in a file you commit. - Add the route to your proxy, with one of the recipes below.
- Choose Check, or call
check_newsletter_mount. FlockStack askshttps://example.com/newsletter/.well-known/flockstack-mountthrough your site and expects its own signed answer back. Once it gets one, the website's links, canonical addresses, sitemap, feed and share cards move to the subfolder.
A newsletter has its own archive subdomain or a subfolder, not both. The path has one to three parts; the address is https on the default port.
Where your proxy forwards
The upstream depends on the server. With a shared domain it is the newsletter's own host, https://your-slug.<shared domain>; without one it is the newsletter's archive under the app's address, https://flockstack.io/archive/your-slug. This server uses https://flockstack.io/archive/your-slug, and the dashboard shows the exact one for each newsletter. Every recipe below comes in both shapes: the second adds /archive/your-slug after stripping the prefix.
Send the original host in X-Forwarded-Host (Traefik and Caddy do it by themselves), keep the request's method, body and cookies, and don't follow redirects for the browser.
Traefik (Coolify)
A router for the path, stripPrefix, a headers middleware that adds the token, and a service with passHostHeader: false so the upstream gets its own host name. The token is read from the proxy container's FLOCKSTACK_MOUNT_TOKEN environment variable (Traefik's file provider runs {{ env }} templates); on Coolify, set it on the proxy and restart it. The entry point and certificate resolver are the names Coolify uses.
# /data/coolify/proxy/dynamic/newsletter.yaml on a Coolify server: Traefik's file provider reads it as it changes.
http:
routers:
newsletter:
rule: Host(`example.com`) && (Path(`/newsletter`) || PathPrefix(`/newsletter/`))
entryPoints: [https]
priority: 1000
tls:
certResolver: letsencrypt
middlewares: [newsletter-strip, newsletter-token]
service: newsletter-flockstack
middlewares:
newsletter-strip:
stripPrefix:
prefixes: ["/newsletter"]
newsletter-token:
headers:
customRequestHeaders:
X-FlockStack-Mount: '{{ env "FLOCKSTACK_MOUNT_TOKEN" }}'
services:
newsletter-flockstack:
loadBalancer:
passHostHeader: false
servers:
- url: "https://your-slug.<shared domain>"# /data/coolify/proxy/dynamic/newsletter.yaml on a Coolify server: Traefik's file provider reads it as it changes.
http:
routers:
newsletter:
rule: Host(`example.com`) && (Path(`/newsletter`) || PathPrefix(`/newsletter/`))
entryPoints: [https]
priority: 1000
tls:
certResolver: letsencrypt
middlewares: [newsletter-strip, newsletter-prefix, newsletter-token]
service: newsletter-flockstack
middlewares:
newsletter-strip:
stripPrefix:
prefixes: ["/newsletter"]
newsletter-prefix:
addPrefix:
prefix: "/archive/your-slug"
newsletter-token:
headers:
customRequestHeaders:
X-FlockStack-Mount: '{{ env "FLOCKSTACK_MOUNT_TOKEN" }}'
services:
newsletter-flockstack:
loadBalancer:
passHostHeader: false
servers:
- url: "https://flockstack.io"nginx
location = /newsletter { return 308 /newsletter/$is_args$args; }
location /newsletter/ {
# The trailing slash replaces /newsletter/ with the upstream's path.
proxy_pass https://your-slug.<shared domain>/;
proxy_set_header Host your-slug.<shared domain>;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_ssl_server_name on;
# One line, proxy_set_header X-FlockStack-Mount "fsm_...";, in a file only root can read.
include /etc/nginx/secrets/flockstack-mount.conf;
}location = /newsletter { return 308 /newsletter/$is_args$args; }
location /newsletter/ {
# The trailing slash replaces /newsletter/ with the upstream's path.
proxy_pass https://flockstack.io/archive/your-slug/;
proxy_set_header Host flockstack.io;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_ssl_server_name on;
# One line, proxy_set_header X-FlockStack-Mount "fsm_...";, in a file only root can read.
include /etc/nginx/secrets/flockstack-mount.conf;
}Caddy
handle_path strips the prefix. The token comes from the FLOCKSTACK_MOUNT_TOKEN environment variable.
example.com {
redir /newsletter /newsletter/ 308
handle_path /newsletter/* {
reverse_proxy https://your-slug.<shared domain> {
header_up Host {upstream_hostport}
header_up X-Forwarded-Host {host}
header_up X-FlockStack-Mount {$FLOCKSTACK_MOUNT_TOKEN}
}
}
}example.com {
redir /newsletter /newsletter/ 308
handle_path /newsletter/* {
rewrite * /archive/your-slug{uri}
reverse_proxy https://flockstack.io {
header_up Host {upstream_hostport}
header_up X-Forwarded-Host {host}
header_up X-FlockStack-Mount {$FLOCKSTACK_MOUNT_TOKEN}
}
}
}Next.js (Vercel)
For a Next.js site, on Vercel or anywhere: a catch-all route handler under the path, since rewrites can't add the token header. Set FLOCKSTACK_MOUNT_TOKEN in the site's environment variables.
// app/newsletter/[[...path]]/route.js in a Next.js site (Vercel or any host): Next.js rewrites can't add a request header, so a
// route handler forwards the path with the token. FLOCKSTACK_MOUNT_TOKEN is an environment variable of the site.
const UPSTREAM = "https://your-slug.<shared domain>";
const PREFIX = "/newsletter";
const HOP = ["host", "connection", "content-length", "transfer-encoding", "accept-encoding"];
async function forward(request) {
const url = new URL(request.url);
const headers = new Headers(request.headers);
for (const name of HOP) headers.delete(name);
headers.set("X-Forwarded-Host", url.host);
headers.set("X-Forwarded-Proto", "https");
headers.set("X-FlockStack-Mount", process.env.FLOCKSTACK_MOUNT_TOKEN);
const body = ["GET", "HEAD"].includes(request.method) ? undefined : await request.arrayBuffer();
const answer = await fetch(`${UPSTREAM}${url.pathname.slice(PREFIX.length)}${url.search}`, { method: request.method, headers, body, redirect: "manual", cache: "no-store" });
const out = new Headers(answer.headers);
out.delete("content-encoding");
out.delete("content-length");
return new Response(answer.body, { status: answer.status, headers: out });
}
export const dynamic = "force-dynamic";
export { forward as GET, forward as HEAD, forward as POST, forward as PUT, forward as PATCH, forward as DELETE, forward as OPTIONS };// app/newsletter/[[...path]]/route.js in a Next.js site (Vercel or any host): Next.js rewrites can't add a request header, so a
// route handler forwards the path with the token. FLOCKSTACK_MOUNT_TOKEN is an environment variable of the site.
const UPSTREAM = "https://flockstack.io/archive/your-slug";
const PREFIX = "/newsletter";
const HOP = ["host", "connection", "content-length", "transfer-encoding", "accept-encoding"];
async function forward(request) {
const url = new URL(request.url);
const headers = new Headers(request.headers);
for (const name of HOP) headers.delete(name);
headers.set("X-Forwarded-Host", url.host);
headers.set("X-Forwarded-Proto", "https");
headers.set("X-FlockStack-Mount", process.env.FLOCKSTACK_MOUNT_TOKEN);
const body = ["GET", "HEAD"].includes(request.method) ? undefined : await request.arrayBuffer();
const answer = await fetch(`${UPSTREAM}${url.pathname.slice(PREFIX.length)}${url.search}`, { method: request.method, headers, body, redirect: "manual", cache: "no-store" });
const out = new Headers(answer.headers);
out.delete("content-encoding");
out.delete("content-length");
return new Response(answer.body, { status: answer.status, headers: out });
}
export const dynamic = "force-dynamic";
export { forward as GET, forward as HEAD, forward as POST, forward as PUT, forward as PATCH, forward as DELETE, forward as OPTIONS };Cloudflare Worker
For a site on Cloudflare: a Worker on a route for the path, with the token as a secret of the Worker.
// A route on example.com/newsletter*, with the token as a secret: npx wrangler secret put FLOCKSTACK_MOUNT_TOKEN
const PREFIX = "/newsletter";
const UPSTREAM = "https://your-slug.<shared domain>";
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === PREFIX) return Response.redirect(`${url.origin}${PREFIX}/${url.search}`, 308);
if (!url.pathname.startsWith(`${PREFIX}/`)) return fetch(request);
const headers = new Headers(request.headers);
headers.set("X-Forwarded-Host", url.host);
headers.set("X-FlockStack-Mount", env.FLOCKSTACK_MOUNT_TOKEN);
const target = `${UPSTREAM}${url.pathname.slice(PREFIX.length)}${url.search}`;
return fetch(target, { method: request.method, headers, body: request.body, redirect: "manual" });
},
};// A route on example.com/newsletter*, with the token as a secret: npx wrangler secret put FLOCKSTACK_MOUNT_TOKEN
const PREFIX = "/newsletter";
const UPSTREAM = "https://flockstack.io/archive/your-slug";
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === PREFIX) return Response.redirect(`${url.origin}${PREFIX}/${url.search}`, 308);
if (!url.pathname.startsWith(`${PREFIX}/`)) return fetch(request);
const headers = new Headers(request.headers);
headers.set("X-Forwarded-Host", url.host);
headers.set("X-FlockStack-Mount", env.FLOCKSTACK_MOUNT_TOKEN);
const target = `${UPSTREAM}${url.pathname.slice(PREFIX.length)}${url.search}`;
return fetch(target, { method: request.method, headers, body: request.body, redirect: "manual" });
},
};The token
A request that carries a token which doesn't match answers 403. A new token (New token, or rotate_newsletter_mount_token) leaves the previous one working for 24 hours, so you can change the proxy's secret without the website going down; the check says when your proxy still sends the previous one. Saving the address again also makes a new token, and waits for a new check.
What else changes
- Once the check passes, a page asked for at the upstream itself without the token is redirected (301) to the same page in the subfolder, so search engines see one address. The links in emails,
/api,/embed.js, the files and the feeds are never redirected. - Readers who sign in get their cookies on your site, host-only and with
Path=/newsletter, so the rest of your site never receives them. A sign-in link in an email opens on FlockStack's address and goes on to the same page in the subfolder, where the session starts. - Your proxy forwards the cookies of your own site under that path too. FlockStack reads only its own, but if your site keeps sensitive cookies at
Path=/, strip them in the proxy. - The rewriting runs in FlockStack's Worker, on FlockStack Cloud and on a self-hosted server deployed with Wrangler alike.
next devdoesn't rewrite pages.