root@frings-net:~# < back to overview
DEEN
DOCKER MACHINE TRANSLATED · ORIGINAL: GERMAN · DE

Relaunch under the hood: frings.net becomes bilingual – and much more secure

Security audit, DE/EN with automatic translation, new blog with cover photos and an editor that no longer loses anything. What I rebuilt – and which three mistakes locked me out in the process.

Actually, I just wanted to close a few security gaps. In the end, there was half a relaunch: a bilingual site, a new blog with cover photos, my own editor, local fonts, versioning – and three mistakes that I used to lock myself out of the admin panel in between. These are actually the most interesting things about this post.

Initial situation

frings.net runs as a lean PHP stack in a Docker container behind the Nginx Proxy Manager. No CMS, no database – contributions are in a JSON file, the admin panel is protected by IP whitelist and MFA. This is deliberately kept small, but it also means that I built every security function myself. And self-built security regularly deserves a second look.

Part 1: Security Audit

The audit has brought a lot to light – nothing dramatic, but enough to take it seriously:

  • Login & MFA: Stricter checks when setting up and changing the second factor, replay protection for TOTP codes, shorter validity of intermediate steps.
  • Reverse Proxy: The container is now only accessible via the proxy's Docker network, no longer via a published port. The X-Real-IPheader is now only accepted by the proxy.
  • Dependencies: An unused composer library with known CVEs flew out completely, PHPMailer is up-to-date.
  • Webroot: read-only mounted, inc/, vendor/ and metafiles locked via Apache, own 403/404 pages instead of bare error texts.
The best vulnerability is code that is not there in the first place. A library that no one calls up anymore can still be attacked – as long as it is in the webroot.

Part 2: German and English

The site is now completely bilingual: language switcher with flag, custom URLs (/blog/… and /en/blog/…), hreflangtags for search engines. On the first visit, the browser language decides, then a cookie.

I continue to write articles in German. When you save it, the English version is created automatically – and in such a way that code is never touched: code blocks do not leave the server in the first place, inline code, URLs and bot commands are protected from translation.

DeepL or Azure?

DeepL was planned. However, the current developer plan has a one-time credit of one million characters – without a monthly reset. For a blog that is supposed to run for years, this was too shaky for me.

I switched to Azure Translator in the Free tier (F0):

DeepL DeveloperAzure Translator F0
Contingent1 million characters unique2 million characters per month
If exceededCredit goneRequests are rejected, no costs
Quality DE→ENvery goodGood

In addition, there is a translation memory: Each paragraph is translated only once. If I change a sentence, only this sentence goes to the API again. DeepL remains switchable via .env .

English versions that I touch up by hand will never be automatically overwritten afterwards.

Part 3: New Blog, New Editor

Visually, everything remains in the terminal look, but the posts now have cover images – either their own image (is automatically cropped, EXIF data is removed) or a generated cover with keyword, category and prompt line. In addition, there is a table of contents, code blocks with copy button and syntax highlighting, hint boxes like this one and separate pages for each topic.

BASH
For shared links, the server generates a preview image (1200×630 PNG, rendered and cached via GD) from the auto-cover. So if you share a post on LinkedIn or Discord, you will now see an image instead of an empty box.

The new editor in the admin panel brings:

  • DE/EN tabs, live preview, and Markdown toolbar
  • Autosave every five seconds in the browser – a crashed tab no longer costs text
  • Versions: the last 20 states of each post, can be retrieved with one click
  • Trash: Deleted posts can be restored

Also new: RSS feed (/feed.xml, /en/feed.xml) and a sitemap with language variants.

Part 4: Three mistakes I learned from

1. The proxy that no one trusted

After the conversion, the ADMIN link was suddenly gone – even though I was sitting in the shared network. The cause was in a single line: The list of trusted proxies contained the IP of the website container itself, not that of the proxy. The site therefore ignored the X-Real-IPheader and saw every visitor as a proxy address.

The real lesson: Container IPs are not a stable configuration. In the meantime, the .env enters the container name of the proxy, which Docker resolves to the current IP in the common network:

BASH
TRUSTED_PROXY_IPS=nginx_proxy_manager-nginx-proxy-1

This means that exactly this one container is considered a proxy – even after a restart with a new IP.

2. PHP 8.5 shows bugs in the browser

After the update, there were suddenly Deprecatedmessages on the start page – including the server path. The trigger was harmless (imagedestroy() has been outdated since PHP 8.5), the actual cause was not: The official php:apacheimage does not come with php.ini**. Without configuration, display_errors is on On.

INI
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /dev/stderr

Since then, warnings have only ended up in docker logswhere they belong.

If you use an official PHP Docker image, you should check exactly that: php -r 'var_dump(ini_get("display_errors"));' in the container. If "1" comes back, every visitor will see your error messages.

3. The login mail that blocked the login

A new feature is an email for every successful admin login. After the deployment, I got stuck on the MFA page – and anyone who impatiently sent the code again ended up back at the login.

The procedure was: Code correct → session ID is renewed for security reasons → then send mail → only then send the forwarding. If the mail server dawdled, the browser waited. A second click arrived with the old, now invalid session.

The solution: first reply, then email.

PHP
session_write_close();
header('Location: admin.php', true, 302);
header('Content-Length: 0');
header('Connection: close');
flush();
notify_admin_login($method);   // laeuft jetzt nach der Antwort
exit;

In addition, the MFA page now prevents duplicate sending. Small cause, big effect – and a good example of the fact that side effects should never be in the request path of a login.

Small things with effect

  • Fonts local: Orbitron and Share Tech Mono now come from their own server instead of Google. No more visitor IP to third parties, one domain less in the content security policy.
  • Whitelist only via DynDNS: Static IPs are optional. Entries without which my current IP would be blocked cannot be deleted in the first place – self-lockout by click is thus excluded.
  • Unified Interface: Login, MFA, password reset, and editor now have the same terminal look as the rest of the admin panel.

Conclusion

Without a framework, you have every line in your own hands – for better or for worse. Most of the problems of this restructuring were not programming errors in the strict sense, but assumptions: that a container IP remains stable, that a Docker image comes with secure defaults, that a mail server responds quickly.

If you run a small PHP page in Docker yourself: Check display_errors, check who your proxy header trusts, and don't attach anything slow to your login. That's three minutes of work that saves you a lot of trouble.

By the way, this article is also available in German – automatically translated, of course.