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.

Panel
TulioCP 1.10.6, apt 1.10.6-2+debian13
Billing
TulioBilling 0.1.0 preview
Platform
Debian 13 (trixie) · amd64
Read from
4f7fa5d · 5bec68a
Last checked
Contents: 15 chapters
  1. Before anything else
  2. The architecture
  3. 01Decide what you sell, and what you refuse to sell
  4. 02Name, domains, and where support arrives
  5. 03Servers, addresses, and the DNS plan
  6. 04Install TulioCP
  7. 05Make the panel a production panel
  8. 06Leave demo mode, then open the API to Billing alone
  9. 07Packages, plans, and the limits behind them
  10. 08Stand up TulioBilling on its own host
  11. 09Wire Billing to the panel
  12. 10Transactional email, and customer mail if you sell it
  13. 11Take money
  14. 12Registrar and authoritative DNS
  15. 13Backups, monitoring, and update discipline
  16. 14Recovery: what you restore, and how long it takes
  17. 15Dry run, launch, and the first thirty days
  18. Readiness checklist
  19. Stop the launch if…
  20. Legal note
  21. 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.

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.

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.
Three planes and the trust boundaries between them.

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 ip6tables anywhere in the panel's command set. An IPv6 address on a box whose firewall only filters IPv4 is an unfiltered address. Either bring your own nftables/ip6tables ruleset 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.

root@host · install
curl -fsSL https://raw.githubusercontent.com/marcosfermin/tuliocp/main/install/tulio-install.sh \
-o tulio-install.sh
less tulio-install.sh
bash tulio-install.sh

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:

root@host · a web-only build
bash tulio-install.sh \
--hostname <PANEL_HOSTNAME> --email <ADMIN_EMAIL> \
--username <ADMIN_USER> \
--multiphp 8.3,8.4,8.5 --quota yes \
--exim no --dovecot no --clamav no --spamassassin no \
--vsftpd no --named no

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:

root@panel · what actually got installed
ls -1 /etc/php/
8.3
8.4
8.5

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:

root@panel · the host certificate
v-add-letsencrypt-host

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:

root@panel · rotate the administrator password
v-change-user-password <ADMIN_USER>
Password:

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:

root@panel · from a password manager
pass-cli read tuliocp/admin | v-change-user-password <ADMIN_USER>

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_config once 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-firewall and read the resulting rules with v-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:

root@panel · services
v-list-sys-services
v-stop-service <SERVICE>

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.

you@yours · take a session the demo published
# From your own address, while the panel is still the published demo.
# Sign in with the published demo login in a browser you can throw away,
# then copy the TULIOSID cookie value out of its storage inspector.
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' \
-H 'Cookie: TULIOSID=<DEMO_SESSION_ID>' \
https://<PANEL_HOSTNAME>:8083/list/user/
200

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.

you@elsewhere · confirm the panel is closed
# At the provider firewall or the reverse proxy, not on the box.
# Deny 0.0.0.0/0 and ::/0 to tcp/8083 and tcp/443,
# allow only <OPERATOR_CIDR>.
curl -sS --max-time 10 -o /dev/null -w '%{http_code}\n' \
https://<PANEL_HOSTNAME>:8083/
000

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:

root@panel · the only form of this step
systemctl is-system-running
running
shutdown -h now
# Take the snapshot at the provider or hypervisor, with the machine
# powered off and confirmed off in their console.
# Then power it back on and confirm it came back clean:
systemctl is-system-running
running
systemctl --failed
0 loaded units listed.

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:

root@panel · leave demo mode
v-change-sys-demo-mode no
Demo mode is off. The API was left disabled.
Restore it deliberately, when it is wanted:
v-change-sys-api enable all
v-add-sys-api-ip <address> # the allow list is seeded with 127.0.0.1 only

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:

root@panel · rotate the published login
v-change-user-password <ADMIN_USER>
Password:

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:

root@panel · end every session
systemctl stop tulio
find -O3 /usr/local/tulio/data/sessions/ -ignore_readdir_race \
-depth -mindepth 1 -name 'sess_*' -type f -delete
find /usr/local/tulio/data/sessions/ -name 'sess_*' -type f | wc -l
0
systemctl start tulio

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:

you@yours · the old session is gone
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' \
-H 'Cookie: TULIOSID=<DEMO_SESSION_ID>' \
https://<PANEL_HOSTNAME>:8083/list/user/
302 https://<PANEL_HOSTNAME>:8083/login/

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.

root@panel · enable and allow-list
v-change-sys-api enable all
v-add-sys-api-ip <BILLING_IP>
grep -E '^(API|API_SYSTEM|API_ALLOWED_IP)=' \
/usr/local/tulio/conf/tulio.conf
API='yes'
API_SYSTEM='1'
API_ALLOWED_IP='127.0.0.1,<BILLING_IP>'

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:

you@elsewhere · confirm the endpoint
curl -sS https://<PANEL_HOSTNAME>:8083/api/ | head -c 80

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:

root@panel · read the packages back
v-list-user-packages

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:

root@panel · a full site lifecycle
v-list-users
v-add-domain <TEST_USER> <TEST_DOMAIN>
v-add-letsencrypt-domain <TEST_USER> <TEST_DOMAIN>
v-list-user-packages
v-delete-user <TEST_USER>

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/log and src/data/uploads. config.php holds the database password and the info.salt the 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.php is 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 .htaccess explicitly allows /install/index.php and /install/install.php through, and on nginx that file does nothing at all. The application does try to delete the directory itself on the next request through index.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.php and /install/index.php directly 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, .bak and .old extensions. 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.mode at strict, force_https on, api.CSRFPrevention on, and debug off in both the main and the Twig sections. If the host sits behind a proxy, set trusted_proxies to 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-Options and 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/log fills 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.

root@billing · the schedule the application asks for
crontab -u <WEB_USER> -e
*/5 * * * * /usr/bin/php <BILLING_ROOT>/cron.php >> \
<BILLING_ROOT>/data/log/cron.log 2>&1

<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:

root@billing · prove the batch runs
sudo -u <WEB_USER> /usr/bin/php <BILLING_ROOT>/cron.php
Successfully ran the cron jobs.
echo $?
0

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_ips if 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:

root@panel · what roles exist
v-list-apis

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:

root@panel · a key scoped to provisioning
v-add-access-key <ADMIN_USER> billing \
'TulioBilling production' json

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-user
  • v-delete-user
  • v-suspend-user
  • v-unsuspend-user
  • v-change-user-shell
  • v-list-user
  • v-list-users
  • v-make-tmp-file
  • v-add-domain
  • v-change-user-package
  • v-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_verify option 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:

  1. The product stores a hosting plan id and a server id in its configuration.
  2. When an order is created, that plan id resolves to a hosting plan record, stored on the service alongside the server.
  3. On activation, the hosting plan becomes a server package object whose name is the hosting plan's name.
  4. 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.

  1. Connect Access-key authenticated connection test against the panel API
  2. Create Provisions the system user and primary domain in one pass
  3. Suspend Locks the account without deleting data
  4. Unsuspend Restores access to a suspended account
  5. Change package Reassigns hosting plan limits on the panel side
  6. Change password Rotates panel credentials from the billing order
  7. 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:

  1. They sign in to your client area, which is where their password is one they chose.
  2. 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.
  3. 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=none with a reporting address, read the reports for a couple of weeks, then move to quarantine and on to reject once 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-Signature header, 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-validate and refusing anything that does not come back VERIFIED, 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 PaymentIntent from 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 with Stripe\Webhook::constructEvent() and throws on a bad signature, a missing Stripe-Signature header 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 that PaymentIntent, and the invoice it paid. For the PayPal adapter, which authenticates by postback rather than by signature, it is the _notify-validate round trip coming back VERIFIED.
    • The two joined up. Correlate by the object the payment is, not by the event: take the PaymentIntent id 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-time payment_intent.succeeded, TulioBilling stores the Stripe event id first and then overwrites txn_id with the PaymentIntent id, 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 and confirmPayment() 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.
    Not a replay of a payment that already went through. Setting an invoice back to unpaid and re-sending the settled event from the dashboard looks like the same test and is not one: the webhook handler deduplicates against the transaction it already stored for that PaymentIntent and 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 that PaymentIntent id. 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 requires constructEvent() 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 in processWebhookEvent() after constructEvent() 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 dig against 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.com to ns1.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 for example.com, and without it nothing in the zone resolves.
  • Out of bailiwick, so no glue is needed. A customer's customer.example is delegated to ns1.yourbrand.com. Those names are resolvable independently through yourbrand.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-user writes 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.

root@panel · produce the archive locally
v-backup-user <USER>
v-schedule-user-backup <USER>
v-list-user-backups <USER>

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.

root@snapshots · the trusted keys, first
# On the snapshot host. It is not a panel and has no Tulio keyring.
apt-get install -y --no-install-recommends debmirror gnupg debian-archive-keyring
# /root/snapshot-keyring.sh — run with `bash`.
set -euo pipefail
umask 022
fpr='766950984D6D728AF4EE623451D8B0CD5126BF11'
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM
install -d -m 0755 /etc/snapshot-keyrings
curl -fsSL https://apt.tuliocp.com/pubkey.gpg -o "$tmp/tulio.asc"
# The primary keys in a file, one fingerprint per line. A `fpr` record
# belongs to whichever `pub` or `sub` record came before it, so this
# reports primaries and never a subkey.
primaries() {
gpg --show-keys --with-colons "$1" | awk -F: '
$1 == "pub" { p = 1; next }
$1 == "sub" { p = 0; next }
$1 == "fpr" && p { print $10; p = 0 }'
}
# Exactly this key and nothing beside it, on the file as fetched, before
# anything is converted.
check_exact() {
local got n
got="$(primaries "$1")"
n=$(printf '%s' "$got" | grep -c . || true)
[ "$n" -eq 1 ] || {
printf '%s carries %s primary keys, expected exactly 1\n' "$1" "$n" >&2
return 1
}
[ "$got" = "$fpr" ] || {
printf 'fingerprint mismatch: refusing to install this key\n' >&2
return 1
}
}
check_exact "$tmp/tulio.asc" || exit 1
# Only now, and only from the file that matched.
gpg --dearmor -o "$tmp/tulio.gpg" "$tmp/tulio.asc"
install -m 0644 "$tmp/tulio.gpg" /etc/snapshot-keyrings/tulio.gpg
# And read back what landed, not what we meant to write.
check_exact /etc/snapshot-keyrings/tulio.gpg || {
rm -f /etc/snapshot-keyrings/tulio.gpg
printf 'installed keyring does not carry exactly the pinned key\n' >&2
exit 1
}
echo 'snapshot keyring installed'
bash /root/snapshot-keyring.sh
snapshot keyring installed

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:

root@snapshots · a repository you can go back to
# On the machine that holds your snapshots, not the panel.
stamp=$(date -u +%FT%TZ); root=/srv/snapshots/$stamp
debmirror --method=https --host=apt.tuliocp.com --root=/ \
--dist=trixie --section=main \
--arch=amd64 --keyring=/etc/snapshot-keyrings/tulio.gpg \
"$root/tuliocp"
debmirror --method=https --host=deb.debian.org --root=/debian \
--dist=trixie,trixie-updates \
--section=main --arch=amd64 \
--keyring=/usr/share/keyrings/debian-archive-keyring.gpg \
"$root/debian"
debmirror --method=https --host=security.debian.org --root=/debian-security \
--dist=trixie-security \
--section=main --arch=amd64 \
--keyring=/usr/share/keyrings/debian-archive-keyring.gpg \
"$root/debian-security"
chmod -R a-w "$root"

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:

root@panel · one apt array for the rollback
# /root/rollback/apt.sh — sourced by every snapshot-only apt command.
# Dir::Etc::* : this sources.list and nothing from /etc/apt, so a live
# repository cannot supply a version the snapshot lacks.
# Check-Valid-Until=false : the snapshot's signed metadata expires and
# cannot be reissued. Scoped to THIS array. Never in
# /etc/apt/apt.conf.d, never on an update that can reach the
# network, never as a machine default.
apt=(apt-get \
-o Dir::Etc::sourcelist=/root/rollback/etc/sources.list \
-o Dir::Etc::sourceparts=/root/rollback/etc/preferences.d \
-o Dir::Etc::preferencesparts=/root/rollback/etc/preferences.d \
-o Acquire::Check-Valid-Until=false)

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:

root@panel · an apt that sees only the snapshot
install -d -m 755 /root/rollback/etc/preferences.d
cat > /root/rollback/etc/sources.list <<'EOF'
deb [signed-by=/usr/share/keyrings/tulio-keyring.gpg] https://<SNAPSHOT_HOST>/<STAMP>/tuliocp/ trixie main
deb [signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] https://<SNAPSHOT_HOST>/<STAMP>/debian/ trixie main
deb [signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] https://<SNAPSHOT_HOST>/<STAMP>/debian/ trixie-updates main
deb [signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] https://<SNAPSHOT_HOST>/<STAMP>/debian-security/ trixie-security main
EOF
source /root/rollback/apt.sh
"${apt[@]}" update

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.

root@snapshots · verify the signatures, then seal the tree
# /root/snapshot-seal.sh <ROOT> — on the snapshot host, at creation.
# Writes the seal. Never run on rollback day: see snapshot-verify.sh.
set -euo pipefail
root="$1"
for rel in "$root"/*/dists/*/InRelease; do
case "$rel" in
*/tuliocp/*) keyring=/etc/snapshot-keyrings/tulio.gpg ;;
*) keyring=/usr/share/keyrings/debian-archive-keyring.gpg ;;
esac
gpgv --keyring "$keyring" "$rel" > /dev/null
printf 'verified %s\n' "$rel"
done
find "$root" -type f -print0 | sort -z \
| xargs -0 sha256sum > "$root.manifest"
sha256sum "$root.manifest" > "$root.manifest.sha256"
verified /srv/snapshots/<STAMP>/tuliocp/dists/trixie/InRelease
verified /srv/snapshots/<STAMP>/debian/dists/trixie/InRelease
verified /srv/snapshots/<STAMP>/debian/dists/trixie-updates/InRelease
verified /srv/snapshots/<STAMP>/debian-security/dists/trixie-security/InRelease

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:

root@snapshots · check the seal, write nothing
# /root/snapshot-verify.sh <ROOT> — on the snapshot host, at rollback.
# Read-only. It checks the seal; snapshot-seal.sh is what writes one.
set -euo pipefail
root="$1"
# The manifest itself, then the tree it describes.
sha256sum -c "$root.manifest.sha256"
sha256sum -c --quiet "$root.manifest"
# The original signatures again, against the same pinned keyrings.
for rel in "$root"/*/dists/*/InRelease; do
case "$rel" in
*/tuliocp/*) keyring=/etc/snapshot-keyrings/tulio.gpg ;;
*) keyring=/usr/share/keyrings/debian-archive-keyring.gpg ;;
esac
gpgv --keyring "$keyring" "$rel" > /dev/null
printf 'verified %s\n' "$rel"
done
echo 'seal intact — serve this tree to the panel read-only'
bash /root/snapshot-verify.sh /srv/snapshots/<STAMP>
/srv/snapshots/<STAMP>.manifest: OK
verified /srv/snapshots/<STAMP>/tuliocp/dists/trixie/InRelease
seal intact — serve this tree to the panel read-only

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:

root@panel · before anything moves
dpkg-query -W -f='${Package}=${Version}\n' | sort \
| tee /root/rollback/before.txt | wc -l
grep -E '^(tulio|tulio-nginx|tulio-php)=' /root/rollback/before.txt
tulio=1.10.6-2+debian13
tulio-nginx=1.30.4-2+debian13
tulio-php=8.5.9-2+debian13

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:

root@panel · update the panel
apt-get update
apt-get install --only-upgrade tulio tulio-nginx tulio-php
dpkg-query -W -f='${Package}=${Version}\n' | sort > /root/rollback/after.txt
diff /root/rollback/before.txt /root/rollback/after.txt | tee /root/rollback/changed.txt

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:

root@panel · go back
set -euo pipefail
# The one apt array, with the scoped Check-Valid-Until exemption in it.
source /root/rollback/apt.sh
"${apt[@]}" update
# Every package whose version changed, back at the recorded version.
mapfile -t back < <(comm -23 /root/rollback/before.txt /root/rollback/after.txt)
[ "${#back[@]}" -eq 0 ] || "${apt[@]}" install --allow-downgrades \
--no-install-recommends "${back[@]}"
# Packages the upgrade introduced, and only those, removed.
mapfile -t added < <(comm -13 <(cut -d= -f1 /root/rollback/before.txt) \
<(cut -d= -f1 /root/rollback/after.txt))
[ "${#added[@]}" -eq 0 ] || "${apt[@]}" purge "${added[@]}"
# The whole changed set, not the three panel packages.
dpkg-query -W -f='${Package}=${Version}\n' | sort | diff - /root/rollback/before.txt

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 default rather 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.
root@scratch · restore one account
cp <ARCHIVE>.tar /backup/
v-restore-user <USER> <ARCHIVE>.tar

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-nginx and tulio-php the box was running, and which PHP versions were installed. Keep the output of dpkg -l 'tulio*' and ls /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:

  1. 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.
  2. 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.
  3. 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.
  4. 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 with default.
  5. 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.
  6. Users, one at a time, through v-restore-user. With no data/users entry 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.
  7. Reconcile. v-list-users against your reference copy, and getent passwd against 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.lock and package-lock.json you 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 and info.salt. It holds no gateway or registrar settings — those are in the database, and they are in it as plain JSON, readable beside any config.php at all. What the salt makes readable is the encrypted part of that database: module configuration in extension_meta and the vars column of email_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:

root@billing · stop what writes
crontab -u <WEB_USER> -l > /root/billing-crontab.txt
crontab -u <WEB_USER> -r
systemctl stop nginx 'php*-fpm.service'
systemctl is-active nginx | sort -u
inactive
pgrep -a -f cron.php || echo 'no cron.php is running'
no cron.php is running

Then record what this install holds, so the restore below has something exact to be checked against rather than a rule someone guessed at:

root@billing · what is in there now
# /root/billing-expect.sh — run with `bash`, before the dump.
set -euo pipefail
umask 077
exp=/root/billing-expect.txt
db='<BILLING_DB>'
q() { mysql -N -B -e "$1"; }
printf 'tables %s\n' \
"$(q "SELECT COUNT(*) FROM information_schema.tables \
WHERE table_schema='$db'")" > "$exp"
# The tables whose contents are the business. Adjust to this install.
for t in client invoice transaction extension_meta; do
printf 'rows %s %s\n' "$t" \
"$(q "SELECT COUNT(*) FROM \`$db\`.\`$t\`")" >> "$exp"
done
cat "$exp"
tables 74
rows client 128
rows invoice 940
rows transaction 1204
rows extension_meta 12

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:

root@billing · the encrypted half, before the dump
# /root/billing-salt-expect.sh — run with `bash`, before the dump.
set -euo pipefail
umask 077
db='<BILLING_DB>'; tpl='<TEMPLATE_ACTION_CODE>'
q() { mysql -N -B -e "$1"; }
fail() { printf '%s\n' "$*" >&2; exit 1; }
# Signups must be CLOSED, or the negative later cannot differ from this.
signup=$(curl -sS -o /dev/null -w '%{http_code}' \
https://<BILLING_HOSTNAME>/signup)
[ "$signup" = 404 ] || fail \
"signup answered $signup: close signups first, or a wrong salt looks identical"
cfg=$(q "SELECT COUNT(*) FROM \`$db\`.extension_meta \
WHERE extension='mod_client' AND meta_key='config' \
AND meta_value IS NOT NULL AND meta_value <> ''")
[ "$cfg" -eq 1 ] || fail 'no encrypted mod_client config to test the salt with'
# One named template, not a count of rows — and exactly one row, because
# `id` from a query matching two rows is two ids on one line, and every
# assertion built on it after that compares the wrong thing.
rows=$(q "SELECT COUNT(*) FROM \`$db\`.email_template \
WHERE action_code='$tpl'")
[ "$rows" -eq 1 ] || fail \
"action_code '$tpl' names $rows rows: pick one that identifies a single template"
tid=$(q "SELECT id FROM \`$db\`.email_template WHERE action_code='$tpl' \
AND vars IS NOT NULL AND vars <> ''")
[ -n "$tid" ] || fail "template '$tpl' has no encrypted vars — pick one that has been sent"
th=$(q "SELECT SHA2(vars,256) FROM \`$db\`.email_template WHERE id=$tid")
printf 'signup %s\nmod_client_config %s\ntemplate_id %s\n' \
"$signup" "$cfg" "$tid" | tee /root/billing-salt-expect.txt
printf 'template_action %s\ntemplate_vars_sha256 %s\n' \
"$tpl" "$th" | tee -a /root/billing-salt-expect.txt
signup 404
mod_client_config 1
template_id 12
template_action mod_service_activated
template_vars_sha256 3f2a9c…
# The decrypted side, which only the application can show you. Open that
# template in the admin area and write its variables down verbatim,
# one key=value per line, sorted:
$EDITOR /root/billing-template-vars.expect
# Then check that what you wrote is capable of being evidence. A wrong
# salt decrypts this column to an empty list, so an empty expectation is
# satisfied by the exact failure it exists to catch.
grep -cE '^[^=[:space:]]+=' /root/billing-template-vars.expect
5

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.

root@billing · a consistent dump, failing closed
# /root/billing-dump.sh — run with `bash`, not pasted into a shell.
set -euo pipefail
umask 077
out="/var/backups/billing/<BILLING_DB>-$(date -u +%FT%TZ).sql.gz"
install -d -m 700 "$(dirname "$out")"
tmp="$(mktemp "$out.XXXXXX")"
mysqldump --single-transaction --routines --events \
--set-gtid-purged=OFF <BILLING_DB> | gzip -c > "$tmp"
gzip -t "$tmp"
zcat "$tmp" | awk '
/^-- (MySQL|MariaDB) dump/ { hdr = 1 }
/^CREATE TABLE / { tbl = 1 }
/^-- Dump completed/ { end = 1 }
END { exit (hdr && tbl && end) ? 0 : 1 }'
sync "$tmp"; mv "$tmp" "$out"; sync -f "$out"
# The dump is one file of a set, and the set is taken now, from the same
# install, before anything is started again. Everything the restoration
# procedures below read is in it: a procedure that needs a file nobody
# copied is a procedure that fails on the night it is used.
install -m 600 <BILLING_ROOT>/src/config.php "$out.config.php"
tar -C <BILLING_ROOT>/src/data -cf "$out.uploads.tar" uploads
cp /root/billing-expect.txt "$out.expect"
cp /root/billing-salt-expect.txt "$out.salt-expect"
cp /root/billing-template-vars.expect "$out.template-vars.expect"
cp /root/billing-crontab.txt "$out.crontab"
# None of them may be empty. An expectation file that arrived empty makes
# every comparison it feeds vacuously true.
for f in "$out.expect" "$out.salt-expect" \
"$out.template-vars.expect" "$out.crontab"; do
[ -s "$f" ] || { printf 'missing or empty: %s\n' "$f" >&2; exit 1; }
done
sha256sum "$out" "$out.config.php" "$out.uploads.tar" "$out.expect" \
"$out.salt-expect" "$out.template-vars.expect" "$out.crontab" \
| tee "$out.sha256"

--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:

root@billing · start it again
systemctl start 'php*-fpm.service' nginx
crontab -u <WEB_USER> /root/billing-crontab.txt
crontab -u <WEB_USER> -l | diff - /root/billing-crontab.txt
sudo -u <WEB_USER> /usr/bin/php <BILLING_ROOT>/cron.php
Successfully ran the cron jobs.

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:

root@billing · prove it restores
# /root/billing-verify.sh <DUMP> — run with `bash`.
set -euo pipefail
dump="$1"; scratch='<SCRATCH_DB>'
cleanup() { mysql -e "DROP DATABASE IF EXISTS \`$scratch\`"; }
trap cleanup EXIT
sha256sum -c "$dump.sha256"
mysql -e "CREATE DATABASE \`$scratch\`"
zcat "$dump" | mysql "$scratch"
q() { mysql -N -B -e "$1"; }
fail() { printf '%s\n' "$*" >&2; exit 1; }
want=$(awk '$1=="tables"{print $2}' "$dump.expect")
got=$(q "SELECT COUNT(*) FROM information_schema.tables \
WHERE table_schema='$scratch'")
[ "$got" -eq "$want" ] || fail "tables: restored $got, recorded $want"
while read -r kind table want; do
[ "$kind" = rows ] || continue
got=$(q "SELECT COUNT(*) FROM \`$scratch\`.\`$table\`")
[ "$got" -eq "$want" ] || fail "$table: restored $got, recorded $want"
done < "$dump.expect"
echo 'restore matches the expectation recorded before the dump'
restore matches the expectation recorded before the dump

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:

root@clone · the salt and the dump matched
# /root/billing-salt-verify.sh — on the ISOLATED restore clone only.
set -euo pipefail
fail() { printf '%s\n' "$*" >&2; exit 1; }
[ -f /root/THIS-IS-A-RESTORE-CLONE ] || fail \
'not a restore clone: this edits config.php and opens signup while it runs'
cfg=<BILLING_ROOT>/src/config.php; exp=/root/billing-salt-expect.txt
[ -s "$exp" ] || fail "$exp is missing or empty — restore the dump's set first"
want() { awk -v k="$1" '$1==k{print $2}' "$exp"; }
probe() { curl -sS -o /dev/null -w '%{http_code}' \
https://<CLONE_HOSTNAME>/signup; }
# config.php is compiled: OPcache serves the old one until the pool restarts.
# `&&`, not `;`: with `;` the function returns sleep's status, which is
# always 0, and `reload || ...` can never fire.
reload() { systemctl restart <BILLING_PHP_FPM> && sleep 2; }
# The cleanup goes in BEFORE the mutation. It runs once, it records its own
# failures, and EXIT decides the status from them.
cp -a "$cfg" /root/config.php.good
cleaned=0; cleanup_failed=0
restore() {
[ "$cleaned" -eq 0 ] || return 0
cleaned=1
install -m 640 -o <WEB_USER> -g <WEB_GROUP> /root/config.php.good "$cfg" \
|| { cleanup_failed=1; echo 'RESTORE COPY FAILED' >&2; }
reload || { cleanup_failed=1; echo 'PHP-FPM DID NOT RESTART' >&2; }
back=$(probe) || { cleanup_failed=1; back='(no answer)'; }
if [ "$back" = "$(want signup)" ]; then
echo 'config.php restored and verified'
else
cleanup_failed=1
printf 'CONFIG NOT RESTORED: signup %s, recorded %s\n' \
"$back" "$(want signup)" >&2
fi
}
on_exit() {
rc=$?
restore
[ "$cleanup_failed" -eq 0 ] || { [ "$rc" -ne 0 ] || rc=1; }
exit "$rc"
}
# One EXIT trap does the cleanup. The signal traps only exit, with the
# status that signal means, and EXIT runs from there.
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
right=$(probe)
[ "$right" = "$(want signup)" ] || fail \
"signup: clone $right, recorded $(want signup) — the salt does not match the dump"
sed -i "s/'salt' => '[^']*'/'salt' => 'deliberately-wrong'/" "$cfg"
reload
wrong=$(probe)
# The assertion is the difference, not the value.
[ "$wrong" != "$right" ] || fail \
"signup answered $wrong with the recorded salt and with a wrong one — proves nothing"
# The wrong salt is in place and nowhere else in this drill is. Record what
# the application makes of the pinned template's variables now: that file is
# the negative control the template drill below compares against.
echo 'open the pinned template in this clone and write its variables to'
echo '/root/billing-template-vars.wrong, then press Enter'
read -r _ || true
echo "salt proven: $right with the restored salt, $wrong with a wrong one"
salt proven: 404 with the restored salt, 200 with a wrong one
config.php restored and verified

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:

root@clone · the template survived and still decrypts
# /root/billing-template-vars.sh — on the clone, with `bash`.
set -euo pipefail
fail() { printf '%s\n' "$*" >&2; exit 1; }
exp=/root/billing-salt-expect.txt; db='<CLONE_DB>'
vars=/root/billing-template-vars.expect
got=/root/billing-template-vars.restored
neg=/root/billing-template-vars.wrong
# The three positives, present and non-empty, before anything is compared.
# A diff of two empty files succeeds and says nothing.
for f in "$exp" "$vars" "$got"; do
[ -s "$f" ] || fail "$f is missing or empty — there is nothing here to compare"
done
# The negative is the one file whose expected content is nothing at all: a
# wrong salt makes getVars() return an empty list, and the recording of an
# empty list is a zero-byte file — `: > "$neg"`. So it must EXIST and it may
# be empty. Requiring it to be non-empty asked for a sentinel nobody defined.
[ -f "$neg" ] || fail \
"$neg was never recorded — run the salt drill's paused window and write it," \
"empty if that is what the clone showed"
# Empty, or a key=value list. Anything else is a transcription, not a recording.
if [ -s "$neg" ]; then
grep -qE '^[^=[:space:]]+=' "$neg" || fail \
"$neg is neither empty nor a key=value list — record what getVars() gave"
fi
# And the two positives have to be lists. A wrong salt makes getVars()
# return an empty array, so an expectation with no key=value line in it is
# matched by the failure it exists to catch.
for f in "$vars" "$got"; do
grep -qE '^[^=[:space:]]+=' "$f" || fail \
"$f holds no key=value line: that is what a wrong salt decrypts to"
done
want() { awk -v k="$1" '$1==k{print $2}' "$exp"; }
hash=$(mysql -N -B -e "SELECT SHA2(vars,256) FROM \`$db\`.email_template \
WHERE id=$(want template_id) \
AND action_code='$(want template_action)'")
[ "$hash" = "$(want template_vars_sha256)" ] || fail \
"template $(want template_action): ciphertext changed across the restore"
# Ciphertext identity is not decryption. Open that same template in the
# clone's admin area, write its variables out the same way, and diff.
diff -u "$vars" "$got"
# The negative, recorded while the wrong salt was in place. If the two
# agree, the diff above is not evidence that anything was decrypted.
if diff -q "$vars" "$neg" > /dev/null; then
fail 'the wrong salt produced the same variables — this has compared nothing'
fi
echo 'the same template decrypts to the same variables, and not without the salt'
the same template decrypts to the same variables, and not without the salt

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:
      1. Leave the old signed service exactly as it is — still answering and still valid. It carries the zone for the whole of this.
      2. Remove the old parent DS. Removed, not replaced, and nothing else changes in the same window.
      3. 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.
      4. Only then move the delegation to the incoming provider, unsigned, and verify it resolves from the new nameservers.
      5. 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.
      6. 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.
      Going unsigned while a DS is still cached makes the zone bogus for every validating resolver, which takes every customer's site down for as long as the cached DS lives. The domain is unsigned for the length of the transfer. That is a real cost of this branch and there is no ordering that avoids it without the outgoing provider's cooperation, which is what the branch above it is for.
  • 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:

  1. Order the smallest package, paying the way a customer would.
  2. Read the confirmation email. Check it came from the right address and says the right things.
  3. Confirm the invoice is marked paid without any action from you.
  4. 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 PaymentIntent id 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.
  5. Confirm the account exists on the panel, with the right package, and the domain resolves.
  6. Read the activation email. It carries the username, the panel URL and the nameservers, and no password.
  7. Sign in to the client area as that customer and set a hosting password from the service page.
  8. Open the panel with that username and the password you just set.
  9. Publish a page and load it over HTTPS.
  10. Reset the client-area password from the login page and check the mail arrives.
  11. 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.
  12. Pay it and confirm it unsuspends.
  13. Cancel it and confirm the panel account is actually gone.
  14. 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

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 NOTICE file'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 serves 1.10.6-2+debian13: the same upstream version with a later packaging revision. The .deb attached 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.

↑ Back to the top of this guide