Thirteen sites under 152-FZ in a day — and ten minutes down over a single dollar sign

This morning a Habr article surfaced about a new wave of Roskomnadzor enforcement on Russian sites — an automated bot scanning public sites for missing privacy / consent / cookie disclosure. Fines run 100–300 thousand rubles per violation. My tactical day stopped being tactical.

By the evening, thirteen csylabs sites were serving the same privacy policy, dev mirrors of every app were behind basicauth, and the operator record in the Roskomnadzor registry was correct. Along the way, ten minutes of production were down over a single dollar sign.

What the Roskomnadzor bot wants

Five things on every public site:

→ A privacy policy document → A separate consent document — not "a checkbox alongside the policy", but an actual second document → Two checkboxes per form — one for each document, both unchecked by default, submit blocked until both are ticked → A cookie banner on the first visit — analytics does not load until the user gives consent → A registry filing where the declared purposes and data categories match what the site actually does

I had the fifth one. The rest, partial. One site was correctly anchored to my company entity. One had a personal name in its policy as an artifact from when the page was written before the company was incorporated. The rest, nothing.

One package, thirteen sites

Built @csylabs/legal — a workspace package in the monorepo. Inside:

→ An OPERATOR constant — single description of the operator: legal name, INN, OGRN, registered address, person responsible for processing under Article 22.1 → getScope(app) — per-app config of what categories of data each site collects, on what basis, for how long → Render-agnostic policy and consent text generators — branded sites bring their own JSX, simpler ones use the default → Components — <ConsentCheckboxes> (the two-checkbox pattern), <CookieBanner> (accept/decline + Umami gate), validateConsent() for server-side checks

The idea is straightforward: there is one operator in the registry. If tomorrow the legal details change, one edit to OPERATOR redraws thirteen sites automatically.

Thirteen sites. Five public with forms or analytics, eight behind authentication. Each one gets /legal/privacy. The ones with forms also get /legal/consent and the two-checkbox pattern. The ones with Umami get a cookie banner.

vox got its operator rebrand done along the way — moved from a personal name to the company entity. I also pulled voice and audio out of the declared data categories: LiveKit relays in real time and stores nothing, so it is not personal data in fact, and should not be in the policy.

Caddy basicauth on dev mirrors

The Roskomnadzor bot scans public sites. Dev mirrors — with half-written policy text and test data — should not be visible.

(dev_basicauth) {
    basicauth {
        {$CADDY_DEV_USER} {$CADDY_DEV_PASS_HASH}
    }
}

A snippet in the Caddyfile, import dev_basicauth in each dev block. Credentials come in via env from .env; the password value is a bcrypt hash from caddy hash-password.

What went wrong

PR merged. GitHub Actions ran cleanly: built containers, updated the Caddyfile, rewrote .env, recreated Caddy. A minute later, every public site was down. Caddy was in a restart loop.

In the logs:

parsing caddyfile tokens for 'basicauth':
username and password cannot be empty or missing,
at /etc/caddy/Caddyfile:16
import chain ['(import dev_basicauth)']

I checked .env:

$ grep "^CADDY_DEV" /opt/csylabs/docker/.env
CADDY_DEV_USER=csydev
CADDY_DEV_PASS_HASH=

The hash was empty. Yet the GitHub Secret holds a correct $2a$14$FGVR.... Yet the workflow writes the heredoc with a single-quoted 'EOF', no shell expansion.

docker inspect csylabs-caddy --format '{{json .Config.Env}}':

"CADDY_DEV_PASS_HASH=",
"CADDY_DEV_USER=csydev",

Empty.

It took me a while, then it clicked. When docker compose reads .env for its ${VAR} substitutions in the YAML, it expands $ on the value itself, shell-style. A bcrypt hash like $2a$14$FGVR... looks to compose like $2 (no such variable, empty) + a + $14 (none, empty) + $FGVR (none, empty). After everything resolves, the value is "a", or empty, depending on which compose version handles those degenerate cases. For me, it landed as empty.

Caddy refuses to parse basicauth with an empty password. Restart, refusal, restart. Sites down.

Fix. Wrap the value in single quotes:

CADDY_DEV_PASS_HASH='$2a$14$FGVR...'

Inside single quotes, compose does not interpolate the value, it reads it literally. The hash makes it into the container intact, basicauth parses, Caddy comes up.

I patched production by hand over SSH — sed over .env, then docker compose up -d --force-recreate caddy. Sites came back a minute later. About ten minutes total from the first 502 to a 200 on the home page.

The workflow patch went in as a separate PR. The secret substitution moved out of the heredoc into a separate printf with explicit single quotes:

cat > /opt/csylabs/docker/.env << 'EOF'
... rest of secrets ...
CADDY_DEV_USER=${{ secrets.CADDY_DEV_USER }}
EOF
printf "CADDY_DEV_PASS_HASH='%s'\n" '${{ secrets.CADDY_DEV_PASS_HASH }}' \
  >> /opt/csylabs/docker/.env

Lesson logged separately: Docker Compose does not like dollar signs inside .env values. I knew this in 2020, forgot it by 2026. The compose docs cover it — one line in the middle of the env section, easy to miss.

(though you would think.)

What is closed today

/legal/privacy lives on all thirteen csylabs subdomains, with the same operator and the same details everywhere → Public sites with forms have the two checkboxes, server-side validation, and a cookie banner where Umami runs → vox is fully under the company entity, voice and audio are off the data list → Dev mirrors are behind basicauth, the Roskomnadzor bot does not see them → Workflow hotfix in: the next deploy writes .env correctly

What I did not do, and why

No DSAR endpoint. Article 14 gives subjects the right to access, correct, and delete their data, and an email channel with a 30-day SLA satisfies the letter of the law. I will build a structured form when real requests start coming in.

No signup-time consent gate in the authenticated apps. There are no external paying users in those apps yet; when onboarding starts, two checkboxes drop into the better-auth form as a five-minute change.

No package upgrades, no dependency reshuffles, no Hasura schema cleanup. The goal was to close one regulatory risk. Sweeping the rest of the codebase along the way is not the goal.

The job today was one thing. Done — minus a single dollar sign.

Related reading