Your mailbox table has a row with a blank password field. You put it there at 23:45 last Thursday because you were “just testing” and you were going to fix it “in a minute.” It’s still there. I know because I’ve seen it on every mail server I’ve ever inherited, and I’ve inherited a lot of mail servers. ViMbAdmin is the web panel that finally broke me of that habit: a real interface for the Postfix and Dovecot virtual-mailbox tables, so you stop editing them by hand at quarter to midnight.
This is what happens when your mail server user management is “open a MySQL client and hope.” ViMbAdmin is the alternative, a web panel that sits between your caffeine-addled hands and the database that runs your virtual mail. Our modernised fork brings it up to PHP 8.5, adds a full JSON-RPC API, TOTP, brute-force protection, and a security posture that assumes, correctly, that the entire internet is trying to get in. Fork is here: github.com/eilandert/ViMbAdmin.
👉 Want to poke at it first? A live demo runs at vimbadmin.myguard.nl — the real panel, with password and 2FA changes locked and outgoing mail no-op’d for the demo account. Log in, click around, break nothing.

What ViMbAdmin actually is
Postfix and Dovecot do not care how the rows get into your database. They read virtual_mailbox_maps, they hash a password, they deliver mail, they go back to sleep. The database is the contract. How you maintain that contract is left, with magnificent Unix indifference, as an exercise for the reader.
Most people solve this one of three ways:
- Raw SQL, forever, on three hours’ sleep, one typo away from dropping the wrong domain.
- A full mail appliance that installs 47 things you didn’t ask for, writes its own Postfix config, and breaks every time you touch anything.
- ViMbAdmin.
ViMbAdmin is a CRUD app over three concepts: domains (@example.com, with quotas and mailbox limits), mailboxes (actual accounts with bcrypt-hashed passwords, maildirs, quotas), and aliases (the forwards, sales@ → wherever, postmaster@ → the person whose job it is to read bounces). It writes to the shared database. Postfix and Dovecot pick up the changes on their next lookup. That’s the whole transaction.
It does not configure Postfix. It does not install Dovecot. It does not filter spam, for that you want Rspamd. It is a component, deliberately narrow, which is exactly why it can be audited and trusted. Tools that try to do everything are the tools that have a buffer overflow in the “we didn’t think anyone would use this” codepath.
Mailbox passwords are hashed natively in PHP into exactly the format Dovecot stores — no doveadm pw binary, no shell-out, no network round-trip. It produces the crypt-family schemes PHP and Dovecot share, strongest first: ARGON2ID (memory-hard, Dovecot’s recommended modern scheme — set it if you hash out-of-band), then BLF-CRYPT (bcrypt $2y$, the recommended native default), then SHA512-CRYPT, then SHA256-CRYPT. If you’ve ever spent an evening debugging why your hand-rolled {SHA512-CRYPT} prefix was subtly wrong, you know what this is worth.
The history: perfectly good software, abandoned mid-sentence
ViMbAdmin was written by Open Solutions, an Irish shop, around 2010. Zend Framework 1, Doctrine ORM, Smarty, a perfectly sensible stack for the era. Then, as happens to a great deal of genuinely useful software, the commits slowed. Then stopped. The last meaningful upstream release predates several PHP versions that have since been born, matured, and declared end-of-life.
Run the stock code on PHP 8.x and you get a wall of deprecation notices, a couple of fatals, and the special kind of silence where a form should have rendered. It didn’t die so much as sit down, mid-sentence, and stop talking.
We needed it on PHP 8.5 on a hardened production stack. So we fixed it. Then, because leaving a five-year-dormant admin panel on the internet with no security review is how you become a cautionary tale, we kept going.
What we actually changed (the itemised version, not the press release)
PHP 8.5, Smarty 5, Doctrine ORM 3
The framework internals got a full transplant. Smarty 4 → 5: the templating layer changed under us in three distinct ways. It removed the public property API the old OSS_View_Smarty bridge poked at. It dropped bare PHP function calls inside {if} expressions. And, the landmine, its backward-compat plugin loader makes getPluginsDir() return an empty array, so the cloned view that Zend’s form-partial renderer uses silently lost every custom plugin. Forms rendered as blank space. No error, no warning, just nothing. We now track plugin directories manually and re-register them on clone.
Doctrine ORM 2.8 → 3.x (currently orm 3.6 / dbal 4 / persistence 4) renamed half the query API along the way. CLI bootstrap, fetchAll() calls, the works. Every function f(Type $x = null) implicit-nullable across the entity and proxy trees got the ?Type treatment, because Zend_Session promotes that particular deprecation to a fatal during session start, and nothing tanks user trust in a mail admin panel like a white screen at login. The jump to ORM 3 specifically needed native lazy-loading proxies (enableNativeLazyObjects — Symfony 8 retired the old var-exporter ghosts), PSR-6 caches in place of the deleted DoctrineProvider, an XSD-clean rewrite of the XML mappings, and a small object-type shim DBAL 4 dropped. Cache layer: doctrine/cache 2.x deleted its concrete providers, so the metadata/query cache now wraps a Symfony Cache PSR-6 pool. Without a persistent backend Doctrine re-parses entity mappings on every single request. With APCu it parses them once. The Docker image leaves doctrine2cache.type = auto, which picks APCu when the extension is present — for a single container that beats Redis, no socket, no hop. OPcache with validate_timestamps=0 and a preload script round it out.
Security: the actual list, not “we hardened it”
The stock app had no CSRF protection. None. Every form, every destructive link, wide open. We added a per-session token to the base form class, every form inherits it, Zend’s isValid() checks it for free, then guarded every destructive GET link (purge, delete, cancel, restore) with an explicit token check. Forge a request without the token: 403, redirect, no mailbox deleted.
Smarty was running with output escaping off. Every {$variable} was a stored-XSS waiting room. We flipped setEscapeHtml(true) globally and marked the genuinely-HTML outputs as nofilter. A description field containing <script>alert(1)</script> now renders as inert text. We tested that payload. It does nothing, which is the point.
SQL injection: Doctrine ORM with parameterised queries throughout, plus we deleted four unreferenced “OSS API” integration classes that were carrying actual SQL concatenation (one with a live injection). ~1,600 lines removed. Dead code in an admin panel is attack surface.
Command injection: every shell-out (doveadm, archive tar/bzip2/du) is escapeshellarg()‘d. Deserialisation: unserialize() of archive blobs is restricted with ['allowed_classes' => false]. Tokens and backup codes use random_int(), the old str_shuffle/mt_rand was replaced. CSRF: covered above. Every session ID is regenerated on login, and again after the 2FA step.
TOTP two-factor auth
Opt-in per admin at /admin/two-factor. Scan QR, confirm a code, save the one-time backup codes (they’re shown once, they work once each, write them down, or find out the hard way). The TOTP secret is stored encrypted at rest with libsodium, keyed off securitysalt. A database read yields nothing usable.
The lockout-yourself scenario: two escape hatches, no SQL required.
# phone dead, authenticator gone — CLI reset:
./bin/vimbtool.php -a admin.cli-reset-totp --username=you@example.com
./bin/vimbtool.php -a admin.cli-reset-totp --all # bad day
# or, applied at next login:
; in application.ini:
twofactor.force_disable = "you@example.com" ; or "*"
Brute-force protection
Per-source-IP attempt counter, configurable lockout window. A fully successful login (password + 2FA, both) clears the counter. Configure in application.ini:
bruteforce.enabled = 1
bruteforce.max_attempts = 5
bruteforce.window = 900 ; seconds counter accumulates over
bruteforce.lockout = 900 ; seconds locked out
bruteforce.whitelist[] = "127.0.0.1"
bruteforce.whitelist[] = "10.0.0.0/8"
If you’re behind a reverse proxy, see the trusted-proxy section below, the limiter needs the actual client IP, and there’s a right way and a very wrong way to get it.
Defence in depth: Snuffleupagus, ModSecurity, hardened configs
Application-layer fixes are necessary. They’re not sufficient. The fork ships three more layers:
- Snuffleupagus ruleset (
contrib/snuffleupagus/vimbadmin-strict.list): code-derived, not copy-pasted from a blog post. Every ban is checked against a scan of what the app and its vendor tree actually call. It bans dangerous PHP functions the app never touches, allow-scopes the few it does, and blocks RFI/LFI wrappers,eval/base64_decodewebshell pipes, mail-header injection, world-writable chmod, writing PHP-loadable files, and insecure cURL. The latest pass dropped roughly thirty more — a webshell’s favourite egress and RCE channels (imap_open,ftp_connect, thessh2_*family,pfsockopen,expect_*), runtime code-redefinition (runkit/uopz), filesystem-ownership calls, and host-fingerprinting functions — each verified to have zero real call-sites first, so nothing legitimate breaks. Note: do not stack nativedisable_functionswith Snuffleupagus, they conflict and the worker SIGSEGVs. Ask how we know. - Hardened PHP-FPM pool (
contrib/php-fpm/vimbadmin.conf):open_basedir, empty nativedisable_functions(SP owns policy), strict session-cookie flags,security.limit_extensions=.php, sane resource limits. - Hardened Angie/nginx vhost (
contrib/angie/vimbadmin.conf): positive security gate: only known HTTP methods, the exact route map (controllers + ZF1 param URLs), and the app’s known argument names reach PHP. Scanner traffic, empty user-agents, and the eternal/.env//wp-login.phpprobe loop die at the edge. Plus strict CSP, security headers, BREACH mitigation (no compression on dynamic HTML), and a rate-limited login endpoint. Optional add-on: the ModSecurity CRS plugin for payload-signature scanning on top.
Quick start
Docker (recommended: fastest path to a running panel)
Bring a MariaDB/MySQL database. The image bundles the app, PHP-FPM, and the web server, pre-wired. First boot generates secrets and sets up the schema. Config lives in a mountable volume, edit application.ini without rebuilding, no files clobbered. The image is on Docker Hub at hub.docker.com/r/eilandert/vimbadmin (eilandert/vimbadmin:latest), built from the dockerized recipe; the app source is the ViMbAdmin fork on GitHub.
services:
db:
image: mariadb:lts
environment:
MARIADB_ROOT_PASSWORD: actually-change-this
MARIADB_DATABASE: vimbadmin
MARIADB_USER: vimbadmin
MARIADB_PASSWORD: also-change-this
vimbadmin:
image: eilandert/vimbadmin:latest
depends_on: [db]
ports:
- "8080:80"
environment:
TZ: Europe/Amsterdam
docker compose up -d
# wait for MariaDB's first-boot, then browse to http://localhost:8080/
Put it behind TLS in production. Behind the hardened vhost from contrib/ if you can. The point of shipping deployment configs is that you don’t have to invent them under pressure at 23:00.
From source
PHP 8.4.1+ with pdo_mysql, mbstring, intl, gettext, dom, ctype, iconv, sodium (2FA encryption). apcu optional but don’t skip it.
git clone https://github.com/eilandert/ViMbAdmin.git
cd ViMbAdmin
composer install --no-dev
cp application/configs/application.ini.dist application/configs/application.ini
# edit application.ini: point resources.doctrine2.connection.options.* at your DB
./bin/doctrine-cli.php orm:schema-tool:create
That last command is the modernised CLI. The stock one called a Doctrine 2.8 API that no longer exists (the fork is on ORM 3 now). PHP 8.4.1 is the dependency-tree floor.
First run: claim the throne before someone else does
On first launch ViMbAdmin detects no administrators exist and routes you to a setup page. This is the one window where the panel is briefly unauthenticated. Do it immediately, on a network you trust, then never think about it again.
The setup page generates a security salt, use the one it gives you. Then it asks for your first super-admin’s credentials. The username is an email address. Not the word “admin.” Not “root.” An actual you@yourdomain.com. The field is labelled “Email.” Read the label. This trips up more people than you’d believe, and none of them believe it could trip them up until it does.
Pick a real password. It’s bcrypt-hashed and constant-time-compared on every login. The strength is entirely on you. The super-admin can see and touch everything. Treat the credentials accordingly.
Day-to-day: domain, mailbox, alias, in that order
The order matters because the foreign keys matter.
- Domains → Add. Set the limits (max mailboxes, max aliases, default quota), decide backup MX status. ViMbAdmin writes the row. Postfix will now accept mail for the domain: assuming your
virtual_mailbox_domainsis actually pointed at this database. The panel maintains the data; it can’t make Postfix care about a table you never configured it to read. That part is on you. - Mailboxes → Add. Local part, password, quota. ViMbAdmin hashes the password natively in your configured scheme (ARGON2ID / BLF-CRYPT / SHA512-CRYPT — no
doveadm pwbinary involved), computes maildir path, stores it. User can now authenticate against Dovecot. If you enabled the welcome-email feature, it tells them: which beats texting them a plaintext password, a practice that should be punishable by having to read your own sent folder. - Aliases → Add. Address → comma-separated goto list. Build your
postmaster@(RFC 5321 requires it, you will forget until a remote server complains), your role addresses, your distribution lists.
Every action is logged, validated, and CSRF-protected. The delete button on a mailbox carries a token; a malicious page can’t trick your browser into purging an account behind your back.
No more scripts: archive, autoprune and quotas are all Dovecot-native
This is the part that changed the most, and it’s the part that makes the panel genuinely pleasant to run. The original ViMbAdmin leaned on a pile of helper scripts and cron jobs that had to live on the mail host, where the maildirs are: one to tar up an archive, one to scan every maildir for its size, one to actually delete files off disk. They needed shell access to the mail filesystem, they needed to be kept in sync with the panel, and if you forgot one, features silently didn’t work.
All of that is gone. The panel now talks to Dovecot over its doveadm HTTP API — a REST endpoint — and never touches the mail filesystem itself. No tarballs, no du, no shared mount, no scripts to deploy on the mail host. The web request just writes a small job to a database queue, and a background runner carries it out against Dovecot. There is one moving part instead of five, and it’s a network call to an API, not a shell command on someone else’s server.
Delete → archive → autoprune
Deleting a mailbox is no longer a destructive single click you regret at 02:00. When you delete (or explicitly archive) a mailbox, the runner asks Dovecot to doveadm backup the whole store into a zstd-compressed maildir under your backup path, and only then removes the live account. The backup shows up on the new Archives tab: when it was made, whether the account still exists, and its autoprune state. A deletion’s backup is flagged for autoprune and is cleaned up automatically after a configurable number of days (default 90; set it to 0 if you want delete to mean delete now, no backup). Changed your mind inside the window? Restore recreates the mailbox — original password hash and all — and doveadm syncs the mail back from the backup. Future-you, the one not reconstructing a fat-fingered deletion from a three-day-old dump, is grateful.
The Maintenance tab
There’s a new Maintenance tab that surfaces all the housekeeping in one place instead of hiding it in cron files: app and schema version, the last queue-run and prune markers, inactive-domain stats, and one-click buttons to update the database schema, run autoprune now (clear expired backups), or delete all autoprune backups. The same actions are available headless for a cron (maintenance.prune-expired) if you’d rather automate them — but you no longer have to.
Quotas update themselves, in real time
Two things people conflate: the limit (how big a mailbox is allowed to get) and the usage (how full it is right now). ViMbAdmin owns the limit — you set it in the GUI. Dovecot owns the usage, and it now reports it back live via its quota-clone plugin, which writes each mailbox’s current byte count straight into a table the panel reads. No nightly maildir-scan cron, no stale numbers — the usage bar you see is what Dovecot measured on the last delivery. The old size-scanning script that used to crawl every maildir overnight is simply retired.
MCP adapter: JSON-RPC API for when clicking is beneath you
Off by default. Enable with mcp.enabled = 1 in application.ini. This is a JSON-RPC 2.0 API at /mcp that lets an agent, a script, or a CI pipeline read and manage the mailbox database without a human in the loop. The full method set:
| Method | Scope | Does |
|---|---|---|
ping | read | Liveness check, pong + timestamp |
domains.list | read | All domains with mailbox/alias counts and quotas |
mailboxes.list | read | All mailboxes for a domain |
aliases.list | read | All aliases for a domain |
domain.create | write | Create a virtual domain |
domain.delete | write | Delete a domain and everything in it |
mailbox.create | write | Create a mailbox (hashes password, wires auto-alias) |
mailbox.delete | write | Delete a mailbox permanently |
alias.create | write | Create an alias (address → goto) |
alias.delete | write | Delete an alias |
mailbox.archive | write* | Queue mailbox for archive (purges live, schedules tar) |
archive.restore | write* | Queue archive for restore |
archive.delete | write* | Queue archive for deletion |
mcp.ratelimit.destructive).Auth is bearer-only, no session, no cookie. Only the SHA-256 hash of each token is stored; a database read yields nothing usable. Tokens are scoped (read or read write), per-token IP/CIDR allowlisted, expirable, and revocable from the CLI without touching the web panel:
# read-only token — printed once, store it now
./bin/vimbtool.php -a mcp.cli-token-generate --name=agent1 --scope="read"
# write-scoped, IP-locked, 90-day expiry
./bin/vimbtool.php -a mcp.cli-token-generate --name=provisioner \
--scope="read write" --ip="10.0.0.5" --days=90
./bin/vimbtool.php -a mcp.cli-token-list
./bin/vimbtool.php -a mcp.cli-token-revoke --name=agent1
The vhost should enforce an IP allowlist in front of /mcp as primary network defence (the contrib/angie/vimbadmin.conf has the block). Bearer is the application layer on top. Revoked names can be reused, the CLI drops the old row rather than refusing, so token rotation doesn’t require inventing new names.
Real client IP behind a proxy: do it right or your brute-force protection is a lie
If your reverse proxy sits in front of ViMbAdmin and you haven’t configured trusted-proxy handling, your brute-force limiter sees the proxy’s IP address, not the attacker’s. It will either lock out nobody (if the proxy is whitelisted) or lock out your entire office (if the proxy isn’t). Both outcomes are bad. The MCP per-token IP allowlist has the same problem.
Controlled by trustedproxy.mode in application.ini:
; auto (default) — trust X-Forwarded-For only when REMOTE_ADDR is private/loopback
trustedproxy.mode = "auto"
; on — trust X-Forwarded-For only from the listed proxy CIDRs
;trustedproxy.mode = "on"
;trustedproxy.proxies[] = "10.0.0.0/8"
;trustedproxy.proxies[] = "172.16.0.0/12"
; off — ignore X-Forwarded-For, always use raw REMOTE_ADDR
;trustedproxy.mode = "off"
auto(default): trustsX-Forwarded-Foronly whenREMOTE_ADDRis a private or loopback address. Covers the standard “proxy on the same host or LAN” setup with zero configuration. A publicREMOTE_ADDRbypasses the header entirely.on: trustX-Forwarded-Forfrom the CIDRs you list. Use when your proxy is on a public IP or a separate network segment.off: always use rawREMOTE_ADDR. Use when you handle IP rewriting at the web server layer instead (Angie/nginxreal_ipmodule, there’s a commented example incontrib/angie/vimbadmin.conf).
The client is taken as the right-most address in the X-Forwarded-For chain that isn’t a trusted proxy. A client can’t spoof it by prepending extra IPs to the header, the leftmost entries are attacker-controlled, the rightmost is what your proxy saw.
Upgrading and schema migrations
Pulling a new version may add columns, indexes, or tables. Two paths:
# Option A: Doctrine reconciles DB against entity mappings
./bin/doctrine-cli.php orm:schema-tool:update --dump-sql # see what it wants to do
./bin/doctrine-cli.php orm:schema-tool:update --force # do it
# Option B: targeted migration from contrib/migrations/
mysql -u<user> -p <database> < contrib/migrations/2026-06-mailbox-username-unique.sql
contrib/migrations/ holds idempotent SQL for changes that warrant a named file and a comment. Current one: UNIQUE index on mailbox.username. Postfix and Dovecot query that column on every delivery and login. Without the index they full-scan the mailbox table every time. Fresh installs have it; DBs created from older dumps don’t. Before applying, check for duplicates (yes, they happen):
SELECT username, COUNT(*) c FROM mailbox GROUP BY username HAVING c > 1;
schema-tool:update is additive (adds, doesn’t drop). The migration files do exactly what they say. Back up the database first regardless. This is not optional advice.
Where it fits in a real mail stack
The components and their responsibilities: Postfix handles SMTP. Dovecot handles IMAP/POP. Rspamd handles spam scoring before any of that. A web server fronts everything. ViMbAdmin keeps the shared user database coherent. That’s it. The deliberate narrowness is a feature, a tool with one job can be audited, hardened, and trusted in a way a sprawling appliance never can.
One boundary to be clear about: ViMbAdmin only writes to the database. It never touches maildirs. The “archive” and “delete” buttons queue a row; a background runner does the real work against Dovecot over its HTTP API (doveadm backup to a zstd-compressed maildir, then doveadm sync to restore) — no tarballs, no shell tools, no shared filesystem with the mail host. In the Docker image that runner is the supervised queue-runner from earlier, ticking every five minutes with nothing to install. On bare metal you add a cron calling queue.cli-run; the fork ships examples in contrib/cron/ with their requirements documented inline. Mailbox usage is fed live by Dovecot’s quota-clone plugin, so there’s no nightly maildir-scan job any more either.
If you’re running this stack on Debian or Ubuntu, the rest of our work slots in beside it: hardened nginx/Angie packages with HTTP/3, daily-rebuilt Docker images, and a general operating principle that default configs are a starting point for hardening, not a destination. ViMbAdmin, properly deployed, is the mail-admin-shaped piece of that.
Stop editing your mailbox table by hand. Future you, the one not reconstructing a dropped domain at 02:00 from a backup that’s three days old, will not thank you, because future you will be asleep.
Frequently asked questions
Does ViMbAdmin configure Postfix and Dovecot for me?
No. ViMbAdmin manages the SQL database of virtual domains, mailboxes and aliases. Postfix and Dovecot read that database independently, you still have to configure virtual_mailbox_maps, virtual_mailbox_domains, and the Dovecot userdb/passdb SQL queries yourself. ViMbAdmin maintains the data; the mail daemons consume it. It does not reload or signal them.
Why does “admin / admin” not work?
Because the username is an email address, not the string “admin”. The login field is labelled “Email”. The setup page asked you to create a super-admin using a real address like you@yourdomain.com. Use that address and the password you set. The form is telling you the truth.
Is the original ViMbAdmin still maintained?
Upstream went quiet years ago and the stock code doesn’t run on modern PHP. Our fork at github.com/eilandert/ViMbAdmin brings it to PHP 8.5, Smarty 5, and Doctrine ORM 3, and adds CSRF, XSS auto-escaping, TOTP, brute-force protection, a Snuffleupagus ruleset, a ModSecurity CRS plugin, the MCP JSON-RPC API, and a self-supervising (s6) Docker image.
What database do I need?
MySQL or MariaDB, the same one your Postfix/Dovecot setup already uses. ViMbAdmin creates its schema with ./bin/doctrine-cli.php orm:schema-tool:create. Point it at your existing mail database (or a dedicated one) and it manages the relevant tables.
How are mailbox passwords hashed?
Natively in PHP, into the exact format Dovecot stores — no doveadm pw binary or shell-out. You pick the scheme in application.ini; strongest first, that’s ARGON2ID (memory-hard, set it if you hash out-of-band), then BLF-CRYPT (bcrypt $2y$, the recommended native default), then SHA512-CRYPT and SHA256-CRYPT. Admin-account passwords use bcrypt, compared in constant time. CSPRNG for all tokens and backup codes.
Can I delegate management to domain owners?
Yes. Per-domain admins can manage their own domain’s mailboxes and aliases without seeing other domains or system-level settings. Saves you being a human ticket queue.
What is the MCP adapter?
An optional JSON-RPC 2.0 API at /mcp for agents and scripts. Off by default (mcp.enabled = 1 to enable). Bearer-token authenticated (SHA-256 hash stored, raw shown once), scoped read or read+write, per-token IP allowlist, expirable, revocable. Covers all 13 domain/mailbox/alias/archive operations. Tokens managed from the CLI, the web panel never touches them.
How do I update the schema after pulling a new version?
Run ./bin/doctrine-cli.php orm:schema-tool:update --dump-sql to preview what Doctrine wants to add, then --force to apply. For specific migrations: hand-written, idempotent SQL in contrib/migrations/, each file documents why it exists and what to check first. Back up the DB before either.
The brute-force limiter is blocking my whole office. What broke?
Your reverse proxy. The limiter sees the proxy’s IP, not the real client’s, and locks the proxy out instead. Set trustedproxy.mode = auto in application.ini (default), it trusts X-Forwarded-For when the request comes from a private/loopback address. Add your office CIDR to bruteforce.whitelist[] as well.
Where do I get the Docker image, and do I need a cron for the queue?
The image is on Docker Hub at hub.docker.com/r/eilandert/vimbadmin as eilandert/vimbadmin:latest, built from the dockerized recipe. You do not need a cron with it: an s6-supervised queue-runner inside the container drains the queue every five minutes (and once on boot), and s6 also respawns PHP-FPM if it dies. A cron calling queue.cli-run is only needed for bare-metal or source installs.
Related reading
- Rspamd Explained: How Modern Spam Filtering Actually Works: the spam-filtering piece that sits beside ViMbAdmin in a real Postfix/Dovecot stack.
- What Is the BREACH Attack?: why we disable compression on dynamic HTML, from first principles.
- Docker Hardening for Self-Hosters: the ten-flag checklist for running the ViMbAdmin container properly.
- Angie and NGINX Docker Images: daily-rebuilt, fully-moduled web-server images to front the panel.
- ViMbAdmin on Docker Hub: pull
eilandert/vimbadmin:latest— the self-supervising, read-only, unprivileged image this article describes.