Drop a folder, get a website. A small self-hosted static site publisher with a Czech and English UI. The language follows the browser (Czech/Slovak → Czech, anything else → English), and visitors can switch with the CS/EN toggle; their choice is remembered.
- Upload page (
/): drag & drop a folder (or a.zip), arrange it in a file tree (add files/folders, new folders, move, rename, replace, remove), look at a 5-minute preview atpreview-xxxxxx, then publish. - Accounts by e-mail, no passwords: a visitor enters e-mail, first and last name; the site goes live once they click the confirmation link, which also creates the account. Later sign-ins use one-time e-mail links. Signed-in users publish immediately.
- Editing: an existing site opens in the same file tree; replace single files, a folder or everything, add and remove, preview, save (atomic swap).
- Account page (
/ucet): sites, name, API tokens for curl/CI, activity history (with IPs, without e-mails), account deletion confirmed by e-mail. - CLI:
curl -H "Authorization: Bearer fp_…" -F name=my-site -F overwrite=1 -F f=@site.zip https://freepages.dev/api/sites. - Admin panel (
/admin, not linked anywhere, only for theADMIN_EMAILaccount, which signs in by e-mail link like everyone else): all sites with owners, users, activity history with IPs and e-mails, rename/edit/delete/purge sites and accounts, IP whitelist. - Retention: deleted sites and accounts stop being public immediately and are purged automatically after 90 days (
RETENTION_DAYS); the admin can purge at once. Activity history is capped at 12 months (AUDIT_DAYS). - IP whitelist: from IPs whitelisted in the admin panel (IP whitelist tab, including a one-click "add my current IP") publishing works without an account, with per-site passwords remembered in the browser (the original mode).
- Guide (
/docs), terms (/podminky) and privacy policy (/soukromi). - Each site gets its own subdomain, e.g.
https://<name>.freepages.dev/, or withoutSITE_DOMAINa path,PUBLIC_URL/<name>/. - SEO: every public page has a title, description, canonical URL, Czech/English
hreflangalternates (?lang=cs/?lang=en, the bare URL follows the browser), Open Graph/Twitter cards with share images, structured data on the home page,/sitemap.xmland/robots.txt. Account, admin, confirmation pages and previews arenoindex. Favicons (favicon.ico, SVG, Apple touch icon, PWA icons,site.webmanifest) are generated bynode scripts/make-icons.js. - Everything is stored in one Docker volume (
/data), including a SQLite database (Node's built-innode:sqlite, no native dependencies).
There are two compose files:
docker-compose.ymlis for Dokploy. It publishes no ports; Dokploy's Traefik reaches the container overdokploy-network. See Deploying on Dokploy.docker-compose.local.ymlis for plain Docker. It publishes portHOST_PORT(default 3000):
cp .env.example .env # set RESEND_API_KEY (and ADMIN_EMAIL, PUBLIC_URL / SITE_DOMAIN)
docker compose -f docker-compose.local.yml up -d --buildOpen http://localhost:3000 and drop a folder. The admin panel is at http://localhost:3000/admin; sign in there with ADMIN_EMAIL (in development the link is printed to the console).
Data lives in the publisher-data volume:
/data/sites/<name>/ published files
/data/freepages.db accounts, sites, activity history (SQLite, WAL)
/data/trash/ deleted sites, kept until purged
/data/pending/ uploads waiting for e-mail confirmation (24 h)
/data/previews/ 5-minute previews
Back up that volume and you have backed up everything. The privacy policy promises that backups are kept for at most 30 days, so rotate them accordingly.
Sites that existed before accounts were introduced are moved to the ADMIN_EMAIL account on the first start (their old passwords are dropped).
| Variable | Default | Meaning |
|---|---|---|
RESEND_API_KEY |
empty | API key of Resend (free tier: 3,000 e-mails a month). Without it no e-mails are sent, so nobody outside the IP whitelist can publish or sign in. |
MAIL_FROM |
Freepages <noreply@SITE_DOMAIN> |
Sender; must be on a domain verified in Resend. |
ADMIN_EMAIL |
martin@balcar.me |
The administrator account (name from ADMIN_FIRST_NAME/ADMIN_LAST_NAME, default Martin Balcar). Created on start; it signs in to /admin by e-mail link and owns the sites published before accounts existed. Changing it moves the admin role (and, if the new address has no account yet, the old admin account) to the new address. API tokens never get admin rights. |
RETENTION_DAYS |
90 |
How long deleted sites and accounts are kept before they are purged. |
AUDIT_DAYS |
365 |
Activity history entries are deleted after this. |
PREVIEW_MINUTES |
5 |
Lifetime of previews. |
PUBLIC_URL |
http://localhost:3000 |
Address of the publisher as the browser sees it, e.g. https://freepages.dev. Secure cookies are enabled when it starts with https. |
SITE_DOMAIN |
empty | Empty: sites live at PUBLIC_URL/<name>/. Set to freepages.dev: sites live at https://<name>.freepages.dev/ and the path URLs redirect there. |
PUBLISH_PER_HOUR |
60 |
Anonymous uploads allowed per IP address per hour (signed-in users and the admin are not limited). |
EMAILS_PER_HOUR |
10 |
E-mails per IP address and per recipient per hour. |
MAX_UPLOAD_MB |
100 |
Upload limit. Cloudflare Free/Pro rejects request bodies over 100 MB anyway. |
SITE_MAX_MB |
100 |
Largest size of one site (after unpacking a .zip and together with kept files). |
ACCOUNT_MAX_MB |
500 |
All sites of one account together (deleted sites don't count; the admin account has no limit). |
TOTAL_MAX_GB |
20 |
Everything the service stores, including deleted sites kept until purged. When full, uploads get HTTP 507. |
PATH_SANDBOX |
true |
Serve path-style sites with a CSP sandbox (see Security). |
- Create a free account at https://resend.com and add your domain (e.g.
freepages.dev) under Domains. Prefer the EU region (eu-west-1). - Add the DNS records Resend shows (SPF/DKIM TXT and MX on a
send.subdomain) in Cloudflare DNS. They must be DNS only (grey cloud). - Wait until the domain shows Verified, then create an API key with Sending access and put it into
RESEND_API_KEY. - In the domain settings, keep click and open tracking off, otherwise the one-time links are rewritten through Resend.
- Accept Resend's data processing agreement (DPA) in the account settings; the privacy policy relies on it.
The setup used here: the publisher runs on https://freepages.dev and every site gets its own subdomain, https://<name>.freepages.dev/. www.freepages.dev redirects to the publisher. Cloudflare's free Universal SSL covers both freepages.dev and *.freepages.dev.
-
Zone:
freepages.devmust be an active zone in your Cloudflare account, with the Universal SSL certificate showing Active (SSL/TLS → Edge Certificates). -
Tunnel: in Zero Trust → Networks → Tunnels → your tunnel → Public hostnames, add two hostnames with the same service:
Subdomain Domain Service (empty) freepages.devHTTP→publisher:3000*freepages.devHTTP→publisher:3000Use
publisher:3000when cloudflared runs in the same compose project. With Dokploy use Traefik instead:dokploy-traefik:80if cloudflared runs ondokploy-network, orlocalhost:80if it runs on the host. -
DNS: check that the zone has
*→CNAME→<tunnel-id>.cfargotunnel.com, Proxied. The dashboard often doesn't create the wildcard record; add it by hand if it's missing. The tunnel ID is on the tunnel's overview page. -
.env:PUBLIC_URL=https://freepages.dev,SITE_DOMAIN=freepages.dev.
Path-style links (freepages.dev/<name>/) redirect to the subdomain. To use another domain, change PUBLIC_URL, SITE_DOMAIN, the tunnel hostnames and (on Dokploy) the Traefik rule. Keep to one level of subdomains: a two-level wildcard like *.pub.example.com needs Cloudflare's paid Advanced Certificate Manager.
Without SITE_DOMAIN everything runs on a single hostname and sites live at PUBLIC_URL/<name>/. Then you only need the one tunnel hostname, but sites run sandboxed (see Security).
# .env: TUNNEL_TOKEN=... (from the Zero Trust dashboard)
docker compose -f docker-compose.local.yml --profile tunnel up -d --buildPoint the tunnel's public hostnames at http://publisher:3000.
- Create a Compose service from this repo. Keep the default Compose Path (
./docker-compose.yml). No host port is published, so it can't clash with other apps. - In Environment set
RESEND_API_KEY,PUBLIC_URL=https://freepages.dev,SITE_DOMAIN=freepages.dev(andADMIN_EMAILif it is notmartin@balcar.me). Without a working Resend key nobody can sign in, the admin included. - Deploy. Routing is done by the Traefik labels in the compose file, not the Domains tab, because they include the wildcard rule:
If you use other domains, edit that line.
Host(`freepages.dev`) || HostRegexp(`^[a-z0-9-]+\.freepages\.dev$`) - Point the Cloudflare Tunnel hostnames (
freepages.devand*.freepages.dev) at Dokploy's Traefik over plain HTTP:http://localhost:80if cloudflared runs on the host, orhttp://dokploy-traefik:80if it runs in Docker ondokploy-network. Cloudflare terminates TLS, so the router uses theweb(HTTP) entrypoint.
- Uploaded sites can contain arbitrary JavaScript. With subdomains each site has its own origin, so it is isolated from the admin panel. In path mode, sites share the admin's origin, so they are served with a CSP
sandbox(opaque origin). Their scripts can't use your session, but they also can't uselocalStorageor cookies. Prefer subdomain mode if your sites need those. - Publishing needs a confirmed e-mail address, and every account, upload, edit, sign-in and e-mail is logged with its IP address (visible in
/admin). Uploads and e-mails are capped per IP per hour. - E-mail links, sessions and API tokens are random 192-bit values stored only as SHA-256 hashes. E-mail links are used up by a button click on
/email/<token>, not by opening the link, so mail scanners can't consume them. - Site passwords (whitelist mode) are generated by the server (about 60 random bits) and stored only as salted hashes. Wrong site passwords are rate-limited per IP.
- State-changing API calls require a custom header plus a matching
Origin(requests with anAuthorization: Bearertoken are exempt: they carry no cookies). The session cookie isHttpOnly; SameSite=Lax; admin rights come only with that cookie, never with an API token. - Wrong passwords are rate-limited to
MAX_FAILED_ATTEMPTS(default 10) per IP per 15 minutes. - Every response, including published sites, carries a
Report-Abuse: martin@freepages.devheader, and every page links to abuse reports in the footer. - Upload paths are sanitized (
..is rejected), zip extraction counts uncompressed bytes against the limit, and a publish swaps directories atomically.
npm install
npm run dev # data in ./data, http://localhost:3000
# e-mails are printed to the console instead of being sentEnd-to-end tests use Playwright and click through the upload page, file tree editor, previews, e-mail confirmation and sign-in, the account page (tokens, curl, history, deletion), the admin panel (sites, users, history, purge, whitelist), whitelist password sites, the guide and the language switch. They also cover site serving, the Report-Abuse header and subdomain routing.
npm install
npx playwright install chromium # once
npm testnpm test starts three throwaway servers (path mode on :4317, subdomain mode on :4318, IP-whitelisted on :4319) with temporary data directories and MAIL_DEV_OUTBOX=1, so e-mails are read from /api/dev/outbox instead of being sent. It never touches real data.