Lightweight web-based customer management for small ISPs, built on top of the MikroTik RouterOS API. Multi-site: when you save a customer, the app automatically writes the DHCP lease + Simple Queue (or PPP secret for PPPoE) to the selected MikroTik, based on the customer's status and plan (speed profile).
Plain PHP + SQLite/MySQL. No framework, no dependencies. Up and running in minutes.
- Customer management: name, address, contact, contract, IP/MAC, plan, status, notes, change history
- Automatic MikroTik provisioning via RouterOS API — DHCP leases, Simple Queues, ARP, firewall address-lists, PPP secrets (PPPoE)
- Multi-router / multi-site: every customer belongs to a specific MikroTik and network
- Plans (speed profiles) with aggregation —
max-limitandlimit-atare calculated automatically - Customer statuses: connected / temporarily disconnected / non-payer / contract terminated — the app blocks automatically based on status (address-list + speed throttling)
- Users and roles (administrator > admin > user), password management, trash bin, audit log
- One-click database backup + FTP/FTPS upload, restore from backup
- Optional geo-blocking of the login (allow selected countries only, via Cloudflare header or offline CIDR lists)
- 10 languages — language picker on the login screen and in the header (Slovak, Czech, English, German, Polish, Hungarian, Romanian, Ukrainian, Latvian, Russian)
- Light / dark theme, responsive UI
- Time zone detected from the server automatically, overridable from the Settings page
- DHCP Option 82 support — bind a customer's lease to the physical circuit (Agent Circuit ID) instead of the MAC address, so swapping a modem needs no reconfiguration
![]() |
![]() |
| Login with language picker | Customer dashboard |
![]() |
![]() |
| Customer form (PPPoE) | Dark mode |
More: Settings · Plans · Routers · Networks · Backup · Users
- PHP 8.1+ with extensions:
pdo+pdo_sqlite(orpdo_mysql),openssl(for api-ssl), optionallycurl/ftp(FTP backups) - MikroTik RouterOS with the API service enabled (port 8728, or api-ssl 8729)
- Or just Docker + docker compose — nothing else needed
git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
php -S 0.0.0.0:8000 -t publicOpen http://server:8000/login.php
Default login: admin / changeme — change the password right after logging in (Account section).
The database is created automatically on first run in data/ispadmin.sqlite, seeded with example plans (edit them in the UI to match your own offer).
This is the whole thing from a freshly installed machine, in order. It takes about fifteen minutes and needs no PHP or web server knowledge. Steps 3 and 4 contain the two things that most commonly make people think the install has failed when it hasn't.
sudo apt update && sudo apt install -y curl
curl -fsSL https://get.docker.com | sudo shgit clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
sudo docker compose up -d --buildThe container runs with network_mode: host, so it reaches your MikroTiks exactly like the
host server does, including over WireGuard or other tunnels. The SQLite database lives in the
ispadmin-data volume and survives rebuilds.
For safety the app listens only on 127.0.0.1:8090, not on the network. Opening
http://your-server-ip:8090 in a browser will simply not respond, which looks like a failed
install but isn't. You have two options.
Either tunnel to it from your own machine — nothing to configure on the server:
ssh -L 8090:127.0.0.1:8090 youruser@your-serverand then browse to http://localhost:8090.
Or, if the server sits on a trusted internal network and you want to reach it directly, edit
docker/ports.conf, change Listen 127.0.0.1:8090 to Listen 8090, and run
sudo docker compose up -d --build again.
For anything reachable from the internet, use the reverse proxy in the next step instead.
Point a DNS record at the server first, then:
sudo apt install -y nginx certbot python3-certbot-nginx
sudo cp docker/nginx-proxy.conf /etc/nginx/sites-available/ispadmin.conf
sudo nano /etc/nginx/sites-available/ispadmin.conf # set server_name to your domainRemove the stock Debian site before running certbot. It has a catch-all server_name, so
it swallows your domain and certbot installs the certificate into the wrong file — you end up
with a valid certificate serving the nginx welcome page:
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/ispadmin.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d ispadmin.example.comCheck that it worked, and that the app is what answers:
curl -I https://ispadmin.example.com/login.php # expect 200Once HTTPS is confirmed working, enable HSTS by uncommenting the
Strict-Transport-Security line in your nginx config and reloading. Don't do it earlier —
browsers will then refuse plain HTTP to this host and you can lock yourself out.
Open your domain and log in with admin / changeme.
Change the password immediately under Account. There's a Generate password button next to the field. The default is public knowledge, so on an internet-facing install this is not optional.
Then, if more people need access, create accounts under Users. Give colleagues the
admin role rather than administrator — admin already unlocks every page, it just can't
delete or modify accounts at the same level, which stops someone accidentally locking you out.
Under Routers, fill in the host, API username and password, then press Test. If it goes green, everything else in the app will work. See MikroTik setup below for what to enable on the router itself.
- In
config.phpset'driver' => 'mysql'and fill in the credentials (or use theDB_HOST,DB_NAME,DB_USER,DB_PASSenvironment variables). - Create the database and import the schema:
mysql -u root -p -e "CREATE DATABASE ispadmin CHARACTER SET utf8mb4"
mysql -u ispadmin -p ispadmin < schema.sql- Point the DocumentRoot at the
public/directory —config.php,lib/,lang/anddata/stay outside the web root. On classic Apache shared hosting the bundled root.htaccesshandles this.
Enable the API on every router:
/ip service enable api
(port 8728; for SSL enable api-ssl on 8729 and turn on use_ssl for the router in the app)
Create an API account with permissions for: dhcp-server/lease, queue, firewall/address-list, ppp/secret, system. You enter the API credentials in the Routers section of the UI — they are stored only in your own database.
To actually block non-payers, add a firewall rule on the router:
/ip firewall filter add chain=forward src-address-list=unpaid action=drop
/ip firewall filter add chain=forward src-address-list=suspended action=drop
The app maintains the address lists; the drop rules above are what actually blocks the traffic. List names default to suspended, unpaid and terminated, and are configurable in config.php (block_lists) or via environment variables.
| Status | What the app does on the MikroTik |
|---|---|
| Connected | lease + queue (enabled), IP removed from block list |
| Temporarily disconnected | lease stays, queue throttled/disabled, IP added to address-list |
| Non-payer | same as temporary, different address-list |
| Contract terminated | deletes lease + queue + address-list entry |
PPPoE customers are managed through /ppp/secret (login, password, profile) instead of lease/queue.
Each customer has an optional Circuit ID field holding the DHCP Option 82 agent circuit identifier — for example an NBN AVC ID in Australia, or a port identifier from a DSLAM or access switch.
When the field is filled, the app binds the DHCP lease to the circuit instead of the MAC address. The MAC is deliberately not sent, so the customer can replace their modem and the lease still applies — no reconfiguration, no manual re-entry. The router matches the lease using the agent-circuit-id parameter:
/ip dhcp-server lease add address=10.0.0.50 \
agent-circuit-id=41564330303032353038313730313138 server=dhcp1
If the Circuit ID is empty, the app falls back to the usual MAC-based lease, so existing setups are unaffected.
RouterOS displays Option 82 identifiers as hex, but the underlying value is usually plain text. You can paste either form and the app normalises it:
| What you enter | Stored / sent to the router |
|---|---|
AVC0002508170118 |
41564330303032353038313730313138 |
41564330303032353038313730313138 |
unchanged |
0x4156433030... |
0x prefix stripped |
A string is only treated as hex if it decodes to readable text — so a purely numeric circuit ID such as 0012345678 is correctly kept as text rather than misread as hex. To force hex interpretation of a binary identifier, prefix it with 0x.
To find the value on a running system, look at an active lease in RouterOS (IP → DHCP Server → Leases, the Agent Circuit Id field), or take it from the carrier's service order.
Notes and open questions live in issue #1.
Every timestamp in the app — change history, logins, backup file names — uses a single time zone, resolved in this order:
- the
ISPADMIN_TZenvironment variable, if set (useful for Docker) - a manual choice saved on the Settings page
- the server's own time zone, read from
/etc/timezoneor/etc/localtime - UTC, as a last resort
In practice step 3 means timestamps are correct straight after installation without configuring anything. If the server's zone is wrong or the app runs somewhere else than your customers, pick the right one under Settings — it is stored in the database, not in config.php, so it survives updates. When ISPADMIN_TZ is set it wins over everything and the Settings field is shown read-only, so it is always obvious where the value comes from.
Per-user Simple Queue:
max-limit= user UL/DL (upload/download, in kbit)limit-at=max-limit / aggregation(guaranteed share)
A plan with no speed set (e.g. IPTV, GPON) creates only the lease, no queue.
Prepare a JSON file following example_data.json and run:
php import_json.php my_site.json # preview, writes nothing
php import_json.php my_site.json --apply # real importThe import is idempotent — existing customers (same IP or PPPoE login) are skipped, and nothing is changed on the MikroTik.
| Script | Purpose |
|---|---|
import_json.php |
import a router, its networks and customers from JSON |
pull_speeds.php |
fill in customers' real speeds from Simple Queues on the MikroTik (read-only) |
set_siet.php |
bulk-set the "Network" field on customers |
fix_encoding.php |
fix diacritics (CP1250 escapes from RouterOS) in already imported data |
update_geoip.php |
download country CIDR lists for geo-blocking (cron-friendly) |
All of them run in preview mode until you add --apply.
Run them as the web user. Inside Docker the app runs as www-data, and the SQLite
database has to stay writable by it. If you run a script as root, the database file ends up
owned by root and the web interface then fails with "attempt to write a readonly database":
sudo docker exec -u www-data -it mt-ispadmin php /var/www/html/import_json.php \
/var/www/html/my_site.json --applyIf it has already happened, fix the ownership with:
sudo docker exec mt-ispadmin chown -R www-data:www-data /dataThe UI ships in 10 languages; users pick their language on the login screen (stored in a cookie, auto-detected from the browser on first visit). Slovak is the source language, English is the fallback for missing strings.
To add or improve a language:
- Copy
lang/en.phptolang/xx.phpand translate the values (keys stay in Slovak). - Add the code and native name to
LANGSinlib/lang.php. - Check completeness:
php lang/verify.php xx(useslang/keys.txt, verifies keys,%splaceholders and inline HTML).
Pull requests with new languages are welcome.
Access can be limited to selected countries. Enable it with the ISPADMIN_GEO_ENFORCE=1 env variable (disabled by default in docker-compose.yml so you can't lock yourself out). It works via the Cloudflare CF-IPCountry header, or fully offline via CIDR lists (update_geoip.php). Add your own IPs to ISPADMIN_GEO_ALLOW_IPS as a safety net.
The header logo is text-based and configured in config.php:
'brand_pre' => 'isp', // first (blue) part
'brand_post' => 'admin', // second (dark) part
'tagline' => 'customer management · MikroTik',A default SVG logo is included in public/assets/logo.svg — feel free to modify it or replace it with your own.
- Change the default password immediately after the first login (
admin/changeme). The default is published here, so anyone who finds your install knows it. config.php,lib/,lang/anddata/must not be reachable from the web — both Docker and the bundled.htaccesstake care of this.- Router API credentials and customer data live only in your own database (
data/is in.gitignore) — never commit them. - Run the app behind HTTPS (certbot + nginx proxy), ideally on an internal network / behind a VPN.
"Fatal error: attempt to write a readonly database"
A CLI script was run as root, so the SQLite file is now owned by root while the web app runs
as www-data. Fix the ownership and always pass -u www-data to docker exec:
sudo docker exec mt-ispadmin chown -R www-data:www-data /dataThe browser doesn't respond on port 8090
That's intended — the app listens on 127.0.0.1 only. Use an SSH tunnel or the nginx reverse
proxy, see step 3 above.
Certbot succeeded but the domain shows the nginx welcome page
Certbot installed the certificate into the stock Debian site because it matched the domain
first. Remove /etc/nginx/sites-enabled/default, make sure server_name in your own config
is exactly right, then re-run certbot and choose reinstall.
The Test button under Routers fails
Check in order: the API service is enabled on the router (/ip service print), the host and
port are reachable from the server (nc -vz ROUTER_IP 8728), the API account exists with the
right permissions, and no firewall rule on the router blocks the API port.
Timestamps are hours off See Time zone. The quickest fix is to set it explicitly on the Settings page.
Check what the app is actually doing
sudo docker compose logs --tail 50- Parent queue / queue-tree for shared group caps
- IPv6 prefix delegation
- Bulk re-sync of all customers to a router
- Per-field audit log
MIT — see LICENSE. Use at your own risk; test everything on a lab router before deploying to production.
Author: Juraj Chudý



