Self-hosting remark42 alongside an existing Caddy on VPS
Table of contents
Introduction#
I’m continuing the series reviewing comment systems for Hugo. After Self-hosting Cusdis with Caddy on VPS
it’s time for remark42
. Unlike Cusdis (which has a managed version at cusdis.com) and Disqus (which is SaaS only), remark42 does not offer a managed variant. The only path is self-hosting. If you want to try remark42 at all, this post covers exactly that setup.
The setup from the previous post has grown since then. To the Caddy dedicated to Cusdis (configured via a Caddyfile, ports 80/443 on the host) I added several more services. Each one had to be appended to that same Caddyfile, which grew tedious and typo-prone with more entries. So I switched to lucaslorentz/caddy-docker-proxy
: a single Caddy instance that builds its proxy configuration from Docker labels on the containers. Cusdis was migrated onto this pattern too.
I deploy remark42 straight into this new regime. The default remark42 repository assumes you will set up your own Caddy alongside the service, which would add a second reverse-proxy instance on the same host. I wanted to avoid that. Below I show how to plug remark42 into an existing caddy-docker-proxy via labels: no second Caddy, no port conflicts, the whole setup done in about fifteen minutes. The result: the umputun/remark42:latest image weighs 91 MB, consumes 14 MiB RAM at rest, and the BoltDB database after the first comment is 64 KB. Numbers that may surprise anyone who remembers Disqus.
Starting point: caddy-docker-proxy and the edge network#
In my setup, a separate compose.yaml runs lucaslorentz/caddy-docker-proxy
watching /var/run/docker.sock. On every container start it scans that container’s labels and builds a reverse proxy configuration from them.
services:
caddy:
image: lucaslorentz/caddy-docker-proxy:2.9-alpine
ports:
- "40080:40080"
- "40081:40081"
- "40082:40082"
# ... other reserved ports from the high range
environment:
CADDY_INGRESS_NETWORKS: edge
networks:
- edge
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- caddy_data:/data
- caddy_config:/config
networks:
edge:
name: edge
Every other service joins the edge network and declares via labels which port Caddy should publish it on. Cusdis, after being migrated to this pattern, looks like this:
services:
cusdis:
image: djyde/cusdis:latest
networks:
- edge
labels:
caddy: ":40080"
caddy.reverse_proxy: "{{upstreams 3000}}"
Caddy then adds a block: „listen on :40080, forward to container cusdis:3000". I apply the same pattern for remark42.
Step-by-step implementation#
1. Adapting compose.yaml#
The official remark42 compose publishes port 8080 directly to the host and expects a Caddyfile on the host. Neither of those assumptions fits my case. I rework the service in the same pattern as post-migration Cusdis: joining the edge network, Caddy labels instead of ports.
services:
remark42:
image: umputun/remark42:latest
container_name: blog-remark42
env_file: .env
volumes:
- remark42_data:/srv/var
restart: unless-stopped
mem_limit: 200m
networks:
- edge
labels:
caddy: ":40081"
caddy.reverse_proxy: "{{upstreams 8080}}"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
remark42_data:
networks:
edge:
name: edge
external: true
Two details worth pointing out:
container_name: blog-remark42, because without this entry Docker Compose generates names in the format<project>-<service>-<index>. With the default configuration this produces something likeblog-remark42-remark42-1, long and illogical. Overridingcontainer_namegives a short, predictable name. A conscious cost: you can no longer scale the service, but remark42 with BoltDB does not scale horizontally anyway./srv/varas the volume mount, because official remark42 examples sometimes show/var/lib/remark42, whereas the binary only writes to/srv/var. With the wrong mount, data would be lost on every restart despite the volume being present. Worth verifying on every install.
2. Configuring .env#
remark42 reads its configuration from environment variables. The minimum set for self-hosting looks like this:
# .env
REMARK_URL=https://remark42.example.com
SITE=blog
SECRET=<32+ random characters, e.g. from openssl rand -hex 32>
ALLOWED_HOSTS=self,https://example.com
# Anonymous auth, lowest friction for the reader, no OAuth
AUTH_ANON=true
# Fill in after first login (see step 4)
# ADMIN_SHARED_ID=
REMARK_URL is the public domain of the remark42 instance (must have TLS). ALLOWED_HOSTS is the allowlist of domains from which the widget can call the API. Add the domain of the blog that will embed the comments there. SECRET signs JWTs, so generate a strong random value and keep it in .env out of git.
I leave auth on anonymous for the start. The reader enters a nickname, remark42 creates a deterministic identifier from the hash of that name. No OAuth, no email, minimal friction. GitHub/Google OAuth can be added later if analytics show it’s worth it.
3. Running the service#
Standard commands are enough:
docker compose up -d
docker compose logs --tail=20 remark42
In the logs, look for confirmation that the service found its database and started:
[INFO] start server on port :8080
[INFO] bolt store for sites [{FileName:./var/blog.db SiteID:blog}]
[INFO] anonymous access enabled
[INFO] activate http rest server on :8080
Verification from outside:
curl https://remark42.example.com/ping
# expected: pong
4. Bootstrapping the admin#
ADMIN_SHARED_ID is the identifier of the user who will get administrative privileges. The format is <provider>_<hex> (for anon: anonymous_<sha1(nick)>). You need to log in first for that identifier to exist at all.
remark42 does not have a separate login panel with a form. The account is created through the widget embedded on a page, or directly through the auth endpoint. The simplest way is the second option, in the browser address bar:
https://remark42.example.com/auth/anonymous/login?user=admin&aud=blog
This sets the JWT cookie and redirects to a simple endpoint. You’ll find the identifier itself in the JWT payload (middle-part of the cookie, base64 JSON) in the user.id field. Paste it into .env:
ADMIN_SHARED_ID=anonymous_a1b2c3...
And restart the service so the variable takes effect:
docker compose up -d --force-recreate
The next login with the same nickname will produce a JWT with attrs.admin: true.
5. TRUSTED_PROXY: production security#
After startup, remark42 logs a warning:
[WARN] --trusted-proxy not set: forwarding headers are trusted from any client
and can be spoofed to bypass rate limiting / vote dedup
remark42 identifies the client by the X-Forwarded-For header so that it can enforce per-IP rate limiting and vote dedup. Without TRUSTED_PROXY it trusts that header from any source, so an attacker could inject a forged IP into a request and bypass the counters.
Check the CIDR of the Docker network Caddy sits on:
docker network inspect edge --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
# for example: 172.21.0.0/16
Add to .env:
TRUSTED_PROXY=172.21.0.0/16
After docker compose up -d --force-recreate the WARN disappears.
An important caveat: this flag alone is not full protection. TRUSTED_PROXY in remark42 mainly guards against attacks that reach the container while bypassing Caddy. Full protection against X-Forwarded-For spoofing also requires the reverse proxy in front of remark42 to strip the incoming header rather than just append its own entry. The remark42 docs describe this in the context of the project’s own Reproxy, but the rules are identical for Caddy: Configure with Reproxy
.
Results#
I measured the basic metrics right after startup and after adding the first comment:
| Metric | Value |
|---|---|
Image size umputun/remark42:latest | 91 MiB |
| RAM (idle) | 14 MiB / 200 MiB limit |
| RAM (after first comment) | 13 MiB, within noise |
| CPU (idle) | 0.01–2.4% |
| BoltDB (empty) | 40 KB |
| BoltDB (after first comment) | 64 KB (+24 KB) |
Full /srv/var (with avatars, backups) | 72 KB |
Cold-start (docker compose up -d) | ~1.0 s |
| Ping after startup | ~250 ms (network dominates, not remark42) |
A few things that surprised me. First, 14 MiB RAM at rest for a full comment server with a database is very little. remark42 is a statically linked Go binary with an embedded HTTP server and BoltDB in the same process. Zero runtime overhead, no Node.js, no PHP-FPM, no external database. The entire application state sits in a single .db file.
Second, 24 KB in the database per one comment. A blog with a thousand comments fits in less than 30 MB, and backup is docker run --rm -v remark42_data:/data -v $PWD:/backup alpine tar czf /backup/$(date +%F).tar.gz -C /data . and you can sleep soundly.
Multi-tenant: one instance for multiple blogs#
remark42 supports multiple sites natively, through a list in SITE and a correspondingly widened ALLOWED_HOSTS:
SITE=blog,docs,portfolio
ALLOWED_HOSTS=self,https://blog.example.com,https://docs.example.com,https://portfolio.example.com
Each blog sets its own site_id in its widget. remark42 keeps separate comment threads per site, all in the same BoltDB instance. A real advantage for someone with several projects: one VPS, one backup, one admin panel.
Worth seeing the trade-off right away: a remark42 outage means comment outage on all blogs at once. ADMIN_SHARED_ID is global, so one set of admins moderates everything. If that is not a problem, multi-tenancy in remark42 is as simple as this section shows.
When self-hosted remark42 makes sense#
Remark42 is a different philosophy from Cusdis, even though both are open source and „privacy-first". Cusdis has no session, every comment is a form to be approved by the admin, and cross-domain works without cookie gymnastics. remark42 builds full commenter identity (JWT, reputation, upvote/downvote, editing your own posts within a time window), so it gives a richer UX at the cost of a larger widget (over ten times heavier than Cusdis) and some configuration gymnastics around cross-domain.
I choose remark42 when I care about:
- Threading and voting: technical discussions gain from structure.
- Commenter identity across posts: reputation and the option to block a specific user.
- The ability to edit your own comment: readers are not afraid to click „Send" with a typo.
I choose Cusdis (or Giscus) when raw widget performance or avoiding sessions is the priority. A detailed numerical comparison will be in a separate post closing the series.
Summary#
Deploying remark42 alongside an existing caddy-docker-proxy boils down to three decisions: adapting compose.yaml (labels instead of ports), filling in .env (SECRET, SITE, ALLOWED_HOSTS, AUTH_ANON), and bootstrapping the admin through the login URL. The whole procedure takes about fifteen minutes and produces a service that consumes 14 MiB RAM and keeps state in a single database file.
Three things worth remembering:
- Watch the volume mount:
/srv/var, not/var/lib/remark42. A common mistake in official examples. TRUSTED_PROXYis mandatory in production if you do not want anyone bypassing rate limiting throughX-Forwarded-For.caddy-docker-proxy+ labels is a comfortable pattern for many services on one VPS. You clearly see in each container’s compose which port and which proxy it lives behind.
In the next post of the series I will show integration of the remark42 widget with Hugo, including a cross-domain solution, without needing to host remark42 under a subdomain of the blog.
References and further reading#
- remark42 – documentation – the project’s official docs
- remark42 on GitHub – Umputun’s repository
lucaslorentz/caddy-docker-proxy– a Caddy that reads its configuration from Docker labels- Self-hosting Cusdis with Caddy on VPS
– previous post of the series, a standalone Caddy setup with a
Caddyfile(starting point before migrating tocaddy-docker-proxy)
$ comments --load