Guide
How to start a hosting company with TulioCP
This is the long version. It assumes you can already administer a Linux server and that nobody is going to do the awkward parts for you. It covers 15 chapters, from deciding what you sell to the routine you run in the first month, and it says where TulioCP and TulioBilling stop and you start.
Contents: 15 chapters
- Before anything else
- The architecture
- 01Decide what you sell, and what you refuse to sell
- 02Name, domains, and where support arrives
- 03Servers, addresses, and the DNS plan
- 04Install TulioCP
- 05Make the panel a production panel
- 06Leave demo mode, then open the API to Billing alone
- 07Packages, plans, and the limits behind them
- 08Stand up TulioBilling on its own host
- 09Wire Billing to the panel
- 10Transactional email, and customer mail if you sell it
- 11Take money
- 12Registrar and authoritative DNS
- 13Backups, monitoring, and update discipline
- 14Recovery: what you restore, and how long it takes
- 15Dry run, launch, and the first thirty days
- Readiness checklist
- Stop the launch if…
- Legal note
- Source and lineage
Do not copy the demo
Two public instances exist so you can look at this software before installing it. Neither is an example of a production deployment, and copying either one is a way to start with problems you did not have to have.
panel.tuliocp.com is a real production host running the panel's built-in
read-only demo mode. Its login is admin / demo and this site
publishes it on purpose. The panel refuses every state-changing action and its API is
switched off, which is the only reason those values are safe to print. A panel you sell
from has a unique administrator name where that is practical, a password no one else
has ever seen, and an API that is on. Never reuse that password anywhere.
Clearing demo mode on a panel whose login is published, while that panel is still reachable, hands a working administrator session to everyone who has read the page it was published on. If you are converting a demo into production rather than installing fresh, the isolate-first order in chapter 06 is the part that matters, and this host is the exact case it describes.
billing.tuliocp.com is a demo of TulioBilling with
self-registration closed, no payment gateway connected, and provisioning inactive.
An order placed there charges nothing and creates no hosting account. Your instance
will have all three of those turned on, which makes it a completely different object
with completely different consequences when it is wrong.
Before anything else
Who this is for, and what the software does not do
You are the right reader if you can install Debian, read a shell script before running it, and hold a DNS zone in your head. You do not need to have run a hosting company before. If you have never administered a Linux server in production, this guide will not carry you. Start with a machine that has nothing on it and no customers, and come back.
What the two applications do
TulioCP is the control panel. It manages web domains and
their vhosts, DNS zones, mail domains and accounts, databases, cron jobs, SSL
certificates, per-user resource packages, firewall rules and backups, from a web UI, a
v-* command-line, and an HTTP API. It is what actually runs a customer's
site.
TulioBilling is the commerce side: product catalogue, orders,
invoices, recurring billing, client accounts, support tickets, and adapters that call
out to payment gateways, domain registrars, and hosting servers. Its
Server_Manager_Tulio adapter is the piece that turns a paid order into a
panel account.
What neither of them provides
This list is the reason most of this guide exists. None of the following ships with either application, and none of it has a setting you can switch on:
- A legal entity. A company, a registered business name, a tax registration, or whatever your jurisdiction requires to sell a service.
- A bank account or a payment processor account. The billing app talks to gateways; it does not get you approved by one.
- A transactional mail provider. The panel can run a mail server for your customers. Sending your own invoices and password resets reliably is a different problem, and the answer to it is an account with a provider.
- A domain registrar relationship. Reselling domains means a reseller account with a registrar, with its own approval, funding, and contract.
- Redundant authoritative DNS. The panel runs BIND on the box it is installed on. One nameserver on one machine is not redundancy. Getting to two, either by buying managed DNS or by building a secondary, is your design problem.
- Off-site backups and monitoring. The panel writes archives and uploads them to a destination you configure, holding the credential for it. Deciding where they go, keeping a copy the panel cannot delete, proving they restore, and noticing when they stop are all yours.
- Disaster recovery for anything but user accounts. The panel's backup unit is one user's data. Panel configuration, access keys, API roles, certificates, packages, templates and the billing application are outside it entirely.
- Customer support. There is a ticket system. There is nobody in it.
On support for the software itself
TulioCP is maintained by one person and its only support channel is GitHub issues, where replies take days rather than hours. There is no commercial support contract to buy. Build your operations on the assumption that when something breaks at 2am, the person fixing it is you, with the source open in another window. That is a real trade-off for the price, and it should be a deliberate one.
The shape of it
The architecture you are aiming at
The single most consequential decision here is the split between the management plane, meaning the machines that run your business, and customer workloads, meaning the machines that run other people's code. Once a customer can upload PHP to a host, that host is not somewhere your billing database, your gateway credentials, or your panel access keys should live.
Management plane: your machines, your code only
- Public site Marketing and documentation. Static, disposable, no customer data.
- TulioBilling Orders, invoices, client records, gateway and registrar credentials. Its own host.
- Monitoring & backup store Off-site. Pulls the archives on its own schedule and holds the history the panel's credentials cannot delete. See chapter 13.
Provisioning: HTTPS, port 8083, allow-listed to the billing host
Workload plane: other people’s code runs here
- TulioCP node 1…n Panel, nginx, PHP-FPM, MariaDB, and the customer accounts themselves. Add nodes as you grow; Billing can drive more than one.
- Authoritative DNS Two servers in two failure domains. Either a managed DNS provider that runs both for you, or the panel's own BIND plus a secondary on separate infrastructure.
Accounts you hold, not software you run
Outside your perimeter: vendors you depend on
- Transactional email providerInvoices, password resets, order confirmations.
- Payment gatewayCard processing, webhooks back into Billing.
- Domain registrarYour own names, and your customers' if you resell.
Write down every flow that crosses a plane
Enumerate the flows, allow exactly those, and deny the rest. The when column is the part that gets skipped: a firewall baseline is not a fixed list, it is a function of what you decided to sell in chapter 01. Work down this table striking out the rows that do not apply to your product, and what is left is the ruleset.
| From | To | What | When |
|---|---|---|---|
| Public internet | Panel node :80, :443 | Customers' visitors reaching the sites you host, and the HTTP-01 challenges that issue their certificates. | Always, if you sell web hosting |
| Public internet | Billing host :443 | The client area and checkout. Also :80, for the redirect to HTTPS and for certificate issuance. | Always |
| Payment processor, registrar | Billing host :443 | Webhooks and callbacks. Unauthenticated at the network layer by definition, so it cannot sit behind an allow list; the adapter authenticates the request instead. See chapter 11. | If you take payments or resell domains |
| Billing host | Panel node :8083 | Provisioning. HTTPS, access-key authenticated, allow-listed to the billing host's source address. | Always |
| Resolvers, internet | Nameservers :53 UDP and TCP | Public authoritative DNS. TCP as well as UDP: a truncated answer is retried over TCP, and blocking it breaks resolution for larger zones rather than slowing it. | If you run your own authoritative DNS |
| Panel node | Secondary nameserver :53 TCP | AXFR and NOTIFY, restricted to the secondary's address. Managed DNS replaces this flow with an API call outward instead. | If you run your own secondary |
| Customers, internet | Panel node :25, :465, :587, :993, :995 | Customer mail: submission, IMAP and POP. Port 25 is inbound delivery from other mail servers and is a different question from outbound sending, which chapter 10 covers. | If you sell mailboxes |
| Customers, internet | Panel node :8083 | Customer panel sign-in. See chapter 05 for the three defensible ways to serve it. | If you sell panel access |
| Operator workstation | Panel and billing hosts, :22 and :8083, billing admin prefix | Administration. Key-only SSH, restricted by source address, and the same restriction on the billing administrator path, which has no second factor of its own. | Always |
| Panel node | Backup destination | Archive collection. The panel's own backend pushes and holds the credential, which is why chapter 13 moves the pipeline to a puller the panel cannot log in to. | Always |
| Monitoring | Panel and billing hosts | Whatever your monitoring actually does: an HTTP probe inward, an agent reporting outward, or a pull over SSH. Each is a different flow with a different blast radius. Pick one and write it down. | Always |
| Panel and billing hosts | Internet | Outbound: apt and the distribution mirrors, Let's Encrypt, your transactional mail provider, the payment gateway's API, the registrar's API. | Always; the last three by product |
The rule that does hold across all of them: a customer workload never initiates a connection into the management plane. Nothing on a panel node needs to reach the billing database, and nothing that runs other people's PHP should be able to.
The public instances are not this diagram
The panel and billing demos this site links run on shared infrastructure that was never meant to model the above. Read the diagram, not the demo topology.
How small it can honestly start
Two compute hosts, if you buy the redundancy you are not building: one panel node, one billing host, managed DNS for the authoritative servers, and object storage for the off-site backup copy. That is a real deployment, and it is what most people starting out should build.
Running your own authoritative DNS raises the floor. Two nameservers in one rack are one nameserver wearing two hats, so self-hosted DNS means a third machine in a second failure domain: a different provider, or at least a different region and a different network. The choice between managed and self-hosted is a chapter 12 decision, and it is the one that decides whether you are buying two machines or three.
One box running the panel, the billing database and customer PHP is not a small version of this. It is a different design, and its failure mode is that one compromised customer site takes the business with it.
Chapter 01
Decide what you sell, and what you refuse to sell
The failure mode for a one-person hosting company is not technical. It is agreeing to support everything, discovering that means everything, and being unable to answer a ticket without an afternoon of research. The way out is to decide the boundary before you have customers who assume otherwise.
Pick a boundary you can defend
"Shared hosting for WordPress sites, PHP 8.3 and 8.4, one database per site, no email hosting" is a business. "Hosting" is not. A narrow line is what lets you write templates for the twenty questions you will get, tune the stack for one workload, and say no without improvising. You can widen it later from a position of knowing what the first version cost you.
Write down, in a document you will actually keep:
- What runs on your platform, and at which versions.
- What you administer, and what the customer administers.
- What you will migrate for a customer, and what you charge for it.
- What you will not host at all, which is the acceptable use policy in draft.
- Your response time, in hours, that you can meet on your worst week.
The foundation under it
Selling a service to strangers, taking their money and holding their data has legal consequences everywhere. The specifics differ enormously by country and are not something this page can tell you. What is universal is the shape of the work:
- An entity. Most payment processors and most registrars want to contract with a business, not a person, and the entity is also what stands between a customer dispute and your personal assets.
- A bank account in that entity's name. Gateways pay out to it and will verify it.
- Tax registration and a plan for collecting the right tax. Digital services sold across borders are taxed in ways that surprise people. Find out before the first invoice, not at the first filing.
- Written policies. Terms of service, acceptable use, privacy, refunds, and how you handle abuse complaints and law-enforcement requests. Have them reviewed by someone qualified in your jurisdiction.
- A data-protection position. You will hold names, addresses, IP addresses, and whatever your customers put on their sites. Know which regime applies to you and what it requires. See the legal note at the end of this page.
None of this is optional and none of it is fast. Start it in parallel with the technical work, because entity registration and gateway approval both have queue times you do not control.
Chapter 02
Name, domains, and where support arrives
Register the brand domain at a registrar you would be happy to still be with in five years, and turn on whatever transfer lock and two-factor authentication it offers. That domain is the root of your identity: your email, your certificates, your panel, your nameserver names. Losing control of it loses all of them at once.
Split the hostnames deliberately
Give each surface its own name under the brand domain rather than running everything on one. Names cost nothing, and a separate name is what lets you move a service to a different machine later without changing anything a customer has bookmarked. A workable scheme:
example.com- The public site. Static, no customer data, cheap to redeploy.
billing.example.com- TulioBilling. Its own host, its own certificate.
panel.example.com- The TulioCP node, answering on port 8083. This becomes the system hostname and the certificate name. See chapter 05.
ns1.example.com,ns2.example.com- Your authoritative nameservers, on separate machines.
Resist the temptation to call anything demo, test or
new. Those names outlive their reasons and end up describing production.
Support has to arrive somewhere you read
Pick one channel and make it the only one. TulioBilling has a ticket system; using it means support requests arrive attached to the client record and the order they are about, which is worth more than it sounds at 2am. Put a monitored address behind it for people who cannot log in. The password-reset ticket is the one that has to work when the ticket system is what they cannot reach.
Publish a response time you can hit on your worst week, not your best, and put it in your terms.
Have an abuse address, and read it. Upstream providers forward complaints there, and they escalate to null-routing your addresses when nobody answers.
Chapter 03
Servers, addresses, and the DNS plan
TulioCP publishes packages for Debian 13 (trixie) on amd64 and nothing else. There are no arm64 packages and no other Debian release, and the installer refuses to run elsewhere rather than half-working. That constrains your provider choice: you need a KVM or LXC virtual machine, or bare metal, that can run a clean Debian 13 amd64 install. The panel's own README warns specifically against OpenVZ 7 and lower for DNS and firewall reasons.
Sizing, honestly
There is no useful universal number here, and any page that gives you one is guessing. What is true is the shape of the demand: the panel itself is light, and everything heavy is your customers' PHP and MySQL. Provision for the workload you are selling, watch the first ten accounts, and size the second node from measurements rather than from the first node's specification sheet.
Two things are worth getting right at the start because retrofitting them hurts. Buy
more disk than the first month needs, because migrating customer data between volumes is the
most disruptive maintenance you will ever schedule. And decide about filesystem quotas
now: the panel's --quota option depends on how the filesystem is mounted,
which is far easier to arrange on an empty machine than on one with accounts on it.
Addresses
- One IPv4 address is enough to start. Name-based virtual hosting and Let's Encrypt mean you do not need an address per site. You will want more eventually, for separating mail from web reputation and for customers who insist.
- IPv6 is a decision, not a default. If you enable it, understand
before you do that TulioCP's firewall is IPv4-only. There is no
ip6tablesanywhere in the panel's command set. An IPv6 address on a box whose firewall only filters IPv4 is an unfiltered address. Either bring your ownnftables/ip6tablesruleset and manage it outside the panel, use your provider's network firewall, or do not assign IPv6 yet. Choosing "not yet" is respectable; choosing it by accident is not. - Reverse DNS matters if you send mail. Set the PTR record for any address that will be a mail source, at the provider that owns the address.
The DNS plan, before the install
Decide now who is authoritative for what, because the install will make assumptions if you
do not. The three workable positions — panel BIND plus your own secondary, managed DNS
with --named no, or panel DNS clustering — are worked through in chapter 12. What has to be settled before
the install is the consequence: managed DNS keeps you at two machines, running your own
authoritative servers means a third in a second failure domain, and the choice decides
which domain you register first and whether you have any glue records to create at all.
Chapter 04
Install TulioCP
Start from a fresh Debian 13 (trixie) install with nothing else on it. The installer takes over nginx or Apache, DNS, mail and database configuration, and it expects to be the only control panel on the machine.
Download it, read it, then run it
Fetch before you execute, so that what you read is what runs. The file you fetch is a
wrapper: it checks that you are root, confirms the host is Debian 13, then downloads and
executes a much larger second stage. The wrapper pins that second stage to an exact git
commit and to its SHA-256 hash and refuses to
run anything that does not match, so reading the wrapper tells you which bytes will run.
The wrapper itself arrives over TLS from GitHub and carries no signature you can check
against a key you hold.
The wrapper needs curl or wget already present; a minimal
Debian 13 has neither, and it will say so rather than doing something surprising. The
apt packages it then installs are GPG-signed and apt verifies them, which is a
different guarantee from the script on a different artifact.
The downloads page carries the full trust model, the
signing-key fingerprint and the manual repository path; the current published versions
live there too, which is why this page links rather than reprinting a checksum that
moves.
Install only what you are selling
The defaults install a full stack including mail, FTP and DNS. Every service you install
and do not sell is a service you have to patch, monitor and defend. The installer takes
yes/no for each component, so decide first and pass the flags:
That example sells web hosting and nothing else: no Exim, no Dovecot, no antivirus or
spam filtering, no FTP, no BIND. Adjust it to what you decided in chapter 01. If you are selling mail,
install the mail stack. If you are running your own authoritative DNS on this box, leave --named alone. Run bash tulio-install.sh -h for the
complete option list with its defaults.
Name the PHP versions rather than taking all of them
--multiphp yes installs every version in the installer's list, which on
this release is 5.6 through 8.5.
Most of that range stopped receiving upstream security fixes years ago, and an
interpreter you installed and never advertised is still an interpreter on the box.
The option also takes a comma-separated list, and then it installs exactly those:
--multiphp 8.3,8.4,8.5 in the example above. The highest version
in the list becomes the default PHP-FPM version. Check what landed:
Read php.net's supported-versions page before you run the install, not after. That page is the authority and it moves; the list here was what it carried on , when 8.2, 8.3, 8.4, 8.5 were all still receiving fixes and everything below 8.2 was end of life. Adding a version later is one command; removing one that customers have started using is a migration.
Check the list the installer accepted
The installer validates the whole comma-separated list in one match rather than one version at a time. A version it does not recognise can therefore be accepted alongside ones it does, and the failure surfaces later as an apt error instead. Type the list carefully, and confirm what landed with the command above.
Quotas are easier now than later
--quota yes enables filesystem quotas, which is what turns a package's
disk limit from documentation into an enforced limit. It depends on how the filesystem
is mounted, which is straightforward on an empty machine and disruptive on one with
customer accounts on it.
The administrator account
Pass --username <ADMIN_USER> and pick a name that is not admin. Every command in the rest of this guide writes <ADMIN_USER> where that name goes.
If you omit --password, the installer prompts for one and will not continue
until you give it a non-empty value. It does not generate a password for you, and it
does not hide what you type: the prompt echoes to the terminal, so the password is in
your scrollback and possibly in a session recording from the moment you enter it. That
is the reason chapter 06 rotates it before the panel is reachable from
anywhere but your own address.
Passing --password on the command line puts the password in your shell
history and, for as long as the installer runs, in the output of ps for
every user on the machine. Let it prompt instead.
When it finishes, the installer prints the panel URL. If you supplied an email address it also tries to mail the details there, which will not work until this machine can send mail.
Chapter 05
Make the panel a production panel
A fresh install works. This chapter is what it takes to run it in production, and none of it takes long.
Hostname and certificate
The panel should answer on its own name, with a certificate that matches. The cheap way
to get there is to have done it already: pass --hostname to the installer,
with the name from chapter 02 resolving to this
machine before you run it. Then all that is left is the certificate:
That validates the host name and issues a certificate for it, which is what makes https://<PANEL_HOSTNAME>:8083 load without a warning. Do it
before you point anything else at the panel: the billing adapter verifies TLS by default,
and a name mismatch there presents as a connection failure rather than as a certificate
problem.
Renaming a panel later is not one command
v-change-sys-hostname exists and it changes the system hostname. On a
panel that is already serving, that is only one of the moving parts: the administrator
web domain has to be renamed, a certificate covering the new name (and, if you want a
way back, the old one as a SAN) has to be issued and installed as the host and backend
certificate, and anything pointing at the old name has to be moved. Getting the order
wrong locks you out of the panel over HTTPS.
Pick the name before the install. If you genuinely have to rename later, do it before there are customers, with a backup and a written rollback in front of you.
Credentials, without putting them in a shell history
The administrator password has been on your screen and in your scrollback since the
installer prompted for it. Rotate it. The obvious way to do that is also the wrong one:
a password passed as a command-line argument lands in your shell history file and is
visible in ps to every user on the machine for as long as the command runs.
v-change-user-password takes the password as an optional second argument.
Leave it off and the command asks for it, without echoing:
If standard input is not a terminal, the same command reads the password from standard input instead, so a password manager's CLI can hand it over without either of you typing it:
Substitute whatever your password manager's read command is. What matters is that the
value arrives on standard input rather than in argv. The panel's own
Users page does the same job through an authenticated session, and is the right answer
on any command that does not offer a prompt.
Generate the password with a password manager and store it there. Every operator with panel access gets their own account rather than sharing this one: an audit log that names one account tells you nothing when there are two of you.
SSH, firewall, and the IPv6 gap
-
Install your public key and turn password authentication off in
sshd_configonce you have confirmed the key works. Confirm from a second terminal before you close the first one. -
Turn the panel firewall on with
v-add-sys-firewalland read the resulting rules withv-list-firewall. Open what you sell and the panel port; close everything else. If you installed with--fail2ban yes, its jails are managed alongside those rules. - The panel firewall is IPv4-only. If this host has an IPv6 address, the panel's rules do not apply to traffic reaching it that way. Filter IPv6 yourself, at the host or at the provider, or leave IPv6 off the machine.
Decide how customers reach port 8083
If what you sell includes panel access, your customers need to reach :8083. An allow list containing only your own address and the
billing host locks out the people paying you. There are three defensible answers and you
have to pick one before launch:
- Public, and hardened. The port is open to the internet with a valid certificate, fail2ban on the panel's login, strong per-user passwords, and monitoring on authentication failures. This is what most shared hosting does. It means the panel login is part of your attack surface, so it is also the case for keeping the panel patched on a schedule you actually meet.
- Behind a front door you run. A reverse proxy or access proxy in front of the panel, terminating TLS and applying whatever you want in front of it: rate limits, geographic rules, an allow list for the administrator paths specifically. The panel still has to see the real client address for its own logging and firewall rules to mean anything, so set the proxy's forwarded headers and confirm the panel reads them.
- Staff-only, because customers never log in. Legitimate if your product is managed hosting and you administer every site yourself. Then the allow list is your addresses and the billing host, and the panel port is not a public service at all. Say so in the product description, because "control panel access" is what most buyers assume shared hosting means.
v-add-firewall-rule takes an action, a CIDR and a port, and it is how the
second and third options get written down. Whichever you pick, test it from a network
you do not control before you sell anything.
Turn off what you are not selling
If you installed the full stack and then decided against mail or FTP, do not leave the daemons running. Read what is configured and stop what should not be:
Stopping a service is not the same as removing it, and a reboot or an upgrade can start it again. If a component is permanently out of scope, disable its unit at the system level as well, and remove its firewall rule so the closed port is closed in two places.
A PHP policy, written down
Chapter 04 installed a specific set of versions. The policy is what you tell customers about it: which versions you offer, which one is the default for a new site, and what happens when one reaches end of life.
Write down a notice period and hold to it. Sixty days before a version leaves php.net's supported list, mail every customer running it with the date and the version they will be moved to; on the date, move them. A version you keep past its end of life because one customer asked is a version you are maintaining alone, on a box that runs everyone else's sites too.
Chapter 06
Leave demo mode, then open the API to Billing alone
TulioBilling provisions accounts by calling the panel's HTTP API, so the API has to be on and reachable from exactly one place. If the panel is currently in demo mode, getting there is a runbook rather than two commands, and the order in it is what keeps a publicly reachable panel from becoming writable before its published password has been changed.
Never convert a publicly credentialed demo in place
A panel running in demo mode with a login published anywhere is safe only because the
software refuses every state-changing action. Clear DEMO_MODE while that
panel is still reachable, and the credentials everyone already has become a working
administrator session on a writable production panel, for as long as it takes you to
type the next command.
Isolate the panel first. Rotate the credential while it is isolated. Restore access afterwards. Every other order has a window in it.
And rotating the password is not the whole of it. Changing a password does not end the
sessions that were opened with the old one: v-change-user-password sets the Unix password, the stored MD5 and the
password-reset key, and web/inc/main.php admits a session on the strength
of the username and timeout it already holds without comparing either value. So anyone
who signed in with the published demo login keeps an administrator session across the
rotation, and gets it back the moment public access returns. The sessions have to be
destroyed explicitly, and the destruction has to be checked.
Step 1: capture a session from the panel as it is now
Before anything changes, take one of the sessions this panel is currently handing out — the same thing an outsider already has, obtained the same way, with the published login. It is the only way to prove at the end that the old ones stopped working, and the proof is the point: every step below is reversible except the one where you put a panel back on the internet.
Keep that id. Run this from the address you will run the check from at the end: the panel
destroys a session presented from a different address than it was created on unless DISABLE_IP_CHECK is set, so a test run from somewhere else would report
success whether or not the session was actually gone.
Step 2: isolate the panel
Close public access at the provider firewall or the reverse proxy in front of the panel,
not with the panel's own firewall commands: v-add-firewall-rule is one of
the commands demo mode refuses, so the box cannot lock its own door while it is in this
state. Keep exactly one route open for yourself, and test that route before you cut the
rest.
Run that from a network that is not in your allow list. A connection failure is the expected result. Then confirm from your own address that the panel still answers, so you are not about to work on a machine you cannot reach.
Step 3: take a powered-off snapshot, or stop here
The panel's own backup command is refused while DEMO_MODE=yes, along with
every other state-changing command, so the copy has to come from underneath the panel. It
also has to be consistent: nothing may write to the panel's data, the customer
home directories or the databases while it is being made.
There is one way to get that which does not depend on remembering everything that writes to the disk. Shut the machine down in an orderly way, take the provider's or hypervisor's snapshot with it powered off, then boot it and confirm it came back:
The panel is already isolated from step 2, so the downtime costs you nothing but your own
access. Confirm in the provider's console that the instance is actually stopped before you
take the snapshot. A snapshot of a running disk is what this step is meant to avoid.
After the boot, systemctl --failed should list nothing, and a customer site
and the panel should both answer from your own address.
If you cannot take a powered-off snapshot, do not convert
The alternative is quiescing a running machine by hand: enumerate every writer, stop the timers as well as cron, stop the panel's queue runner, the web servers, PHP-FPM, the mail stack, the database, your monitoring agent's collector and whatever else was installed on this box, verify each one stopped, copy, then restart exactly that set. This guide used to print that procedure. It no longer does, because it cannot be made reliable from a page: it asks you to enumerate the writers on a machine you may not have built, and one missed unit — a custom service, a timer that fired between the inventory and the copy — produces an archive that restores into a database mid-write and looks exactly like one that does not.
A conversion is a scheduled change on a panel you have already taken off the network. If
the snapshot is not available to you, get it — move the instance to a provider that
offers one, or attach the disk to a hypervisor that does — before clearing DEMO_MODE. Converting without a copy you can restore from means the next
steps have no way back.
The snapshot holds every customer's data and the panel's own credentials, so it is subject to the same rules as a backup archive: encrypted at rest, access-controlled to the people who need it, and copied out to wherever your retention policy says it lives rather than left in the provider's default pool indefinitely. Chapter 13 is where those rules are written down.
What that covers and what it does not is the subject of chapter 14. For right now it is enough that the panel's configuration, its users, its access keys and its data exist somewhere the next few commands cannot reach.
Step 4: leave demo mode
One command does it. On 1.10.6 and later it writes DEMO_MODE directly
rather than going through the config command that demo mode itself refuses:
Those last three lines are the panel saying that leaving demo mode does not switch the
API back on. The API was disabled on the way in and the allow list was emptied; restoring
either is a separate decision it will not make for you. Confirm the mode changed rather
than assuming: grep '^DEMO_MODE=' /usr/local/tulio/conf/tulio.conf should
print DEMO_MODE='no'.
Step 5: rotate every credential the demo published
Now, before public access comes back, and while the panel is still only reachable from
your address. v-change-user-password works again the moment demo mode is
off, and it takes the new password on standard input rather than as an argument:
Rotate the administrator account and every other account whose password was published,
shared in a demo, or typed into a browser by someone who is not you. Then look at what
else the demo period left behind: v-list-access-keys for API keys, and v-list-users for accounts nobody created on purpose.
Step 6: destroy every session the demo left behind
The password change does not log anybody out
v-change-user-password runs chpasswd, copies the new hash
into the user's record and regenerates the password-reset key. That is all it does.
The panel's session check, in web/inc/main.php, admits a request when the
session it is carrying has a username, a matching originating address and a live
timeout — it never compares a password or a hash, so a session opened before the
rotation stays valid after it. Restore public access without dealing with that and the
published demo login is still an administrator session on a writable panel.
Sessions are files. Stop the panel so nothing can create another one while you work, delete them, confirm the directory is empty, and start it again — all still isolated:
Nothing in that directory is preserved first. A session is a logged-in browser, not a backup asset: there is nothing in one worth carrying across a conversion, and the whole purpose of this step is that none of them survives it. Every staff member signs in again afterwards, which is the intended outcome rather than a side effect.
The find is the one the installer writes into /etc/cron.daily/php-session-cleanup, with its age test removed so that it
matches all of them instead of the ones over a week old. Keep the -name 'sess_*' -type f and the -mindepth 1: they are what stop
this from being a recursive delete of a directory whose path you mistyped.
The pattern is sess_* rather than the session id because PHP's files handler
names each file sess_ followed by the id — the TULIOSID cookie
value is the id, not the filename. It is worth knowing which is which if you ever go
looking for one session rather than deleting all of them: a find for the
cookie value verbatim matches nothing, deletes nothing, and exits 0.
Then prove it. The session captured in step 1, replayed from the address it was captured from, has to be refused now — and refused by the panel rather than by the firewall, so run this over the route you kept open:
A redirect to /login/ is the panel saying the session it was handed names
nothing. A 200 means the session is still live, and the conversion is not
finished: do not go on to the steps that put the panel back on the internet. Then sign in
from a fresh browser with the new administrator password and confirm that works, so you
leave this step knowing both halves — the old credential is dead and the new one is
usable.
Step 7: apply the hardening from chapter 05
Certificate, SSH keys, firewall rules, IPv6 filtering and the PHP set, all while the panel is still isolated. A panel that was a demo has never been through any of it.
Step 8: enable the API, then say who may reach it
Two commands, in this order. enable seeds the allow list with 127.0.0.1 when it finds it empty, which is loopback and nothing else, so
the second command is what lets the billing host in at all.
Write all even though it is the default on this version: it is what the
command needs on an older panel, where omitting it enabled the endpoint and left the API
itself off. Replace <BILLING_IP> with the public address your billing
host connects from, which is not always the address it answers on. Check it
rather than assuming.
Then confirm from outside that the endpoint is answering. A disabled API returns HTTP
200 with the body Error: Disabled, so a status-code check tells
you nothing. Read the body:
It must no longer say Error: Disabled. If it does, the API is
still off regardless of what the config file says.
One upgrade footgun
A tulio package upgrade replaces web/api/index.php with the
enabled form of the file. If you are deliberately keeping the API disabled at that
layer while the panel is in demo mode, an upgrade undoes it. The configuration layer
still holds, because API and API_SYSTEM live in tulio.conf and that file is not replaced, so nothing is exposed. A layer
you thought you had is gone. Re-check the endpoint after every panel upgrade.
Step 9: restore the access you meant to have
Only now does public access go back, and only in the shape you decided in chapter 05: open to customers if you sell panel access, behind your front door if you run one, or staff-only if customers never sign in. Two things have to be true before you open the port: the captured session from step 1 is refused, and the new administrator password works. If either is unproven, the panel stays isolated. Then open the panel port to the internet again, or re-point the reverse proxy, and check from outside that the login page loads and that your new password works there too.
Sign in once as the administrator and once as a test customer before you consider this done. A panel that answers on the port but rejects your password is a discovery you want now rather than during an order.
Step 10: update whatever expected the old state
If you have monitoring that asserts the panel's API is off, change it in the same session. A check that encodes the previous state pages you about a change you made on purpose. The same goes for any page or document that describes this panel as read-only.
Chapter 07
Packages, plans, and the limits behind them
A TulioCP package is the set of limits a user account gets: disk, bandwidth, how many web domains, DNS zones, mail accounts, databases and cron jobs, the web and backend templates, the shell, and the backup count. Build them here, because they are what TulioBilling will name later. The chain that connects a customer's order to one of these is spelled out in chapter 09; the short version is that a package name is matched by name, exactly, including case.
Build the packages first
Create them in the panel under Packages, where every limit has a labelled field, and read them back from the command line to confirm what you built:
Three tiers is plenty to launch with. A package you sell once is a package you maintain for years, so resist inventing a fourth to catch one prospect's edge case.
Decide these deliberately rather than accepting the defaults:
- Disk. Only enforced if you installed with filesystem quotas. Without them the number in the package is documentation, and a runaway account fills the volume for everyone.
- Bandwidth. Decide what happens when it is exceeded, whether that is suspension, an overage charge or nothing, and write it in the terms before it happens.
- Databases and mail accounts. Zero is a valid limit and the right one for services you do not sell.
- Shell access. Default to none. Turning it on for one customer is easy; turning it off later is a conversation.
- Backups. The per-user backup count is what a customer can restore themselves. It is not your disaster recovery. See chapter 14.
- Templates. The web, proxy, backend and DNS templates named in a
package have to exist by name on any machine you restore an account onto. A restore
onto a host that does not have your custom template silently falls back to
default, which is a different vhost than the one the customer had.
Then run the lifecycle by hand
Before any of this is wired to billing, prove it works. If the panel cannot create and destroy an account cleanly on its own, adding an API caller in front of it only makes the failure harder to read.
Create the test account in the panel's Users page, not from the shell.
v-add-user takes the password as its second positional argument, and unlike v-change-user-password it has no prompt and does not read standard input:
every command-line form of it puts a real password into argv, where it is
visible in ps to every user on the machine and lands in your shell history.
The Users page does the same work over an authenticated session. Give the account a name
you will recognise as disposable, and the package you are testing.
Then drive the rest of it from the command line, where nothing takes a secret:
Between those commands, actually look: put an index.html in the account's
web root and load it over HTTP, then over HTTPS once the certificate has issued. Check
the vhost, the DNS zone if you are running DNS, and the database if the package allows
one. Then delete the user and confirm the home directory, the vhost and the zone are
gone. A half-deleted account that still holds a name is the kind of thing you find out
about six months later, from a customer who cannot add that domain.
Chapter 08
Stand up TulioBilling on its own host
TulioBilling is a PHP application. It runs on a standard LAMP or LEMP stack
and needs PHP 8.3+ with the
curl, intl, mbstring, pdo and
zlib extensions, plus MySQL or MariaDB. It is currently at v0.1.0, flagged a prerelease on
GitHub and described by its own README as beta. Treat that as a real constraint on how
much of your business you automate through it on day one.
Put it on its own host
Not on a panel node. The billing database holds client records and the application configuration holds gateway and registrar credentials; a panel node is a machine where customers run their own code. Separating them is free at this stage and expensive later.
Install per the repository's instructions: composer install, npm install, npm run build, then point the web server's
document root at src/ and open the site to run the installer. Give it its
own certificate for billing.<YOUR_DOMAIN>.
Harden it before it holds anything
The document root is the application directory itself, so src/config.php
and src/data/ sit inside the tree the web server serves. The repository
ships an .htaccess that blocks direct access to both, and that file does
nothing at all on nginx. If you are running LEMP, the equivalent location blocks are
yours to write, and until they exist your logs and uploads are fetchable.
- Ownership and modes. The application files belong to a user that is
not the one PHP runs as, except for the paths that have to be writable:
src/data/cache,src/data/logandsrc/data/uploads.config.phpholds the database password and theinfo.saltthe application hashes with. It should be readable by the PHP user and by nobody else, and it should never be world-readable. - Delete the installer once it has run.
rm -rf <BILLING_ROOT>/install. Not a deny rule instead — the directory itself.install/install.phpis its own entry point: it loads the Composer autoloader directly and never goes through the application bootstrap, so nothing in the application's configuration or its access control applies to it. The shipped.htaccessexplicitly allows/install/index.phpand/install/install.phpthrough, and on nginx that file does nothing at all. The application does try to delete the directory itself on the next request throughindex.php, which requires the web user to have write access to its own application tree — which the ownership rule above deliberately denies. Do it yourself. - Then prove it is gone, both ways. Fetch
/install/install.phpand/install/index.phpdirectly and confirm a 404. Then fetch/install/and any rewritten form your configuration produces, and confirm those 404 as well rather than reaching the application's router. - Deny list on nginx. Return 404 for
/config.php,/config-sample.php,/config.old.php,/data/log/,/data/cache/, dotfiles, and the.twig,.sql,.log,.bakand.oldextensions. Then fetch each of those paths yourself and confirm you get a 404 rather than a file. Do this as well as the deletion above. - Database. One database, one account, granted only on that database, and bound to localhost or to a private address the billing host alone can reach. Nothing on a panel node has any reason to open a connection to it.
- Configuration. Leave
security.modeatstrict,force_httpson,api.CSRFPreventionon, anddebugoff in both the main and the Twig sections. If the host sits behind a proxy, settrusted_proxiesto that proxy and nothing wider, because that setting is what decides which address the rate limiter counts against. - Firewall and headers. Ports 80 and 443 open, 22 restricted to your
addresses, everything else closed. HSTS,
X-Content-Type-Options,X-Frame-Optionsand a referrer policy on every response. - Staff accounts. One account per person, with the permission groups the application provides, rather than one shared login. Delete accounts when people leave.
- Logs.
src/data/logfills up. Rotate it, decide how long you keep it, and ship a copy somewhere the billing host cannot rewrite, because those logs are your evidence if the billing host is what gets compromised. - Updates. Before each one: a database dump, a copy of
config.php, and a copy of the application directory. Write down the command that puts all three back. An application update that has to be undone at 2am is not the moment to work out where the uploads live.
There is no multi-factor authentication for staff
TulioBilling at v0.1.0 has no second factor for the administrator area. The staff table stores an email, a password hash and an API token, and there is no time-based one-time password, no WebAuthn, and no recovery-code machinery anywhere in the application. This is a gap, not a setting you have missed.
What you can do instead: restrict the administrator prefix by source address, at the
web server or at a front door that does support a second factor. Change admin_area_prefix from its default so the login is not at the obvious
path. Use long unique passwords from a password manager, and alert on administrator
sign-ins from addresses you do not recognise. None of that is equivalent to a second
factor, so say so on your own security page rather than implying otherwise.
Run the cron, or none of the automation happens
TulioBilling does its recurring work in a single scheduled run, and the
application's own admin area states the schedule it expects: cron.php,
every five minutes.
<WEB_USER> is the account PHP-FPM runs as. Running the job as root works once and then leaves root-owned files in src/data/cache that the web user cannot rewrite, which fails later and
looks unrelated.
Without that entry, the following do not happen at all:
- Paid invoices are never activated, so a paid order never provisions.
- Recurring invoices are never generated, so subscriptions stop billing.
- Invoice reminders, due-date events and suspension warnings never send.
- Expired orders are never suspended and suspended orders are never cancelled.
- Unpaid orders are never cleaned up and expired carts never expire.
- Queued email sits in the queue.
The application has a fallback that runs the batch when an administrator signs in, and
the shipped configuration turns it off (disable_auto_cron). Even with it on,
it means your dunning schedule is "whenever someone logs in", which is not a schedule.
Prove it runs, from the command line and then from the admin area:
A non-zero exit status means at least one task threw, and the reason is in src/data/log. Then open the cron settings page in the admin area and check
the last execution time: if it is older than ten minutes, the schedule is not running,
whatever the crontab says. Put that timestamp in your monitoring, because a silently
dead billing cron looks exactly like a quiet week.
Turn it into your company, not a demo
- Company identity. Legal name, registered address, tax identifiers, and whatever your jurisdiction requires on an invoice. This is what prints on every invoice you issue; get it right before you issue one, because reissuing is worse.
- Invoice numbering and currency. Set the series start and the currency before the first order. Both are awkward to change with invoices already in the system.
- Legal pages. Terms, acceptable use, privacy and refunds, published and linked from checkout. Customers agree to what is there at the moment they order.
- Email templates. Order confirmation, invoice, payment received, overdue notice, suspension warning, password reset. Read every one of them out loud once. The defaults are someone else's voice and some of them say things you have not promised.
- Outbound network calls. TulioBilling ships with its own phone-home features disabled, meaning update checks, alerts and the extension directory, and the endpoints they point at are placeholders that are not operated yet. Leave that setting off.
Configure the abuse controls before signup opens
A public hosting signup form is a target from the hour it exists: card testing, spam account creation, and free-trial farming all arrive before your first real customer does. TulioBilling ships controls for this in its Antispam module and in its rate limiter, and they are not all on by default.
- Rate limits are on by default. The limiter is enabled in the shipped
configuration, and client signup is capped per address per hour, alongside separate
limits for login, password reset, order generation, domain lookup and invoice payment.
Read the policy list before you widen anything, and put your own static addresses in
rate_limiter.whitelist_ipsif your testing keeps tripping it, rather than turning the limiter off. - The honeypot is on by default. A hidden field on the signup form rejects the submission when it arrives filled in. Confirm your theme still renders it, because a custom template that drops the field silently disables the check.
- Disposable-email rejection is on by default. Signups from known throwaway providers are refused. Decide whether that is the policy you want before a legitimate customer is turned away by it.
- CAPTCHA is off until you configure it. The module supports reCAPTCHA v2 and v3, hCaptcha and Cloudflare Turnstile, and each needs a secret key from that provider. With none set, the check does nothing. Turn one on and submit the form to watch it reject a missing token, because a CAPTCHA that is enabled with an empty key fails in the other direction and blocks everyone.
- Reputation lookups are off by default. Enabling the Stop Forum Spam check sends the visitor's address and email to a third party on every signup. That is a data-protection decision as much as a technical one, so put it in your privacy policy before you switch it on.
- The address block list is manual. There is a list you can add addresses to, and nothing populates it for you. It is where a specific abuser goes once you have found them.
Trigger each of these against your own form before signup opens, and watch it reject the submission.
Keep signup closed until you mean it
Self-registration is a switch. Keep it closed while you build, keep it closed while you test with accounts you create yourself, and open it only after the dry run in chapter 15 has passed end to end. An order that arrives before provisioning is proven is a refund and an apology.
Chapter 09
Wire Billing to the panel
This is the join between the two applications: one access key, one allow-list entry, one server record, one hosting plan, and then a test of every action. The failure worth finding here is not "it did not connect". It is "it connected and did half a thing".
Create a restricted access key
A TulioCP access key carries a permission list, and the entries in that
list are API role names, not command names. Each role is a file under
/usr/local/tulio/data/api/ naming a set of v-* commands and the
role allowed to call them; a permission the panel cannot find there is rejected with
API <name> doesn't exist. Read what your install ships with:
One of them is called billing, and it exists for
exactly this job. Use it, and leave the permission list off only for keys you intend to
be unrestricted, which this one is not:
The json format prints the access key ID and the secret. Copy both
immediately into the billing configuration and your password manager; the secret is
stored on the panel but there is no reason to go back for it. The comment is what tells
you six months from now which key this is, so make it say something.
What the billing role grants, and what the adapter actually uses
The role's COMMANDS list, verbatim from
/usr/local/tulio/data/api/billing:
v-add-userv-delete-userv-suspend-userv-unsuspend-userv-change-user-shellv-list-userv-list-usersv-make-tmp-filev-add-domainv-change-user-packagev-change-user-password
Server_Manager_Tulio sends 8 of them.
v-list-users is the connection test; v-add-user and
v-add-domain are the two halves of creating an account, and
v-delete-user is both the termination path and the rollback if the
domain step fails after the user was created. The rest are one command per lifecycle
action. Username changes, domain changes and IP changes are absent because the adapter
refuses them outright rather than attempting them.
The role is wider than the adapter needs. It also permits v-change-user-shell, v-list-user, v-make-tmp-file. Its ROLE is admin, so the key
has to belong to the administrator and a non-administrator cannot hold it. If that is
wider than you want, the roles are plain files: copy one, cut the list down to the 8 above, and name the copy in your key instead. What you
must not do is pass raw v-* names as permissions, because the panel
checks them against the role directory rather than against /usr/local/tulio/bin.
Configure the server in TulioBilling
Add a hosting server with the TulioCP manager and fill in:
- Hostname
-
<PANEL_HOSTNAME>, the name on the certificate, not an IP address. - Port
8083. The adapter defaults to it; set it anyway.- Access Key ID
- The 20-character ID from the command above.
- Secret Key
- The 40-character secret from the same output.
- TLS verification
- On. The adapter's
tls_verifyoption defaults to true. If verification fails, the certificate or the hostname is wrong. Fix that rather than disabling the check. - Nameservers
- The server record carries up to four nameserver names, and they are what the activation email tells the customer to use. Fill them in with the names from chapter 12.
Match a hosting plan to a panel package, by name
A TulioBilling product does not name a TulioCP package directly, and this is the step that is easiest to skip and hardest to debug afterwards. The chain, end to end:
- The product stores a hosting plan id and a server id in its configuration.
- When an order is created, that plan id resolves to a hosting plan record, stored on the service alongside the server.
- On activation, the hosting plan becomes a server package object whose name is the hosting plan's name.
- The TulioCP adapter passes that name as the fourth argument to
v-add-user, where it has to be an existing panel package.
So the thing that must match a panel package is the name of the TulioBilling hosting plan, character for character, case included. Not the product title, which customers see and you will want to change.
Do it in this order:
- Create the hosting plan. Under hosting plans in the admin area,
create one whose name is exactly a package name from
v-list-user-packages. Copy and paste it rather than typing it. - Reconcile its limits. The plan carries its own disk, bandwidth, database, mailbox, subdomain and FTP numbers, and those are what the customer sees in the client area. They are not read from the panel. If the plan says 10 GB and the package enforces 5, the customer has been told something untrue, and nothing in either application will notice. Set them to the package's real numbers, and change both together whenever either moves.
- Attach the plan and the server to the product. Both, on the product's configuration. A product missing either one refuses the order with a message about being incompletely configured, which at least fails loudly.
- Order it. A product that looks right and has never been ordered has not been tested. Place one and watch the account appear on the panel with the right package.
Renaming a panel package later breaks every product pointing at it, because the name is the only thing joining them. If you have to rename one, create the new package first, repoint the hosting plan, and place a test order before deleting the old one.
Then test every action, on something disposable
Create a test client and a test order against a throwaway domain, and drive the whole lifecycle from the billing side while watching the panel side. Confirm each one on the panel rather than only in the billing UI. The adapter reports success on the response it got, and what you are testing is whether the panel agrees.
- Connect Access-key authenticated connection test against the panel API
- Create Provisions the system user and primary domain in one pass
- Suspend Locks the account without deleting data
- Unsuspend Restores access to a suspended account
- Change package Reassigns hosting plan limits on the panel side
- Change password Rotates panel credentials from the billing order
- Delete Tears down the account when a service is terminated
Delete last, and check afterwards that the account, its domain and its files are gone. Then run it a second time. The failures worth finding only happen when the username or the domain already exists.
How the customer gets into the panel
Provisioning generates a hosting password, sends it to the panel, and then replaces the stored copy with a placeholder. It is never emailed. The activation email carries the username, the panel URL, the domain, the addresses and the nameservers, and it says in as many words that passwords are not included for security reasons.
So the customer's first sign-in works like this:
- They sign in to your client area, which is where their password is one they chose.
- They open the service and set a hosting password from there. That action calls the panel's change-password command through the adapter, so the panel and the customer now agree.
- They open the panel with that password.
The client area's control-panel link returns the panel's URL. It is not single sign-on: there is no token exchange in the adapter, and the customer still has to sign in on the other side. Write your welcome documentation accordingly, because "click here to open your control panel" reads like SSO and is not.
If a customer contacts you before setting a hosting password, the answer is to send them to the client area, not to read a password out of the database. There is not one there to read.
The hosted panel cannot demonstrate this
The hosted panel this site links has its API disabled by demo mode, so the provisioning path cannot provision through it. That is a property of that one host, not of the adapter. Against your own install, with the API enabled and the allow list right, these seven actions work today.
Chapter 10
Transactional email, and customer mail if you sell it
Your invoices, order confirmations and password resets are transactional email, and they have exactly one job: arrive. Sending them from the panel host's own mail server is the most common way a new hosting company discovers deliverability, usually from a customer who says they never got the reset link.
Use a provider
Get an account with a transactional email provider and point TulioBilling's mail configuration at its SMTP endpoint or API. You are buying two things you cannot build quickly: IP reputation, and the feedback loops that tell you when a message bounced or was marked spam. Send from a subdomain you use for nothing else, so that a reputation problem on your marketing mail cannot take your password resets down with it.
Publish the DNS that makes it work
- SPF. A TXT record on the sending domain naming the provider as authorised. One SPF record per domain; a second one invalidates both.
- DKIM. The selector record your provider gives you, so receivers can verify the signature the provider adds.
- DMARC. Start at
p=nonewith a reporting address, read the reports for a couple of weeks, then move toquarantineand on torejectonce you can see that everything legitimate passes. - MX. For the domain that receives your support mail, pointing at wherever that is actually hosted. Missing MX records are why abuse reports about you bounce.
- PTR. If any mail leaves your own address, set reverse DNS for it at the provider that owns the address.
Then prove it. Send a real order confirmation, a real invoice and a real password reset to a mailbox at a provider you do not control, and read the received headers. SPF, DKIM and DMARC should all pass. "It arrived" is not the test. "It arrived and the headers say why" is.
Customer mailboxes are a different business
Everything above is about your own mail: six messages a day, to people who ordered from
you. If you install the mail stack and sell mailboxes, you are also running an outbound
mail service for strangers, and that is a different operational problem with its own
failure mode. Skip this section if you installed with --exim no and mean to
keep it that way.
- Find out about port 25 before you sell anything. Many providers block outbound 25 by default, some unblock it on request with an abuse history, and some never do. A mail product on a host that cannot open connections on port 25 delivers nothing.
- Relay outbound customer mail through something you control. Sending directly from the shared address that also serves the panel means one compromised customer account gets that address listed, and the listing takes your own invoices down with it. A smarthost, or a separate address for customer mail with its own reverse DNS, keeps the blast radius smaller.
- Publish SPF, DKIM and DMARC per customer domain. Your own sending
domain's records do nothing for
customer.example. If you host their DNS, generate the records when the mail domain is created and check them. If they host their own DNS, you need a documented set of records to hand them and a way to tell them the mail is failing because they never added it. - Rate limit per account. A compromised mailbox sends as fast as the machine allows. A per-account hourly cap turns that from a listing into a support ticket. Decide the number before you need it.
- Watch the queue and the block lists. A growing outbound queue is the first sign of both a delivery problem and a compromised account. Alert on queue depth, and check the addresses you send from against the major block lists on a schedule.
- Have an abuse procedure written before the first complaint. How you identify the sending account, how fast you suspend it, who you tell, and what the customer has to do to get service back. Your upstream provider will ask, and the answer "we are looking into it" has a short shelf life.
Selling mailboxes is a legitimate product and it is a real ongoing cost. Deciding not to sell them, and pointing customers at a mail provider instead, removes this whole section from your operation.
Chapter 11
Take money
TulioBilling ships these payment adapters, by their identifiers in the
source: Stripe, PayPalEmail, plus ClientBalance, which draws down a credit the client already
holds, and Custom, which is discussed below. Getting approved by a
processor takes time and paperwork; start it while you are still installing things.
The Custom gateway is not a gateway
It prints payment instructions you write and waits for an administrator to mark the invoice paid. That is a legitimate thing for bank transfers, and it is a way to ship a checkout that appears to work and moves no money if you leave it selected by accident. Before launch, confirm which adapter is actually enabled.
Keep card data off your servers
The card fields in the Stripe checkout are Stripe's own, loaded from Stripe's script and rendered in frames Stripe controls. The card number goes from the customer's browser to Stripe; your billing host receives an identifier for a payment that has already happened. That is what keeps your compliance obligations small, and it is a property of not touching the data rather than of any setting.
Anything that changes that changes your obligations with it: a custom checkout that posts card fields to your own server, a support process where someone reads a card number over the phone and types it in, a log that captures a request body containing one. Each of those pulls you into a scope you have to be assessed against. Your processor decides which self-assessment questionnaire applies to your integration, so ask them rather than deciding for yourself, and get the answer in writing before you take a payment.
Callbacks are where the money actually lands
The gateway calls back to your billing host to say a payment settled, and that callback is what marks the invoice paid and triggers provisioning. It is a public endpoint that anybody can post to, so it has to prove who sent it — and how it proves that is a property of the adapter, not a setting you turn on. Open the adapter you chose and find its mechanism before you launch:
- A signed payload, for the Stripe-style adapters. The Stripe adapter
refuses to process a webhook when no signing secret is configured, requires the
Stripe-Signatureheader, and verifies the signature against the raw body before anything else happens. Test mode and live mode carry separate secrets. If webhooks are silently doing nothing, an unset secret for the mode you are in is the first thing to check. - A server-to-server postback, for the PayPal IPN adapter. There is no
signing secret to configure at all. Its adapter-specific credential is the PayPal
email address you are paid at — the rest of its configuration is a test-mode switch and
the notification, return and cancellation URLs, plus an optional setting for how the
customer is redirected, none of which authenticate anything. It authenticates by
posting the notification back to PayPal with
cmd=_notify-validateand refusing anything that does not come backVERIFIED, then checking that the payee on the notification is the address you configured. That check is what stops a genuine PayPal payment made to somebody else's account from being replayed at your invoice. Both halves have to work, and the way to know they do is to send one. - Test the mechanism your adapter uses. Not the one this page happens to describe first. Post a plausible but unsigned or unverifiable payload at the endpoint yourself and confirm it is refused. An endpoint that accepts an invented callback is a free hosting account, and it fails open silently.
- Rotate the secret once before launch, where there is one. Rotating under pressure, for the first time, while payments are failing, is worse than doing it now with nothing riding on it. Know where it is stored and how long the change takes to apply. Where the adapter has no secret, the equivalent exercise is confirming you can change the configured payee account and that payments to the old one stop being accepted.
- Duplicates are normal. Gateways retry, and the customer's browser redirect can arrive before or after the callback for the same payment. Both adapters deduplicate — Stripe by event and by which gateway record owns the object, PayPal by transaction id, status, type and amount together. Test it: pay an invoice and then replay the same event from the gateway's dashboard. The invoice must stay paid once, not twice.
- The endpoint has to be reachable. Valid TLS, no allow list in front of it, no maintenance mode blocking it. A gateway that cannot reach you produces orders that stay unpaid forever and customers who were charged.
- A paid invoice is not evidence that the callback arrived. This is the
one that catches people, because the happy path hides it. The Stripe adapter's redirect
flow retrieves the
PaymentIntentfrom Stripe's API when the customer's browser comes back and applies the payment there and then — no webhook involved. So an invoice can be marked paid, an account provisioned and a receipt sent on a billing host whose callback endpoint is unreachable, unconfigured, or answering 403 to every request the processor makes. You find out weeks later, on the first payment where nobody's browser completes the return: a renewal, a subscription cycle, an asynchronous method that settles hours after checkout, a customer who closed the tab. - So prove the callback on the processor's side, with the browser return
excluded. Three pieces of evidence, and none of them is the invoice:
- The processor's own delivery record — Stripe's webhook endpoint page, PayPal's IPN history — showing your URL, the event, and a 2xx from your host, at a timestamp you can match to the test.
-
Your side's record of the event having been processed, which for a
signing adapter is a record that can only exist downstream of verification. On the
Stripe adapter at the pinned commit,
processWebhookEvent()verifies withStripe\Webhook::constructEvent()and throws on a bad signature, a missingStripe-Signatureheader or an unconfigured signing secret — so nothing after that line runs unless the signature checked out. What it does not do is log that it did: there is no success line in that method to grep for, and a checklist that tells you to find one is sending you after something the code does not write. The local record that does exist is the transaction row the handler wrote for thatPaymentIntent, and the invoice it paid. For the PayPal adapter, which authenticates by postback rather than by signature, it is the_notify-validateround trip coming backVERIFIED. -
The two joined up. Correlate by the object the payment is, not by the event: take
the
PaymentIntentid from the delivery record, find the invoice it paid, and find the local transaction against that invoice. Do not look for a transaction row keyed by the event id. On a successful one-timepayment_intent.succeeded, TulioBilling stores the Stripe event id first and then overwritestxn_idwith thePaymentIntentid, so the event id is not in the row by the time you go looking — a search for it comes back empty on a webhook that worked perfectly. -
A run where the browser return cannot have done the work: a fresh test payment, made from a browser that is unable to reach
the return URL at all. Block that navigation or make the host unresolvable from
the machine you are paying on — a hosts-file entry pointing the billing hostname
at a dead address, a proxy rule, a firewall rule — and then check the billing
host's own access log and confirm that no request for the return URL arrived for
that payment. That last part is the evidence; the rest is the setup.
Closing the tab is not this test. Stripe's Payment Element runs inside the TulioBilling page andconfirmPayment()hands the browser the configured return URL as soon as the payment is confirmed, which can be before anyone has reached for the tab. A test whose exclusion depends on how quickly you clicked is a test whose result you cannot read afterwards: if the invoice is paid, you do not know which path paid it. Make the request impossible and then prove it did not happen.
PaymentIntentand discards the replay, so the invoice stays unpaid however well delivery and signature verification are working. That is a correct handler behaving correctly, and reading it as a broken callback sends you looking for a fault that is not there. Replay is the test for double-charging, above; it is not a test for whether callbacks arrive.
What proves it, on the Stripe adapter, is the three of those joined up. The processor's delivery record says the event was sent to your URL and got a 2xx. The access log says no browser ever asked for the return URL. And the invoice is paid, with a transaction against it carrying thatPaymentIntentid. The adapter has exactly two code paths that can write that row — the redirect flow, which requires the return request that did not happen, and the webhook handler, which requiresconstructEvent()to have returned. Rule out the first and the second is what is left. That is an argument from the code you are running rather than from a log line, and it is available today.
If you would rather grep for a sentence than reason about two paths, add the log line: one call inprocessWebhookEvent()afterconstructEvent()returns, recording the event id and type. It is a small, reviewable change and it makes every later investigation cheaper. It is also a change to the product, so it belongs in a pull request rather than in a launch checklist — do not treat it as something that must be in place before you can prove your callbacks work, because the evidence above does not need it.
Which of those you can collect is adapter-specific and processor-specific: read your adapter's callback handler and your processor's dashboard rather than assuming this list transfers.
Test payments, then a real one, in that order
- Test mode first. Run the whole path with the processor's test credentials: order, payment, webhook, invoice paid, account provisioned. Nothing about that costs anything or needs a real card.
- Then one real, low-value payment. Buying from yourself is the only way to see the live path, and processors have rules about it. Some allow a genuine self-purchase, some treat repeated self-funding as a warning sign, and some prohibit it outright. Read your agreement, and if you are unsure, ask support before you do it rather than after your account is under review.
- Then refund it, and reconcile. Refund the payment in the gateway, record it in billing, and check that both sides agree afterwards. That exercise is also your refund runbook.
Fraud, disputes, and reconciliation
- Turn on the processor's fraud screening. Hosting is a favourite for card testing: small recurring amounts, instant delivery, no shipping address. Use the rules your processor provides, and add a manual review step for the order shapes that look like testing rather than buying.
- Currencies and tax. Set your base currency, any additional ones, and the tax rules that apply to your customers. Check what an invoice renders for a customer in each country you sell to.
- Chargebacks. They cost the disputed amount plus a fee. A recognisable statement descriptor and a support reply faster than the customer's bank are what reduce them.
- Reconcile monthly. The gateway's payout report against the invoices marked paid in billing. A webhook that stopped arriving three weeks ago shows up here as a gap, and nowhere else.
Then decide the dunning rules: how many days overdue before a reminder, before suspension, before termination. Whichever you choose, confirm the suspension action is wired to the panel and that you have watched it work. A suspension that fails silently is a customer who stopped paying and kept hosting. All of it runs on the cron from chapter 08, so check that first when a dunning step does not fire.
Chapter 12
Registrar and authoritative DNS
If you resell domains
TulioBilling has registrar adapters for Namecheap, Resellerclub, Resellbiz, Internetbs, Netearthone, Resellerid, plus Email, which mails the request to you to action by hand, and
Custom. Reselling means a reseller account with one of them, funded, with
its own contract and its own approval time.
The Custom registrar always says yes
Its own description in the source is that it "always responds with positive results". It reports every registration, transfer and renewal as successful without contacting anyone. In production that means selling a domain you have not registered, and finding out when the customer's site does not resolve. It is a development placeholder. Do not ship with it.
Reselling is optional. Telling customers to bring a domain they already own and point it at your nameservers removes a vendor relationship, a funding balance and a category of support ticket. Decide on purpose.
An adapter with a real name in it is not a tested integration
Picking a registrar adapter that is not the placeholder is the first step, not the last one. These adapters call third-party APIs that change, and this application is at v0.1.0. A class that exists tells you somebody implemented that registrar's API at some point. It does not tell you the API still looks like that, that your reseller account is provisioned for it, or that your credentials work.
Before you sell a domain, run the whole lifecycle. Use the registrar's sandbox if it has one; otherwise use a cheap real domain you own and are willing to lose:
- Availability. A lookup for a name that is taken and one that is free. Both answers have to be right, because a broken lookup sells registered names.
- Registration. A real registration that completes, and the domain visible in the registrar's own control panel afterwards, not only in billing.
- Contact data and privacy. Check what was actually filed as the registrant. Then turn WHOIS privacy on and off and confirm the registry reflects it. Wrong registrant data is how a domain gets suspended for verification failure.
- Nameserver update. Change the delegation from billing and confirm it
at the registry with a
digagainst the parent zone, not by reading the billing UI back to yourself. - Transfer and EPP. Request the authorisation code, and confirm you can get one. A registrar you cannot transfer away from is a registrar you are stuck with. Note that some adapters do not implement every operation, and one that throws on transfer is a gap you want to know about now.
- Renewal. Renew the test domain and confirm the expiry date moved at the registry. Then check what happens when your account balance is too low, because unfunded auto-renewals are how domains expire.
- A failure, on purpose. Break the credentials, or try to register a name that is taken, and watch what billing does with the error. You need to know whether the customer is charged for a registration that failed, and what your reconciliation looks like when it happens.
- Funding and allow lists. Confirm the account has a balance or an auto-top-up, and that the registrar's API allow list contains your billing host's outbound address. Many registrars require one, and the failure looks like an authentication error rather than a network error.
Respect the registrar's rules while you do this. Bulk availability lookups against a live API, or repeated register-and-delete cycles inside the grace period, are what get reseller accounts suspended. Ask what their sandbox is for and use it.
Authoritative DNS, redundantly
Whether or not you resell, your customers' domains have to resolve. That needs at least two authoritative nameservers, and the reason is not politeness: a single authoritative server is a single point of failure for every domain you host, and registries generally require two for a delegation.
Three architectures, and you pick one rather than blending them:
- Managed DNS. A provider runs the authoritative servers and you point
customer domains at their names. This satisfies the redundancy requirement on its own,
costs a vendor bill, and means zone edits happen at the provider rather than in the
panel. Install with
--named no. - Panel primary plus your own secondary. The panel's BIND is the source of truth and a second server on separate infrastructure transfers zones from it. Two failure domains, one place to edit. Restrict transfers to the secondary's address and check from elsewhere that a stranger cannot pull your zones.
- Panel DNS clustering. TulioCP can synchronise zones between panel nodes. Sensible once you have two nodes for other reasons; not a reason to build the second node early, and it needs testing like anything else.
Whichever you pick, two names on one machine is one nameserver. ns1.<YOUR_DOMAIN> and ns2.<YOUR_DOMAIN> in the same
rack fails the same power event twice.
Glue records, when you actually need them
Glue is not a universal requirement, and treating it as one sends people looking for a setting that does not apply to their case. The rule is about bailiwick:
- In bailiwick, so glue is required. You delegate
example.comtons1.example.com. To reach the nameserver a resolver must first resolve a name inside the zone that nameserver is authoritative for, which is circular. The registry breaks the loop by publishing the nameserver's address as a host object in the parent zone. That is glue, you create it in the registrar's control panel forexample.com, and without it nothing in the zone resolves. - Out of bailiwick, so no glue is needed. A customer's
customer.exampleis delegated tons1.yourbrand.com. Those names are resolvable independently throughyourbrand.com, so there is no loop and the registry needs no addresses. This is the common case for every customer domain you host, and there is nothing to create.
So the work is one-time and it is on your own brand domain: if your nameservers are named under a domain they are themselves authoritative for, register the host objects once at that domain's registrar. If you name them under a domain you delegate elsewhere, you never touch glue at all. Work out which case you are in before you go looking for the setting.
The rest of the DNS work
- Sensible TTLs. Long enough to survive an outage, short enough to migrate a customer. Lower them deliberately in the days before a planned move, not on the day.
- Verify from outside. Query both nameservers directly for a test zone, confirm they agree, and confirm the parent zone hands out the delegation you think it does. Do this from a network that is not yours.
- Monitor both, separately. A secondary that stopped transferring serves stale answers indefinitely and looks healthy. Compare serial numbers between the two, not just whether each responds.
Chapter 13
Backups, monitoring, and update discipline
What the panel's backup command covers
TulioCP's native backup unit is one user account. v-backup-user writes an archive of that user's web data, mail, databases and
cron jobs; v-restore-user puts one back. That is the whole scope. Panel
configuration, access keys, API roles, certificates, packages and templates are not in
it, and neither is anything belonging to the billing host. Covering those is chapter 14.
What a copy of one has to survive
A user archive contains site files, mailboxes, database dumps and whatever credentials your customers left in a configuration file. So the destination holding it has to encrypt it in transit and at rest, authenticate the machine at both ends, hold it under a credential that reaches nothing else, keep a history that whoever takes your panel cannot delete, and give it back when you ask. The panel provides none of that for you. What it provides is the archive; the next two sections are about where the rest has to come from.
What the panel's own destinations do, and what stops each one
v-add-backup-host takes a destination type as its first argument. At the
commit this guide was read from, none of them is a destination it will recommend for
archives holding customer data. They are listed here as capabilities of the product, each
with the thing that currently blocks it, because you will meet them in the panel and need
to know what you are looking at:
sftp- SSH transport, so the archive and the credential are encrypted in flight. Authenticates with a stored password, or with the path to a passphrase-less OpenSSH private key. Needs the expect utility on the panel host; the command checks for it and refuses without it.
- Blocked: The transport runs with `-o StrictHostKeyChecking=no`, on both the password and the private-key paths. The panel accepts whatever host key answers, on the first connection and on every one after it, so the destination is not authenticated and anything that can answer in its place receives the archives.
b2- Object storage over HTTPS, with a key ID and an application key. Needs the b2 command-line tool, which the panel will download and run for you if it is absent.
- Blocked: When /usr/local/bin/b2 is absent the panel fetches a release binary with wget, makes it executable and runs it as root. There is no checksum and no signature, and the only thing pinned is a version string in the panel's own upgrade.conf.
rclone- Hands off to a remote you configured yourself in /root/.config/rclone/rclone.conf, so the transport is whatever that remote speaks — which rclone lets be plain FTP or plain HTTP as easily as SFTP or S3 over TLS. The panel checks only that a section with that name exists.
- Blocked: The panel does not look at what the named remote does. A wrapper remote — crypt, alias, chunker, compress, combine, hasher, union, cache — carries another remote, and the multi-upstream ones name theirs in `upstreams` rather than in `remote`, so a procedure that follows one key stops before the hop that opens the socket.
- Blocked: The install line the panel runs is `curl -s https://rclone.org/install.sh | bash /dev/null`, which executes /dev/null and discards the script, so the backend depends on a binary the panel neither installs nor checks.
local- No remote at all: archives stay in /backup on the machine they protect. A copy, not a backup.
- Blocked: The archive never leaves the machine it protects, so it survives nothing that machine does not.
ftp- Plain FTP. Credentials and archive contents cross the network unencrypted. Accepted by the command; never a production destination.
- Blocked: Credentials and archive contents cross the network in clear text.
sftp encrypts the transfer and does
not authenticate the destination
SSH is a protocol operators already trust, which is why this one is worth spelling out.
The panel's transport in func/backup.sh runs sftp with -o StrictHostKeyChecking=no, on the stored-password path and on the
private-key path alike. That is not a minor setting: it removes the half of SSH that
establishes which machine is on the other end. The first connection accepts
whatever host key it is offered, and every connection after it accepts a changed one.
Encryption without endpoint authentication keeps the archive from someone reading the wire. It does nothing about someone answering in the destination's place, and that someone then holds every customer's site files, mailboxes, database dumps and whatever credentials your customers left in a configuration file. Restricting the account at the far end — a dedicated user, a chroot, a forced SFTP subsystem, no forwarding — limits what the credential can do on the machine you meant. It does not make the panel talk to that machine rather than to another one.
So this guide does not give you an SFTP setup procedure. A procedure ending in a registered destination would be telling you that you had authenticated transport when you had not. What would make it usable: a reviewed change to the product that pins the expected host key and validates it before the transfer — a known-hosts entry for the destination, strict checking against it, and a transfer that fails rather than continuing when the key does not match — tested on a host you control before it carries a real archive.
The other two are blocked on supply chain rather than on transport. The b2 branch of v-add-backup-host fetches a release binary from GitHub with wget, makes it executable and runs it as root when /usr/local/bin/b2 is absent. There is no checksum and
no signature, and the only thing pinned is the version string 3.6.0 in the panel's upgrade.conf, so whatever that URL serves at the moment you run the command
becomes a root-owned executable on your panel node.
rclone's transport cannot be settled from this page. The panel checks that a
section with the name you gave exists in /root/.config/rclone/rclone.conf and looks no further, and what that section
does is not readable from its type: crypt, alias, chunker, compress, combine, hasher, union and cache are layers over another remote, and the
multi-upstream ones name theirs in upstreams rather than in remote — so a recipe that follows one key stops before the hop that opens the
socket. There is no procedure in this guide that settles it, so it does not print one.
Run the backup pipeline outside the panel
The panel's native backup path is a push: the panel connects outward and
uploads, so the credential for the destination lives on the panel, and the panel enforces
its BACKUPS retention count by deleting old archives at the far end. Whoever
gets root on the panel holds a credential that can delete everything it reaches. That is
not a property you can configure away — a credential that cannot delete breaks the rotation
the panel performs — so the copy you would actually recover from has to be produced by
something the panel does not control.
Build that as its own pipeline, on a host the panel cannot log in to, and hold it to these:
- A dedicated puller. A separate host or agent that reaches into the panel on its own schedule, running a transfer tool you chose and reviewed rather than whatever the panel would have invoked. It also adds a flow across your plane boundary, so write it into the table in the architecture section.
- Verified endpoint identity, in both directions. Pinned and checked SSH host keys, or TLS with certificate validation against the name you meant. This is the requirement the panel's own SFTP path fails, and it is the reason the pipeline is out here instead of in there.
- Configuration that does not disclose its own secrets. Credentials come from a file or a secret store the transfer tool reads, not from a command line, and the commands you run to inspect the pipeline print the transport settings with secret values redacted. A support transcript, a terminal capture or a log line is a place a key ends up without anyone deciding it should.
- Encrypted in transit and at rest.
v-backup-userwrites a plain.tar, so encryption at rest is something your pipeline adds — an encrypted volume on the backup host, server-side encryption on the bucket, or client-side encryption before upload. Decide which, then confirm it on the destination you actually configured. - History the panel cannot rewrite. Object lock or immutability at the storage provider, append-only credentials, or filesystem snapshots taken on the backup host's own schedule. Whichever you pick, the production panel must not hold a credential that can delete or overwrite it.
- Least privilege, split by direction. The puller's credential on the panel side reads and nothing else. The credential that writes the immutable copy cannot delete it. Monitoring and restore rehearsals get read-only credentials of their own.
- A retention period you chose, and a restore you have run. Long enough to reach back past the point where you stopped paying attention, and proven by restoring from it onto a scratch machine rather than by the pipeline reporting success.
- Key recovery that does not depend on the systems being recovered. Whatever unlocks those archives — an encryption key, a bucket credential, a passphrase — is recoverable without them, and you have used the recovery copy once.
There are no commands here, deliberately. Which tool you run and how it authenticates depend on the destination you chose, and a worked example would be a recipe nobody reviewed against your setup.
What the panel can still do for you is produce the archive. v-backup-user with the default local destination writes to /backup on the panel host, which the pipeline above can then collect over its
own authenticated channel. That keeps the panel's job to the part it does well — knowing
what belongs to a user account — and keeps the credential for the copy that matters off
the machine you are protecting yourself against losing.
Check the destination itself afterwards, not the exit status: an archive that is a few
hundred bytes is a failure that reported success. Note also that v-backup-user is one of the commands demo mode refuses, so a staging box
parked in demo mode is a box whose backups are not running.
Retention matters as much as the copy. Silent corruption you notice on Thursday outlives a
two-day window. The v-*-restic command family is a separate path in the panel
that keeps daily, weekly, monthly and yearly snapshot counts in a restic repository; it was
not read end to end for this guide, so treat it as something to evaluate rather than
something recommended here.
Monitoring that fails in both directions
Check the things a customer would notice and the things they would not:
- The public site, the billing app and the panel each return what they should. Status code and body, since a 200 can carry an error page.
- Certificate expiry for every name you serve, with warning well before the failure.
- Both authoritative nameservers answer, and their zone serials agree.
- Backups ran, are the size you expect, and are recent. Check the destination, not the panel's opinion of the destination.
- TulioBilling's last cron execution is under ten minutes old.
- The billing outbound mail queue is not growing.
- Disk on every host, before it is full rather than after.
- The panel's API state is what you intend, alerting in either direction.
Make a healthy run silent. Mail on every quiet check and you will filter the whole stream into a folder within a fortnight.
Updates, and a rollback that exists before you need it
Panel automatic updates are off by default, because an update changes a running
production panel. They can be turned on under Server Settings, Updates, or with v-add-cron-tulio-autoupdate apt. On a single node with no staging, an
unattended panel upgrade at 3am is a change you did not watch. A defensible middle position
is unattended security updates for the distribution, panel upgrades applied by
hand at a time you choose, and the way back prepared first.
The way back is a signed repository snapshot, and there is no second method.
Nothing guarantees the repository still serves a superseded revision, and /var/cache/apt/archives is cleared by apt-get clean and by the
distribution's own cache maintenance. Keeping three .deb files is not a
rollback either: an upgrade that pulled in a newer library does not come back from the
panel packages alone. A snapshot is complete by construction — it holds the signed Release metadata and the package indexes as well as the pool, so apt resolves
the rollback the way it resolved the install, against the same suite, with signature
verification still working.
First give the snapshot host the keys it verifies with. debmirror checks the Release signature against a keyring you
name, and this machine is not a panel: nothing has ever installed /usr/share/keyrings/tulio-keyring.gpg on it. Fetch the key from its published URL, verify
the fingerprint before you trust it, and put it in a keyring of this host's own. Do not
copy the file across from the panel — that is trusting a key because the machine you are
preparing to roll back happened to have it.
7669 5098 4D6D 728A F4EE 6234 51D8 B0CD 5126 BF11, compared by machine rather than by eye. Four
things make that comparison load-bearing rather than decorative, and each of them is a
way the shorter version of this failed quietly. It is a script, with set -euo pipefail and a cleanup trap, so a failure is the run's status
instead of one command's inside an interactive shell — pasted as a chain, the rm -rf that tidied up afterwards succeeded and became the exit status of the
whole block. The mismatch path prints and exit 1s in as many words. The
fingerprint is checked on the armoured file before the dearmor, so a key that
does not match never becomes the binary keyring the next step is looking for. And the
installed file is read back at the end, because what is at /etc/snapshot-keyrings/tulio.gpg is the file debmirror will use, and "we
wrote it" is a different claim from "it is there and it is the right key". The Debian
half needs no fetching: debian-archive-keyring comes from Debian's own
signed archive, which this host already trusts.
"Contains the pinned key" is not "is the pinned key"
The obvious way to write that check — gpg --show-keys … | grep -qx "$fpr" — asks whether the expected
fingerprint is among the keys in the file. An armoured file can carry any
number of keys, so a bundle holding the Tulio key and three more passes it,
and the whole bundle is then installed as the keyring debmirror verifies Release signatures against. Every key in
it can sign a repository this host will then accept as authentic — which is the entire
property the fingerprint check exists to establish, handed away by a check that reads
as if it establishes it.
So the script enumerates the primary fingerprints in the file, requires there to be exactly one, and requires that one to be exactly the expected value — on the fetched file, and again on the
file that landed at /etc/snapshot-keyrings/tulio.gpg. Primary fingerprints
specifically: --with-colons emits an fpr record for subkeys
as well, attached to whichever pub or sub record came before
it, so counting every fpr line would reject an ordinary one-key file that
happens to have a signing subkey.
Snapshot both repositories. The panel's
dependencies come from Debian, so a mirror of apt.tuliocp.com on its own
names versions of libraries it does not contain, and the live Debian archive has moved
past them. That means the suite the panel was installed against, its updates pocket, and
the security repository:
Make the result immutable once it is written — a read-only export, object lock at a storage provider, or a filesystem snapshot the panel holds no credential for. A rollback source something on the panel can rewrite is not one you can trust on the day the panel is the problem.
Then define the rollback's apt once, on the panel, and use it everywhere.
One file, sourced by every snapshot-only apt command in this chapter: the update you are about to test with, and the downgrade further down that does
the actual rollback. Both options that follow are properties of this array, and an option
that is written down beside a command rather than inside the one that runs is not in
effect:
The exemption is in there from the start, and the callout below is why. It goes in this array and
nowhere else: never in /etc/apt/apt.conf.d/, never on an apt-get update that can reach a live repository, never as a default on a
machine. Globally, it disables the one mechanism that stops a network attacker from
holding you at a stale-but-signed index forever — replaying yesterday's metadata so the
security update you think you are installing never appears. Scoped to a snapshot served
read-only from a host that verified its signatures at creation and re-checked its hashes
an hour ago, that attack has nowhere to happen: there is no live repository in the
resolution and no version of the tree other than the sealed one.
Then test it, before the upgrade rather than during the rollback. The sources that array resolves against point at the snapshot host over HTTPS and nowhere else, so the live repositories cannot quietly supply a version the snapshot lacks. The panel never holds the snapshot tree itself — it is served the verified copy:
That update has to complete with signature verification clean. A NO_PUBKEY or a missing Packages index is a rollback that will
not run when you need it, and finding that out now costs you an afternoon rather than an
outage. Rehearse an actual downgrade against it on a scratch host once.
The snapshot expires, and the test above will not tell you
Debian's Release files carry a Valid-Until. The updates and
security suites carry a short one — days, not months — and it is inside the signed
metadata, which is immutable by construction: the whole reason the snapshot is a
rollback source is that nobody can change what is in it, and that includes the date.
So the test you just ran passes today and the same command fails months later with Release file … is not valid yet or … has expired, on the
night you need it. Testing before the upgrade does not establish that the rollback
still runs after it.
The answer this guide gives is the scoped exemption already in /root/rollback/apt.sh: accept the expired metadata for this snapshot,
against a tree whose signatures were verified at creation and whose hashes are
re-checked on the day, and nowhere else. What there is not is an answer where the
snapshot quietly keeps working.
Re-signing the snapshot into an internal archive of your own is a real alternative and
it is not written up here, because writing it up honestly means the whole thing: index
regeneration and signing for the Tulio suite and the Debian base, updates and
security suites, an apt-ftparchive configuration naming suite, codename
and architectures, a signing key with custody and a renewal schedule, a bootstrap that
gets its public half onto a rebuilt panel, and snapshot sources switched over to it.
A sketch of one suite is not that procedure, and a half-written repository is a
rollback that fails on the night in a new way. If you want it, build it and rehearse
it as its own piece of infrastructure.
So seal the snapshot when you take it. Verify the original signatures while they are still current, against the keyrings you pinned, and record a hash of every file. The exemption rests entirely on this: it is the only signature verification that will ever happen against fresh metadata, and the manifest is what says the tree has not moved since.
gpgv, not gpg: it verifies against exactly the keyring named and
has no web of trust to fall back on. Keep .manifest and .manifest.sha256 somewhere the snapshot host cannot rewrite, alongside a
note of who can write to the snapshot store and how. That custody note is part of the
artefact — an expired signature you are choosing to accept is only as good as your
confidence that the bytes have not moved since you checked it.
Then check that seal on rollback day, on the snapshot host, read-only.
Here rather than on the panel: the tree and its seal live on this machine, and the panel
is served a copy of them over HTTPS. Read-only because a verifier must not be able to
rewrite the thing it is verifying — the earlier version of this re-ran the sealing script,
which overwrote .manifest and .manifest.sha256 immediately after
checking them, so the check proved nothing about the tree and would fail outright against
a seal correctly kept immutable:
Only once that has passed does the panel get to fetch from it, and only then does the
exemption in /root/rollback/apt.sh mean anything: it is the hashes and the
custody note doing the work that Valid-Until would otherwise do. gpgv here re-checks the signature, not the freshness — Valid-Until lives inside the signed body and is apt's rule, so an expired
snapshot still verifies at this step and still needs the exemption. What it does catch is
a tree whose bytes have moved, and a signing key whose expiry date has since
passed, because that date is inside the key material the keyring already holds.
It does not catch a revocation on its own. gpgv knows only what is in the keyring it was handed. A key revoked after
you sealed the snapshot is still an unrevoked key as far as this check is concerned,
because the revocation certificate is a thing that has to arrive — and nothing
in this procedure fetches one. Re-running the verifier a year later re-checks the same
signature against the same unchanged keyring and says the same thing it said on the day.
For this step to have an opinion about revocation, the keyring itself has to have been
refreshed from a source you authenticate, on a schedule, as its own piece of maintenance:
the same fetch-and-pin procedure as the bootstrap above, run again. Whether you do that
is a decision to write down next to the custody note; what you cannot do is read a clean gpgv as evidence that the key is still good.
The residual risk is real and worth stating plainly. You are installing packages whose signed metadata says do not trust this after a date that has passed, so you no longer have freshness as a property — if a package in that snapshot has a vulnerability disclosed since, nothing in this path will tell you. That is acceptable for the twenty minutes of a rollback and unacceptable as a standing configuration, which is exactly why the scope matters. Roll forward to a current snapshot as soon as the incident is over.
Then test it in the state it will actually be used in.
Not today's state — the expired one. Take a scratch VM, set its clock past the snapshot's Valid-Until (or keep a snapshot until one of them expires, which happens
within a fortnight for the security suite), and run the whole rollback against it: the
seal check on the snapshot host, then the update, the downgrade and the final
manifest comparison on the panel, all through /root/rollback/apt.sh. A
rollback rehearsed only against fresh metadata is a rollback rehearsed in a state it will
never be in — and rehearsing it through anything other than that one array proves the
rollback works with an apt configuration the rollback does not use. Write the date the
snapshot expires next to the snapshot, so the question is answered before someone asks it
at three in the morning.
Record what you are on — all of it. A panel upgrade moves 3 packages and whatever they depend on, so the manifest is every installed package and its version, not the three:
Then upgrade, naming the packages so an unrelated distribution upgrade does not ride along, and take the same manifest afterwards. Read the release notes on the releases page first, and take a backup:
The diff between the two manifests is the change: which packages moved, and
which are new. That file is what the rollback works from. Re-check the API state afterwards
— see the upgrade note in chapter 06 — and load a
customer site.
The rollback is that recorded diff, resolved against the snapshot: every
package whose version changed goes back to the version in the before-manifest, and every
package the upgrade introduced is removed. Nothing else is touched, and nothing is
installed by wildcard from a directory or from apt's cache — a .deb left over from an unrelated experiment is exactly what a glob picks up.
Check the seal on the snapshot host first, then run this, and note that every apt command
in it is "${apt[@]}" from the one file — the update included, which is the command that fails on an expired snapshot when
the exemption is somewhere else:
The final diff is the check that matters: it compares the whole installed set
against the recorded one and fails if any package came back at the wrong version or was
left behind. Checking only the 3 panel packages would report success
on a machine still carrying the upgraded library that broke it.
If you did not prepare a snapshot, the rollback is the VM
Restore the whole machine from the full snapshot you took before the upgrade. Do not try
to reconstruct the package set from the live archive: the versions you need may no longer
be published, --print-uris run on the panel omits every dependency already
installed there, and the hashes apt prints for a fetch plan are not the ones a sha256sum -c reads. An apt rollback assembled on the night is guesswork.
Downgrading code does not downgrade what the new version wrote. If the upgrade migrated
anything under /usr/local/tulio/data or in the databases, restore that from
the backup you took, and check that the configuration files under /usr/local/tulio/conf still match what the older packages expect. Then run the
site and the panel and confirm both work.
Chapter 14
Recovery: what you restore, and how long it takes
Chapter 13 got copies off the machines. This chapter is about turning those copies back into a working business, which is a different problem and has four separate answers. Work through each one on a scratch machine and time it. The numbers you get are what you are allowed to tell a customer.
Two numbers, measured rather than hoped for
Recovery point is how much work you lose: the gap between the last good copy and the failure. Nightly user archives means up to a day of customer changes. Recovery time is how long the business is down while you put it back. Both are properties of the procedure you actually run, so you get them by running it.
Write both down per system, because they differ. Losing a day of a customer's uploads is survivable. Losing a day of invoices and payments is an accounting problem, so the billing database needs a tighter recovery point than the web accounts do.
Customer accounts
This is the path v-restore-user covers, and it has prerequisites that
decide whether it works at all:
- A compatible panel. A TulioCP installation on the same platform, at a version that reads your archives. Building that scratch install is part of the recovery time, so either keep one warm or time the install too.
- The same services enabled. The restore skips the mail portion of an archive if the target has no mail system configured, and the database portion if it has no database system. It does this quietly. A web-only rebuild of a box that sold mail restores the sites and none of the mailboxes.
- The same templates and packages. If a web, proxy, backend or DNS
template named in the archive is missing on the target, the restore substitutes
defaultrather than failing. The account comes back with a different vhost configuration than it had. Restore your templates and packages before your users. - The archive where the command can find it. It reads from
/backup. If the file is not there it will try to download it from a configured remote destination, which means either you have configured that destination on the scratch host or you copy the file in yourself. - Disk and load headroom. The command refuses to start if the backup filesystem is over its configured usage limit, and waits when the load average is over its threshold. A restore onto a nearly full volume stops before it begins.
Then open the site over HTTP and HTTPS, sign in to a mailbox if you sell mail, and query
the database. The command accepts per-component flags, so a partial restore of one
customer's databases without touching their web files is possible; read v-restore-user's own options line before you need it rather than during.
The panel itself
User archives do not rebuild a panel. If the machine is gone, so are the things that made it your panel, and none of them are in a user backup:
- Configuration.
/usr/local/tulio/conf/and/etc/tuliocp/, which is where the API state, the allow list, the backup destination and the demo-mode flag live. - Access keys and API roles.
/usr/local/tulio/data/access-keys/and/usr/local/tulio/data/api/. Lose the keys and Billing stops provisioning until you issue new ones and update it. - Packages and templates.
/usr/local/tulio/data/packages/and/usr/local/tulio/data/templates/. These have to exist before you restore users, per the point above. - Certificates. Under each user's
ssl/directory in/usr/local/tulio/data/users/. Let's Encrypt will reissue, but not instantly and not without rate limits. - Version manifest. Which versions of
tulio,tulio-nginxandtulio-phpthe box was running, and which PHP versions were installed. Keep the output ofdpkg -l 'tulio*'andls /etc/php/with the backup. - System configuration outside the panel. Your nginx additions, your IPv6 firewall rules, your SSH configuration, your cron entries. The panel did not write these and does not know about them.
Do not copy data/users back wholesale
It is the obvious move and it breaks the restore. v-restore-user decides
whether it has to create the account by looking for /usr/local/tulio/data/users/<USER>. Find that directory and it
concludes the user already exists, skips creating both the panel account and the Linux
account, and then restores files against a UID that is not in /etc/passwd. Nothing warns you; the restore runs.
The archive carries the user's own user.conf, SSL directory and PAM
entries, and v-restore-user is what puts them back. Let it. Keep your copy
of data/users for reference and for forensics — it is how you check
afterwards that the account came back with the limits it had — and restore through the
supported command.
The order below is the procedure, and every step has to happen before the next one. It is worth walking once on a scratch host, because the failures in it are silent:
- A clean, matching base. Debian 13 amd64, nothing else on it, the panel package versions from your recorded manifest installed out of the repository snapshot from chapter 13, the same installer flags, the same PHP version list. Restoring onto a build with fewer services makes the restore skip the parts that have nowhere to go.
- Global panel configuration, selectively.
/usr/local/tulio/conf/and/etc/tuliocp/. Compare rather than overwrite: some of what a fresh install wrote there is about this new machine, notably addresses and hostnames. What you are restoring is your decisions — the API state and allow list, the backup destination, the firewall configuration. - API roles and access keys.
/usr/local/tulio/data/api/and/usr/local/tulio/data/access-keys/. Roles first: a key naming a role the panel cannot find is rejected. If you would rather reissue the keys than restore them, that is defensible — it is one edit in Billing — but decide now, not during. - Packages and templates.
/usr/local/tulio/data/packages/and/usr/local/tulio/data/templates/, plus any web, proxy, backend or DNS template files you added outside that tree. This must precede the users: a template named in an archive but missing on the target is silently replaced withdefault. - Your own system configuration. The nginx additions, the IPv6 ruleset, the SSH configuration, the root crontab. The panel did not write these and will not restore them.
- Users, one at a time, through
v-restore-user. With nodata/usersentry and no Linux account for that name present. The command refuses outright if the Linux user exists, which is the loud half of the failure; the quiet half is the callout above. - Reconcile.
v-list-usersagainst your reference copy, andgetent passwdagainst both. Every panel user should have a Linux account, every restored account should carry the package it had, and nothing should exist that you did not restore. Then load a site over HTTPS and let the certificates reissue.
Where you cannot show from the source that a file is safe to put back by hand, keep it for reference and use the supported command instead. A restore that half-worked reports success, and the discovery comes from a customer weeks later.
The consistent copy from chapter 06 is exactly the input this procedure needs. Take one on a schedule, not only before a risky change.
TulioBilling
The panel's backups do nothing for the billing host, and the billing host is where the invoices, the payment records and the client identities are. Five things to copy, and the database is the one with a correctness requirement:
- The application directory at a known version. Either the deployed
tree or the release tag plus the exact
composer.lockandpackage-lock.jsonyou built from. Record which, alongside the PHP version and the extension list the host was running, because a rebuild that guesses at those is a different application. -
src/config.php. The database credentials andinfo.salt. It holds no gateway or registrar settings — those are in the database, and they are in it as plain JSON, readable beside anyconfig.phpat all. What the salt makes readable is the encrypted part of that database: module configuration inextension_metaand thevarscolumn ofemail_template. Restore the dump beside a different salt and those two go silently blank while everything else looks fine, which is the point below. - A consistent database dump, taken from the same install as that
config.php. Not a copy of the data directory taken while MySQL was running. -
src/data/uploads. Anything clients and staff attached. Not reproducible from the database. - The crontab line that runs
cron.php. One line, and it is the difference between a restored billing application that works and one that looks fine and processes nothing.
The dump and the salt are one artefact
Module configuration — the gateway module's settings, the registrar module's, the mail
module's, every module's — is stored in the extension_meta table as
AES-256-CBC ciphertext. The key is derived from info.salt in config.php with PBKDF2, and that salt is generated per install. Restore
the database next to a config.php carrying a different salt and those rows
do not error: they fail to decrypt, the application reads them as empty configuration,
and you get a billing system that starts, looks normal, and has forgotten how it was
configured. email_template vars are encrypted the same way.
So a restorable backup is a matched pair: a dump and the exact config.php that install was running. Version them together, label them so
a tired person cannot mix generations, and test the pair rather than the dump.
Not everything in the database is encrypted, and the parts that are not are the ones
worth protecting hardest. Payment gateway credentials sit in pay_gateway.config as plain JSON — ServicePayGateway reads
and writes that column with json_decode and json_encode and
no cipher anywhere — registrar credentials sit in tld_registrar.config the
same way, and the panel access key and secret sit in the hosting-server record. So those settings pages render fine beside a config.php carrying the
wrong salt, and opening one proves nothing about the salt at all. A billing dump is a
live-secret artefact: encrypt it at rest, restrict who can read it, and keep it under
the same rules as the panel archives in chapter 13.
Stop the writers first. Not for the dump's sake — --single-transaction already gives a point-in-time view of InnoDB — but for
the set's. The dump, the config.php beside it and the uploads directory have
to come from the same instant, and a module reconfigured between them produces three files
that were never true together:
Then record what this install holds, so the restore below has something exact to be checked against rather than a rule someone guessed at:
These are counts, not thresholds. A fresh install has a handful of clients and may have
no module configuration at all, so a check demanding a non-zero extension_meta would fail on a perfectly good dump of it. If a number in that
file surprises you, look into it now rather than during a restore.
Record the encrypted half separately, because the counts above cannot see
it. A row count says the ciphertext arrived; it says nothing about whether the config.php beside it can still read it, and that is the failure this whole
section is about. Pick a value that is encrypted, that this install definitely has, and
whose decrypted meaning the application will show you:
The mod_client configuration row is the one to use because the installer
writes it on every install, encrypted with that install's salt, and it holds disable_signup — which Box\AppClient reads on every request for /signup. A closed signup answers 404 there and an open one renders the form,
so a single HTTP status is a report on whether the application decrypted its own
configuration. If the salt is wrong, Module::getConfig() cannot decrypt the
row, returns an empty array, and disable_signup is simply absent: signups
open. That is the silent failure, and it is also what makes it testable.
Which is why the script refuses to record anything unless /signup is already
answering 404. The whole test is "the wrong salt gives a different answer", and on an
install whose signups are open the wrong salt gives the same answer — 200 either
way, because the flag it lost was the one keeping them closed. Recorded in that state the
drill runs to completion, reports no failure, and has compared nothing. Close signups,
record, and reopen them afterwards if you need them open.
The email template is pinned by action_code rather than counted for the same
reason. "Six rows held ciphertext before and six hold it now" is equally true of a
restore that can decrypt all six and one that can decrypt none, and it does not even say
they are the same six. One named template, its row id, and a hash of its ciphertext are
three facts a later run can actually assert against — and the decrypted variables get
written down as text, because that is the half a hash cannot reach.
--single-transaction is what makes the dump a point-in-time view on InnoDB
rather than a set of tables read at different moments. Drop --set-gtid-purged=OFF on MariaDB, which does not take it. Save this as a file
and run it with bash rather than pasting it into a login shell, where set -e would end the session on the first failure.
The rest of it is there because the obvious form of this command is fail-open. Written as mysqldump … | gzip > file, the shell reports gzip's exit status
and gzip succeeded: it compressed whatever it was given, including nothing. mysqldump can
die on a lost connection, a permissions change, a full disk or a table it cannot lock, and
what is left behind is a small, valid, checksummable .gz that passes gzip -t and every monitoring check that looks at whether a file exists. So the
pipeline fails closed at every step: set -euo pipefail so mysqldump's status
is not swallowed by gzip's, a temporary file so a half-written dump never takes the name of
a good one, gzip -t for stream integrity, an awk pass over the
decompressed content for the markers mysqldump only writes if it got that far, then sync, the atomic rename, sync -f, and the checksum over the whole
set. Adjust the marker lines to what your server's mysqldump actually writes;
check that once, by eye, against a dump you trust.
Everything the restore needs is in that set, and the checksum file covers all of
it. The generation is <DUMP>.sql.gz , <DUMP>.config.php , <DUMP>.uploads.tar , <DUMP>.expect , <DUMP>.salt-expect , <DUMP>.template-vars.expect , <DUMP>.crontab , and <DUMP>.sha256
— one timestamp, one sha256sum manifest, one thing to move. The version of
this script that shipped before copied the dump, the config.php, the uploads
and billing-expect.txt, and then the procedures below went on to read billing-salt-expect.txt, billing-template-vars.expect and billing-crontab.txt — three files that existed only in /root on
the host the whole drill exists because you may no longer have. An expectation you cannot
reach is not an expectation, and a restore rehearsed without one silently drops the
assertion it fed.
And the set travels the way the panel archives travel. Copy every file of
that generation — the whole <DUMP>.* set, checksum manifest included —
to the off-host destination through the same authenticated pull pipeline chapter 13 describes, with the same requirements: the puller authenticates this
host's identity rather than accepting whatever key it is offered, the billing host holds
no credential that can delete the history, and the destination is encrypted at rest. Then
verify it at the far end rather than at this one: sha256sum -c <DUMP>.sha256 against the copy that landed, not against
the copy you made. A dump verified only where it was written is a dump you have checked
the disk of.
Then start the billing host again and confirm the cron entry came back. Nothing else will tell you it did not:
And gzip integrity is still not a restorable dump. The only thing that proves a dump restores is restoring it, and the only thing that makes the restore mean anything is comparing it against what you recorded:
Every check there fails the script rather than printing a number for someone to read. SELECT COUNT(*) on its own exits 0 when the answer is zero, which is how an
empty table passes a check written to catch empty tables. The comparison is against the
file recorded before the dump, so a table that lost rows and a table that never had any are
different results. The trap drops the scratch database whether the run
succeeds or fails, so a failed verification does not leave a copy of your customers' data
sitting on the host under a name nobody remembers.
Where a scratch database is not available on that host, do it on the recovery host during the rehearsal below, and say in your runbook which of the two you are relying on.
Keep the gateway and registrar credentials in your password manager as well, independent of both the dump and the config file. If you lose the salt, that manager is where the module configuration comes back from — by hand, in the admin area, one module at a time.
Then restore it. A scratch host, the same PHP version and extensions,
the application directory at the recorded version, the matching config.php,
the uploads, the database, the cron entry. Sign in as staff, open a paid invoice, run cron.php by hand and confirm it exits zero. Time the whole thing. Until you
have done that, what you have is a directory of files.
Then prove the salt, on the thing the salt actually protects, on an isolated
clone. Not the payment gateway settings page: that configuration is plain JSON
in pay_gateway.config and renders identically beside any config.php at all, so it is the one page in the admin area that cannot tell
you anything about the salt. Check an encrypted value instead, through the route the
application reads it by, against what you recorded before the dump — and do it on a copy
nobody can reach, because proving the salt means running the install once with the wrong
one:
Read the first four lines before you run it. This script edits config.php, and the thing it edits it into is a configuration under which
signups are open. That is fine on an isolated clone with no public DNS, no traffic and no
real customers behind it; it is a public signup form on anything else. So it refuses to
start unless the marker file is there, and putting that marker on a host that serves
traffic is the one way to make this dangerous. Do the negative on the restore rehearsal's
own scratch host, or on a second copy made for it — never on production, and never on the
restored install you are about to promote.
The trap is installed before the mutation, not after it. Under set -e the script can end at any line: a curl that fails, a host that is not
up yet, someone pressing Ctrl-C. Written with the restoration as the last statement, all
three of those leave the deliberately wrong salt on disk — which is not a tidiness
problem, it is a billing install with signups open and every module reading as
unconfigured, and nothing in the output saying so.
And there is one trap that cleans up, not three. trap restore EXIT INT TERM reads like three covered cases and is not. A
signal trap that does not exit returns to the script, so Ctrl-C ran the
restoration and then carried on to the next line and put the wrong salt back; the
ordinary path ran it twice, once for the signal and once for the EXIT that
followed, restarting PHP-FPM twice and printing two verdicts. So the cleanup hangs off EXIT alone, guarded to run once, and the signal traps do nothing but exit with the status that signal means — 130 for INT, 143 for TERM — which is what runs the EXIT trap, once, on the way out.
A failed restoration fails the run. The version of this that shipped
before printed CONFIG NOT RESTORED to stderr and returned the exit status
of printf, which is zero — so a drill that could not put config.php back reported the failure and exited successfully, leaving a
billing install with signups open behind a green result. Every step of the cleanup now
feeds one flag: the copy, the PHP-FPM restart, and the probe that checks the good
configuration is the one being served again. If any of them failed, the EXIT trap turns a zero status into a failure and leaves a non-zero one
alone, so the reason the script was already failing is the reason you see.
And it restarts PHP-FPM after each change. config.php is a
compiled PHP file, so OPcache may serve the previous one for as long as its validation
interval allows — or indefinitely, where timestamp validation is off, which is a normal
production setting. Without the restart the negative can appear not to fire while the
workers still hold the good salt, and, worse, the restoration can appear to succeed while
they still hold the wrong one. The application's own config writer invalidates the
compiled entry as part of writing; sed does not, so the restart is what
stands in for it.
And the restart has to be able to fail. Written reload() { systemctl restart …; sleep 2; } the function returns sleep's status, which is zero however the restart went — so reload || { cleanup_failed=1; … } could never fire, and set -e does not catch it either, because errexit is suppressed
inside a function on the left of an ||. What is left is the probe, and the
probe answers a different question: it says the signup route looks right, which it can do
on a host whose pool never came back — OPcache may have revalidated on its own timer
during the sleep. A restart that failed is a failed restart whether or not
one route happened to recover, and the operator is the one who needs to know. So it is systemctl restart … && sleep 2: the status the caller reads is the
restart's, and PHP-FPM DID NOT RESTART is reachable.
The assertion is the difference, not the number. The version of this
drill that printed 200 and moved on was not comparing anything: on an
install whose signups were already open it printed 200 with the right salt too, and
reported success. So the script compares the two answers to each other and fails when
they agree. If they do agree, either the precondition slipped or your restore is reading
its configuration from somewhere you have not accounted for — and in both cases the drill
has told you nothing rather than told you good news.
The drill pauses once, with the wrong salt in place. That window is the
only moment in this procedure where a wrong salt legitimately exists, and it is the only
moment you can find out what the application makes of an encrypted value without one. So
open the pinned email template in the clone's admin area while it is paused and write its
variables to /root/billing-template-vars.wrong the same way you wrote the
expectation. Expect it to be empty, and record that: an empty list is a
zero-byte file, : > /root/billing-template-vars.wrong, which is the
answer this drill is predicting. The template check below requires that file to exist and
requires it to differ from the expectation — see the paragraph after it for why a
diff without a negative is not evidence of anything.
Then the other encrypted column, and this one is asserted rather than admired:
Email\Service::getVars() decrypts that column with the same salt and returns
an empty array when it cannot, so a template that lists no example variables on a restore
where it listed five is the same failure in its other place. The hash and the diff are
two separate claims: the hash says the ciphertext survived the dump and the restore
byte-for-byte, which a wrong salt would also pass; the diff says the application still
turns it back into the same variables, which is the one that needs the salt. Nothing here
renders a template's decrypted vars from the command line, so writing the admin area's
list into a file is a manual step — but it is a recorded one, and diff is what turns two recordings into an assertion. "Open a template and
confirm it looks right" is not an assertion; it is a person agreeing with a page.
And a diff of two empty files succeeds. That is the shape this check has
to survive, because it is exactly what the failure produces: a wrong salt makes getVars() return an empty array, so the list you write down on the clone is
empty — and if the list you wrote down before the dump was also empty, whether because
you were rushing or because that template's vars decrypt to {}, diff reports them identical and the drill reports success. The failure has
matched the expectation. So the script requires the expectation and both recorded lists
to exist and be non-empty, requires both recorded lists to contain at least one key=value line, and requires the list captured under the deliberately wrong
salt to differ from the expectation. Those three together are what make the diff
evidence of decryption rather than of two blank files agreeing.
The negative is the one file that is allowed to be empty — and normally is.
An earlier version of this script applied the non-empty rule to all four, which is a
contradiction with the paragraph above it: a wrong salt makes getVars()
return an empty list, and the recording of an empty list is an empty file. The expected
result could not pass its own verifier, and the only way through was to invent a sentinel
this guide never defined. So the recording is defined instead: a wrong-salt list of
nothing is a zero-byte /root/billing-template-vars.wrong, written with : > /root/billing-template-vars.wrong. The script requires that file to
be there — a negative nobody captured is not a negative — and requires it to be
either empty or a key=value list, so a half-transcribed page is not mistaken
for either. Then it compares: empty against a list of five variables is a difference, and
the difference is the evidence.
The plaintext stores are a separate test, and they need a lifecycle rather than a page. Payment gateway credentials, registrar credentials and the panel access key are not protected by the salt and are not proven by it. Each is proven the way chapters 11, 12 and 09 prove it in the first place: put a real payment through the gateway in test mode and watch it settle, run a registrar operation that costs nothing and read the registry's answer, and call one panel command through the access key and check the panel did it. Restoring a database is not evidence that any credential in it still works — the account may have been rotated, suspended or scoped down since the dump was taken.
DNS
DNS is the one where an outage takes every customer at once and the recovery time is bounded below by the TTLs you set weeks earlier. Buying managed DNS moves the operation to a vendor; it does not move the responsibility, and "the provider will fix it" is not a recovery plan for an account lockout, a mistaken bulk delete, or a provider you have decided to leave.
If a vendor runs it, hold all of this outside that vendor:
- Account access you can still use. The credentials, the second factor, and the recovery codes, in your password manager rather than in an authenticator on one phone. The failure mode here is losing the account, not losing the servers.
- Zone exports on a schedule. Every zone, in a format another provider imports, pulled by your own automation through their API.
- A measured recovery point, in zone changes rather than in words. The gap you would lose is not the export interval on its own. It is the export interval, plus however long a change made in the provider's interface takes to appear in what their API hands back, plus your automation's own lag — the time between the export being available and your copy of it landing somewhere you still control. Measure it once end to end: change a TXT record, poll until it appears in a completed export you hold, and write down the elapsed time and the number of record changes a normal day produces. Hourly exports and a ten-minute propagation delay is a recovery point of just over an hour, not an hour, and if you make forty changes a day that is roughly two changes you would have to reconstruct from memory.
- DNSSEC, branched on what the provider's protocol actually supports.
Record which zones are signed, the algorithm, and the DS records published at each
parent. Then find out which of three situations you are in, because they need different
plans:
- The keys are yours and exportable. Export them, hold them with the zone exports under the same protection as any other private key, and test the import at the second provider. A signed zone moves.
- The provider supports a coordinated transfer. Multi-signer, prepublication of the incoming provider's DNSKEY, or a documented DS/DNSKEY rollover run jointly by both providers — none of which needs the old private key to leave the old provider. If yours offers one, that is the migration path: get the procedure in writing and rehearse it on a domain you can afford to move. This is also the only branch where the parent DS is ever replaced in one step: a coordinated rollover works because the incoming provider's DNSKEY is already being served before the new DS appears.
- Neither. Now moving means going insecure in the middle, and the
order is what keeps the zone resolvable. Never swap the parent DS for the incoming
provider's here: the old service does not publish that DNSKEY, so the moment the
new DS is in the parent the currently delegated zone is bogus to every validating
resolver, and the outage starts before anything has moved. Six steps, in this
order:
- Leave the old signed service exactly as it is — still answering and still valid. It carries the zone for the whole of this.
- Remove the old parent DS. Removed, not replaced, and nothing else changes in the same window.
- Wait the measured parent publication and cache interval, then check rather than assume: query several validating resolvers until each answers the zone as insecure rather than secure, and the parent's DS RRset is gone at its own authoritative servers. That wait is the parent's DS TTL plus the negative-cache TTL from its SOA, and it is measured on the day rather than remembered from the last one.
- Only then move the delegation to the incoming provider, unsigned, and verify it resolves from the new nameservers.
- Have the incoming provider sign the zone and publish its DNSKEY. The zone is live and insecure while this happens, which is the state you spent the previous three steps arriving at safely.
- Add the new DS last, after that DNSKEY is authoritative everywhere the new provider answers and you have tested validation against it. A DS published before its key takes the zone down for validating resolvers, which is the outage step 2 exists to avoid.
- A tested import at a second provider, and a tested delegation change. An import you have actually run, with a real zone, checked record by record against the export — and then the other half, which is the one people skip: change the delegation to that second provider on a domain you can afford to move, watch it resolve from the new nameservers, and if the zone is signed, take the DS transition with it. Put it back afterwards. An import that was never delegated to has not been tested.
- Registrar access, and what the delegation change needs. Changing nameservers happens at the registrar, so that account and its second factor are part of this procedure. Check for a transfer lock or a registry change window that would delay it, and know whether any of your nameserver names need glue updated too.
- A measured recovery time. Import plus delegation change plus the parent's TTL plus resolver caches, and plus the DS TTL if the zone is signed. Time the first two from the rehearsal above; the rest you look up. That total is the number, and it is usually hours rather than minutes.
If you run authoritative DNS yourself, the zones are part of user archives and the configuration around them is not: which zones exist, the secondary's configuration, the transfer restrictions, the DNSSEC keys if you sign. A rebuilt primary with no zones is every customer's site offline. Keep a copy of the zone files and the server configuration, keep the signing keys somewhere the primary's loss does not take with it, and rehearse bringing the primary back while the secondary is still serving.
Write it down where you can read it offline
Every procedure above assumes you can reach a document that tells you what to do. Keep the recovery runbook, the credentials and the version manifest somewhere that does not depend on the systems being recovered. Printed, or on a second device, or with whoever else can reach the servers.
Chapter 15
Dry run, launch, and the first thirty days
The dry run
With signup still closed, buy your own product. All the way through, as a customer, on a browser you are not logged into anything with:
- Order the smallest package, paying the way a customer would.
- Read the confirmation email. Check it came from the right address and says the right things.
- Confirm the invoice is marked paid without any action from you.
-
Then confirm the callback did it, rather than the browser coming back. A paid invoice
proves neither — the Stripe adapter processes the payment straight off the redirect.
Open the processor's webhook delivery log, find the event for this payment, and check
it got a 2xx from your host; then correlate it with the local transaction by the
PaymentIntentid and the invoice it paid, not by the event id, which the transaction row does not keep. Then do it once more as a fresh payment from a browser that cannot reach the return URL at all — block that navigation or point the billing hostname at a dead address on the machine you are paying from — and check the billing host's access log to confirm no request for the return URL arrived. Closing the tab is not that test:confirmPayment()can redirect before you get there, and a test you cannot tell the result of is not one. Not a replay of the payment you just made either: the handler deduplicates against the transaction it already has and discards it. See chapter 11. - Confirm the account exists on the panel, with the right package, and the domain resolves.
- Read the activation email. It carries the username, the panel URL and the nameservers, and no password.
- Sign in to the client area as that customer and set a hosting password from the service page.
- Open the panel with that username and the password you just set.
- Publish a page and load it over HTTPS.
- Reset the client-area password from the login page and check the mail arrives.
- Let the account go overdue on purpose and confirm it suspends when your rules say it should. This runs on the cron, so allow more than five minutes.
- Pay it and confirm it unsuspends.
- Cancel it and confirm the panel account is actually gone.
- Refund the payment and reconcile it against the gateway.
Anything that needed you to intervene by hand is a launch blocker rather than a note for later.
Launch
Launch is one switch, opening self-registration, after the readiness checklist below is complete. Open it on a morning you can watch, not on a Friday evening.
The first thirty days
Day two is where hosting companies quietly rot. The routine that prevents it is small:
- Daily. Read the monitoring output, including the quiet runs. Read the support queue. Check that yesterday's backup exists and is not zero bytes.
- Weekly. Apply distribution security updates. Look at disk and load trends rather than instantaneous values. Read the abuse mailbox even when it is empty.
- Monthly. Restore a backup to a scratch machine and time it. Review certificate expiry across every name. Reconcile the gateway's payouts against the invoices in billing.
- As it happens. Every support ticket that took research becomes a documentation page or a template. That is the only thing that stops ticket volume growing linearly with customers.
Keep a written incident log from the first week, even for small things. Six months in it is the only record of why the machine is configured the way it is, and it is worth more than any amount of remembered context.
Before you open signup
Readiness checklist
53 items, in six groups, with a box beside each one to tick. Printing this page prints the whole guide: the navigation, the contents list and the site chrome drop out and everything else goes on paper in reading order, so you can put the chapter and its checklist in front of you together. Anything you cannot tick is either a task or a decision to write down and accept in writing.
Business
- The legal entity exists and the bank account is open in its name.
- Terms of service, acceptable use policy, privacy policy and refund policy are published and dated.
- You know which taxes apply to your sales, and the billing system is configured to collect them.
- The service boundary is written down: what you support, what you do not, and what you will refuse.
Infrastructure
- The panel host is a clean Debian 13 amd64 machine with no other control panel on it.
- The panel has its own hostname and a certificate that matches it.
- Billing runs on a separate host from customer workloads.
- Two authoritative nameservers answer for every zone you serve, in two failure domains. Managed DNS satisfies this if the provider runs them.
- If any of your nameservers is named under a domain it is itself authoritative for, that name has a glue record (a host object) at the registrar. If your nameservers are all out of bailiwick, no glue is needed and you have checked which case you are in.
- The administrator account has a name you chose, and a password no one has seen, entered without appearing in a shell history or a process listing.
- If this panel was ever a demo, every session file was deleted while it was isolated, and a session captured before the conversion has been replayed and refused.
- SSH is key-only for the accounts you use; the firewall is on and its rules are the ones you meant.
- If IPv6 is enabled on the box, you have your own IPv6 filtering, because the panel firewall does not do it.
- You have decided how customers reach the panel on port 8083, and tested that route from outside your network.
- No end-of-life PHP version is installed. The versions that are installed are the ones php.net still supports.
Provisioning
- The panel API is enabled, and its allow list names the billing host and nothing it does not need.
- The billing access key names a restricted API role rather than being an unrestricted admin key.
- TLS verification is on in the server manager configuration.
- A TulioBilling hosting plan exists whose name is character-for-character the panel package name, and its displayed limits match the package's real ones.
- The product has that hosting plan and the intended server attached, and an order placed against it provisions.
- Create, suspend, unsuspend, change password, change package and delete have each been run against a disposable account and confirmed on the panel side.
- TulioBilling's cron runs every five minutes as the web user, and its last execution time in the admin area is current.
Commerce
- A real payment gateway is connected and a real payment has settled through it, in a way your processor's rules permit.
- The gateway's callback reaches the billing host and is authenticated by the mechanism that adapter actually uses, proven from the processor's own delivery log, the local transaction the handler wrote for that payment, and a fresh test payment made from a browser that could not reach the return URL — with the billing host's access log showing no return request arrived — not from a paid invoice, not from closing the tab and hoping the redirect lost the race, and not from replaying a payment that already went through, which the handler deduplicates and discards.
- You have replayed a settled callback from the gateway's dashboard and the invoice stayed paid once rather than twice.
- If your adapter has a signing secret, you know where it is stored and have rotated it once. If it authenticates some other way, you have tested that mechanism instead.
- No placeholder adapter is selected for payments, registrar, or server.
- Currencies, tax rules and invoice numbering are set the way your accountant expects.
- If you resell domains, a real registration, a nameserver change, a renewal and a failed operation have each been run in the registrar's sandbox or against a domain you own.
Communication
- Transactional mail goes through a provider you have an account with, not the panel host.
- SPF, DKIM and DMARC are published for the sending domain and pass on a delivered test message.
- An order confirmation, an invoice, and a password reset have each arrived in an inbox you do not control.
- If you sell customer mailboxes, outbound mail leaves through a relay you control, with per-account rate limits and a monitored queue.
- The support address is monitored and its auto-reply states a response time you can meet.
- Signup abuse controls are configured and tested: rate limits, honeypot, disposable-email rejection, and a CAPTCHA provider with real keys.
Survival
- Panel user backups run on a schedule and produce an archive, and the copy you would recover from is collected by a pipeline the panel does not control.
- That pipeline authenticates the host at both ends — pinned SSH host keys, or TLS validated against the name you meant — and you have confirmed it rather than assumed it from the protocol's name.
- Its configuration keeps credentials out of command lines, logs and support transcripts, and the commands you run to inspect it redact secret values.
- Every destination holding an archive is encrypted at rest, and you can name the component doing it, because the panel writes a plain tar.
- The history that copy builds up is one the production panel holds no credential to delete: object lock, immutability, append-only, or snapshots taken on the backup host's own schedule, under a retention period you chose.
- The keys and credentials needed to read those backups are recoverable without any of the systems being recovered, and you have used the recovery copy at least once.
- Panel configuration, access keys, API roles, certificates, packages and templates are backed up separately from user archives, and that copy meets the same rules as the archives.
- The billing application, its configuration, its uploads and a consistent database dump are backed up, and you have restored them onto a scratch host.
- You have restored a panel user onto a scratch machine and written down how long it took.
- Your stated recovery point and recovery time are numbers you have measured, not numbers you hope for.
- Monitoring alerts you when the panel, the billing app, DNS, the billing cron or a certificate is wrong.
- Every database dump you rely on is taken by a pipeline that fails closed, is validated after it is written, and has been restored somewhere at least once.
- You hold an immutable signed repository snapshot of both the panel repository and the exact Debian suite, updates and security repositories the panel was installed against, an apt configuration that resolves against it and nothing else, and a downgrade you have run from it on a scratch host. If you do not, your rollback is a restore of the whole machine and you have said so in the runbook.
- You record a full package-and-version manifest before and after every upgrade, and the rollback works from the difference between them rather than from a directory of .deb files.
- If a vendor runs your DNS, you hold its credentials, second factor and recovery codes outside it, export every zone on a schedule, and have rehearsed importing them elsewhere and moving the delegation.
- Your DNS recovery point is a measured number: export interval, plus the delay before a change appears in an export, plus your automation's own lag — and you know how many zone changes that is on a normal day.
- If your zones are signed, you know which of the three cases you are in: exportable keys you hold, a coordinated multi-signer or prepublication transfer the provider supports and you have tested, or neither — in which case you hold a plan in this order, and the registrar access to execute it: remove the old parent DS, wait and verify with several validating resolvers that the zone answers insecure and the parent's DS RRset is gone, move the delegation unsigned, have the incoming provider publish and serve its DNSKEY, and add the new DS last. Never replace the parent DS in one step outside a coordinated rollover: the incoming provider's key is not being served yet, so the currently delegated zone goes bogus the moment the new DS lands.
- Someone other than you can reach the servers if you are unreachable for a week.
Red flags
Stop the launch if…
Any one of these on its own is enough to postpone. Each is a way to take money for something you cannot deliver.
- The payment gateway is still the placeholder. The custom gateway prints instructions and waits for an administrator to mark the invoice paid. Orders will appear to work and no money will move.
- The registrar is still the placeholder. TulioBilling's custom registrar adapter reports success for every operation by its own description. You will sell a domain you have not registered.
- TulioBilling's cron is not running. Paid invoices never activate, recurring invoices are never generated, reminders and suspensions never fire, and queued mail never leaves. The application looks fine and does none of its work.
- You have never restored a backup. You do not know your recovery time, and you do not know whether the archive contains what you think. Find out on a scratch machine, not during an outage.
- Your only backup copy can be deleted with credentials that live on the panel. Whoever takes the panel takes the history with it. The copy you would recover from has to be built by something the panel cannot log in to, and held where the panel's credentials do not reach.
- One machine runs the panel, the billing app, and the customer sites. A customer's PHP application and your billing database on one host means one compromise takes the business and the evidence together.
- Only one nameserver answers for your customers' domains. A single authoritative server is a single point of failure for every domain you host, and some registries will not accept the delegation.
- Transactional mail sends from the panel host with no SPF, DKIM or DMARC. Password resets and invoices will land in spam folders, and you will find out from a customer who thinks you never replied.
- The administrator password has been in a terminal, a chat log or a shell history. Rotate it before launch. The installer prints it back to the screen as you type it, so it has been somewhere at least once.
- Signup is open and you have not read your own terms in full. The first dispute is the wrong time to discover what you promised.
Disclaimer
This is not legal, tax, or financial advice
Everything on this page about entities, taxes, contracts, data protection, refunds and liability is a description of the kinds of question a hosting business has to answer. It is not advice, it is not specific to your situation, and it is written by people who build software rather than practise law or accountancy. Requirements differ by country, by state or province, by what you sell and to whom, and they change.
Get a lawyer and an accountant qualified in your own jurisdiction to review your terms, your privacy policy and your tax handling before you take money. The cost of that review is small next to the cost of discovering you needed it.
Nothing here is a projection of what a business built this way will earn, and no page on this site claims one. There are no customer counts, no revenue figures and no testimonials on it, because there is nothing verifiable to put in their place.
Provenance
Source, licence, and lineage
You are building a business on two pieces of software, so it is worth knowing exactly what they are and who wrote them.
- TulioCP
- A GPL-3.0 fork of HestiaCP, itself descended from VestaCP. Most of the code was written by contributors to those
projects and remains under their copyright; TulioCP claims copyright
only over its own modifications. The full breakdown ships on every install at
/usr/share/doc/tulio/copyright. Source · Releases · Security policy - TulioBilling
- An Apache-2.0 fork of FOSSBilling, descended from BoxBilling. Currently at v0.1.0, a public preview flagged as a prerelease. Source · Security policy
Neither project is affiliated with, sponsored by, or endorsed by HestiaCP, VestaCP, FOSSBilling or BoxBilling. Both licences let you run this commercially without paying anyone.
What the licences ask of you
Running either application as a service, however commercially, is use rather than distribution, and neither licence asks anything of you for that. The obligations attach when you convey the software to somebody else.
- TulioCP is GPL-3.0. Conveying a modified panel to anyone means more than "publish your patch". You owe that recipient the complete corresponding source for what you gave them, including the scripts that control building and installing it, offered on the terms the licence sets out. You keep the copyright and licence notices intact, you pass on the same licence and the same rights to whoever receives it, and you do not add restrictions on top. If you ship a modified panel on an appliance you sell, the installation information the licence requires is part of that too.
- TulioBilling is Apache-2.0. Redistributing it,
modified or not, means keeping the licence and the copyright, attribution and patent
notices with it, carrying the
NOTICEfile's contents into what you ship, and marking the files you changed. It does not require you to publish your modifications. - Names and logos are separate. A copyright licence on code is not a trademark licence. "TulioCP", "TulioBilling", the logos and the branding are not granted by either licence, and neither is any right to imply that a product you built is endorsed by or affiliated with this project or with the upstream projects. Name your service after your own brand.
The panel ships a full copyright breakdown at /usr/share/doc/tulio/copyright. Read the licence texts rather than taking
this paragraph's word for it, and see the disclaimer above: this is a description of the shape of the
obligations, not legal advice.
Exactly which source this was read from
"TulioCP 1.10.6" names three different things at once: a GitHub tag, an apt package revision, and a branch that has moved past both. So the commands on this page were read out of specific commits, and those are what a reader should check against:
- TulioCP
-
4f7fa5df16a5851ae00797ed70c11addb1e34f67(v1.10.6-3-g4f7fa5d), which is three commits after the v1.10.6 tag. The apt repository serves1.10.6-2+debian13: the same upstream version with a later packaging revision. The.debattached to the release is the earlier revision and is not re-uploaded. - TulioBilling
-
5bec68a8d3fa263755a0196d9acd760e01a7ae5d(v0.1.0-1-g5bec68a), one commit after the v0.1.0 preview tag. - Verified
- . Command syntax, option handling, demo-mode restrictions, backup backends, the cron schedule and the provisioning chain were all read at those commits on that date.
Nothing re-checks this automatically. Where the software has moved on, this page is wrong until it is corrected, and the issue tracker is the place to say so.