# CorePanel CLI

> Command-line reference for the corepanel CLI — service status, initial setup, admin listing, mail synchronization, and cron jobs.

Source: https://www.corepanel.net/docs/cli/
Last updated: 2026-09-14
Part of the CorePanel documentation — https://www.corepanel.net/docs

---

The `corepanel` command-line interface is the administrative tool for operating a CorePanel
server from the shell. It talks directly to `corepanel-core` over a local Unix socket, so it
is ideal for initial provisioning, automation, and troubleshooting — no browser required.

> **Run as root**
>
> Most CLI commands operate on system-level state and connect to a privileged socket. Run
> `corepanel` as `root` (or via `sudo`) unless a command explicitly states otherwise.
## Overview

```bash
corepanel system init          # provision a fresh server
corepanel status               # are the services up?
corepanel account …            # hosting accounts: create, update, import, back up, restore
corepanel domain / subdomain … # domains, aliases and subdomains
corepanel mail …               # mailboxes, forwarders, antispam
corepanel cron …               # per-account cron jobs
corepanel wp …                 # WordPress installations
corepanel app …                # application runtime
corepanel quota …              # disk quotas
corepanel ssl …                # commercial certificates
corepanel firewall …           # host firewall (firewalld)
corepanel license / edition …  # licensing and the edition in effect
corepanel notifications …      # what the server has been trying to tell you
```

Every group answers `--help`, and so does every command inside it.

## Naming an account

Every command that acts on a hosting account takes it as `<account>`, and accepts any of
three spellings:

| Spelling | Example |
|----------|---------|
| Linux username | `corepanel domain list acmecorp` |
| Any domain the account owns — primary, alias or addon | `corepanel domain list example.com` |
| Any subdomain of one of those | `corepanel domain list shop.example.com` |
| Numeric account id (the legacy `dom-{id}` form works too) | `corepanel domain list 7` |

Reach for the username. It is what appears in your billing system, in `/etc/passwd` and in
a support ticket, so it is the name you already know — whereas the numeric id is an
internal row number you would have to look up first. The id keeps working everywhere it
ever did, so existing scripts need no change.

> **Names are matched exactly**
>
> `acme` does **not** resolve to `acmecorp`. A partial name is an error, never a guess about
> which customer was meant — deleting or suspending the wrong account is not something an
> apology fixes. To search, use
> [`corepanel account list --search`](#corepanel-account-list), which is a substring match
> and a different job.
>
> Case is ignored, so `ACMECORP` and `acmecorp` are the same account.
A reference that names no account fails with a non-zero exit status, and that includes
ids: `corepanel domain list 999` reports that no account matches rather than printing an
empty list, which would read like a real answer for an account that was never there.

There is no ambiguity for CorePanel to resolve: a domain always contains a dot and a Linux
username never can, and CorePanel never creates a username made only of digits — so a bare
number is always an id.

## `corepanel ssl`

Manages certificates bought from a commercial CA. Every site already gets a free
certificate, issued and renewed automatically — this command group is for the case where
the certificate has to come from somewhere else: an OV/EV certificate, a corporate CA, or
an organisation's policy.

```bash
corepanel ssl list [--json]
corepanel ssl show <host> [--json]
corepanel ssl upload <host> --cert FILE --key FILE [--chain FILE]
corepanel ssl delete <host> [--yes]
```

Three rules explain the whole group:

- **An installed certificate is never replaced by a renewal.** It lives in its own store,
  which nothing automatic writes to, and is served ahead of the automatic certificate.
- **Expiry degrades, it does not break.** A bought certificate does not renew itself, so
  the panel warns from 30 days out. If it lapses anyway, the site falls back to its
  automatic certificate and keeps serving valid TLS — under a different issuer, which is
  why the warning matters.
- **Installing requires Pro or Business; listing, inspecting and removing do not.** A
  server whose licence lapsed is never stuck with a certificate it cannot manage.

### `corepanel ssl list`

Lists every commercial certificate on the server with its issuer, expiry date and time
remaining. The **exit status is 1 when at least one certificate is inside its 30-day
warning window or already expired**, so it can be watched from monitoring without parsing
the output. A server with no commercial certificates is not a problem: the list is empty
and the status stays 0.

### `corepanel ssl show`

Shows one host's certificate: subject, issuer, the names it covers, its validity window,
the chain length and the SHA-256 fingerprint. The private key is never shown.

### `corepanel ssl upload`

Installs a certificate. The material is read from **files**, not arguments: a private key
on a command line ends up in the shell history and is visible to every user on the box
through the process list.

`--chain` is optional — some CAs append the intermediate to the certificate itself, others
mail it separately. The upload is refused when:

| Refusal | Why it matters |
|---------|----------------|
| The chain does not reach a trusted CA | The failure that works in the browser you test with (it cached the issuer elsewhere) and breaks on Android and `curl` |
| The key does not match the certificate | Would take TLS down on the next handshake |
| The certificate does not cover the host | Name mismatch in every browser |
| Expired, or not yet valid | Cannot secure anything |
| Self-signed | A trust error for every visitor |

Nothing is written unless every check passes, so a rejected upload leaves the site exactly
as it was. Installing over an existing certificate is how a renewal is applied.

### `corepanel ssl delete`

Removes a host's certificate and its private key, returning the host to the automatic
certificate from the next connection on. This is also the way back if an installed
certificate turns out to be wrong. Because the key is deleted, the command asks for
confirmation; a non-interactive run must pass `--yes`.

```bash
# Monitoring: alert before a certificate runs out
corepanel ssl list >/dev/null || echo "a certificate needs attention"

# Install a certificate whose CA sent the bundle separately
corepanel ssl upload example.com \
    --cert /root/example.com.crt \
    --key /root/example.com.key \
    --chain /root/example.com.ca-bundle

# Apply a renewal: same command, new files
corepanel ssl upload example.com --cert new.crt --key new.key --chain new.ca-bundle

# Scripting: days left on every certificate
corepanel ssl list --json | jq -r '.[] | "\(.host) \(.daysRemaining)"'

# Go back to the automatic certificate
corepanel ssl delete example.com --yes
```
bash
corepanel [command] [subcommand] [flags]
```

| Command | Description |
|---------|-------------|
| `corepanel system init` | Interactive wizard to initialize a fresh server |
| `corepanel status` | Show the status of all CorePanel services |
| `corepanel quota` | Enable disk quotas, inspect state and usage, and set per-account limits |
| `corepanel account list` | List hosting accounts with their ids, usernames and domains |
| `corepanel account create` | Create a new hosting account and its primary domain |
| `corepanel account update` | Change an account's limits, owner details, hosting package or PHP version |
| `corepanel account suspend` / `unsuspend` | Suspend a hosting account across every plane, and lift it again |
| `corepanel account transfer` | Move a hosting account to another reseller |
| `corepanel account panel` | Show or change whether an account may sign in to the client panel |
| `corepanel account import` | Import a cPanel account (or preview with `--dry-run`) |
| `corepanel account backup` | Create a full local backup of a hosting account |
| `corepanel account restore` | Restore a hosting account from a cpbackup archive |
| `corepanel account backup-schedule` | Manage recurring backup schedules and retention |
| `corepanel domain` | List, add and remove an account's secondary (alias/addon) domains |
| `corepanel subdomain` | List, add, tune and remove an account's subdomains |
| `corepanel app` | Run and publish an account's persistent applications |
| `corepanel cron` | Manage an account's cron jobs |
| `corepanel wp` | Install, update, harden and roll back WordPress sites |
| `corepanel seller` | Create, edit, suspend and delete the resellers this server hosts for |
| `corepanel admin` | Create, list and delete the people who sign in to the panel |
| `corepanel mail sync` | Resynchronize the Postfix lookup database |
| `corepanel mail list` | List the mailboxes of a domain |
| `corepanel mail delete` | Delete a mailbox and its Maildir |
| `corepanel mail quota` | Set a mailbox storage quota |
| `corepanel mail suspend` / `activate` | Suspend or reactivate a mailbox |
| `corepanel mail forwarder` | Manage mail forwarders (aliases) |
| `corepanel mail catchall` | Manage a domain catch-all |
| `corepanel mail redirect` | Redirect all mail for a domain to another domain |
| `corepanel mail vacation` | Manage a mailbox vacation autoresponder |
| `corepanel mail filter` | Manage mailbox Sieve filter rules |
| `corepanel mail spam` | Manage spam thresholds and allow/deny lists |
| `corepanel mail deliverability` | Check whether mail leaving this server is accepted |
| `corepanel edition websites` | Show how many websites the edition allows and how many are in use |
| `corepanel license` | Buy, activate, inspect, refresh or release this server’s license |
| `corepanel telemetry` | Turn this server’s daily check-in on or off |
| `corepanel ssl` | Install and manage certificates bought from a commercial CA |
| `corepanel version` | Print the CLI build version |

Run `corepanel [command] --help` at any time to see the available subcommands and flags.

## `corepanel system init`

Interactive wizard that initializes a fresh CorePanel server. It detects the server identity,
sets the panel domain (the FQDN used to access CorePanel), validates DNS, sets the administrator
identity, and generates (or reuses) the administrator password.

```bash
corepanel system init
```

The wizard walks through six steps: detecting the public IP, prompting for the panel domain,
checking DNS resolution against the server IP (advisory, non-blocking), prompting for the admin
email, preparing the admin password, and applying the configuration. The panel domain is a DNS
name pointing at the server — not the machine's OS hostname, which is only offered as the default
when it happens to be a fully qualified domain name.

### The panel domain is optional

You do not need a domain to finish the setup. Leave the prompt blank — or run unattended on a
server whose hostname is not an FQDN, which is what cloud images and provisioning systems give you
(`almalinux-2gb-nyc1-01`) — and CorePanel is served on `https://<server-ip>` with a self-signed
certificate. The panel then asks for the domain on first login, together with the nameservers.

Set it whenever you are ready, from the panel or by re-running this command with `--domain`. Once
the name resolves to this server, a trusted certificate is issued automatically.

A domain passed explicitly with `--domain` is still validated: a value that is not an FQDN is an
error, not a silent fallback to the IP.

Nameservers and company details are **not** asked here. The panel's initial configuration wizard
collects them on first login and keeps opening until they are set — see
[Initial Server Setup](https://www.corepanel.net/docs/initial-setup).

### Flags

| Flag | Description |
|------|-------------|
| `--domain <fqdn>` | Panel domain — the FQDN used to access CorePanel (e.g. `panel.example.com`). Optional; without it the panel is served on the server's IP |
| `--admin-name <name>` | Administrator display name (default `Administrator`) |
| `--admin-email <email>` | Administrator email for notifications and Let's Encrypt |
| `--admin-password <password>` | Administrator password (generated if omitted) |
| `--no-interactive` | Skip all prompts; use flags and defaults for scripted provisioning |

`--hostname <fqdn>` is retained as a **deprecated alias** for `--domain`.

### Non-interactive provisioning

For automated installs, pass the values as flags and disable prompts:

```bash
corepanel system init \
  --domain panel.example.com \
  --admin-email admin@example.com \
  --no-interactive
```

When there is no domain yet — a cloud image, a marketplace image, a CI run — omit `--domain`
entirely and the install still completes:

```bash
corepanel system init \
  --admin-email admin@example.com \
  --no-interactive
```

When `--admin-password` is omitted, a strong 16-character password is generated and stored at
`/root/.corepanel_password` (mode `0600`). If that file already contains a password, it is
reused instead of generating a new one.

> **Full walkthrough**
>
> See the [Initial Server Setup](https://www.corepanel.net/docs/initial-setup) guide for an annotated, step-by-step run of
> the interactive wizard.
## `corepanel status`

Displays the status of all CorePanel services — `corepanel-api`, `corepanel-auth`,
`corepanel-core`, and `corepanel-sys` — in a table. For each service it shows the systemd
active state, the detailed sub-state (running, exited, failed, …), and the availability of its
sockets.

```bash
corepanel status
```

```
+----------------+--------+---------+---------------------------------+
| Service        | Active | Status  | Sockets                         |
+----------------+--------+---------+---------------------------------+
| corepanel-api  | active | running | ● 127.0.0.1:16087               |
| corepanel-auth | active | running | ● corepanel-ftp-auth.sock       |
|                |        |         | ● corepanel-mail-auth.sock      |
|                |        |         | ● ftp-getcert.sock              |
| corepanel-core | active | running | ● corepanel-core.sock           |
| corepanel-sys  | active | running | ● corepanel-sys.sock            |
+----------------+--------+---------+---------------------------------+
```

Socket indicators: `●` means the socket (or TCP port) is available, `○` means it is
unavailable. Use this command as a first check when the panel or a subsystem is not responding.

## `corepanel quota`

Manages disk quotas: the server's quota state, what each account is using, and the
per-account limits.

Usage is measured by the kernel's per-uid quota accounting, so these commands always report
the state of that accounting alongside the figures. With accounting off there is nothing to
measure, and a usage of zero would read as an empty account rather than as "not measured".
Limits are stored either way and take effect when quotas are enabled.

### `corepanel quota enable`

Turns disk quotas on for this server: enables the kernel's per-account accounting **and
enforcement**, applies every account's stored limit, and measures what each one is using.

Until this is run, CorePanel stores the quota an account was sold but nothing measures or
enforces it. Quota accounting is a property of how the filesystem was **mounted**, which is
why enabling it is a deliberate act and never something a package upgrade does on its own.

What the command can finish depends on the filesystem:

| Filesystem | What happens | Reboot |
|---|---|---|
| ext2/3/4 | the option is added to `/etc/fstab` and the filesystem is remounted | no |
| XFS, separate `/home` | unmounted and mounted again, when nothing holds it open | only if busy |
| XFS on `/` | configured on the kernel command line with `grubby` | **yes** |

The output distinguishes *configured* from *active*. On a root XFS — the usual server
layout — nothing is measured or limited until the machine reboots, and the command says so
instead of reporting success.

```bash
corepanel quota enable [--json]
```

```
Disk quotas are CONFIGURED but NOT yet active — this server must be rebooted.
  disk quotas are configured on the kernel command line and start at the next reboot
  Configured in: the kernel command line (grubby)
    grubby --update-kernel=ALL --args=rootflags=uquota

Usage figures stay unavailable and no limit is enforced until this server reboots.
```

> **An account already over its limit is not cut off**
>
> Enabling quotas on a populated server does not break the accounts that turn out to be too
> big. The kernel's hard limit sits above the quota, so such an account goes over its stated
> size — and is warned — before any write is refused. They are listed at the end of the
> command's output, and `corepanel quota list` shows them at any time.
### `corepanel quota status`

Shows the quota state of the filesystem backing the account home directories.

The three layers are reported separately because they fail separately:

- **Accounting** — is the kernel counting bytes per account at all.
- **Enforcement** — is it refusing writes past a limit.
- **Reboot required** — quotas are configured but the filesystem has not been mounted with
  them yet. On a root filesystem this is the normal state between enabling quotas and
  restarting the server.

```bash
corepanel quota status [--json]
```

```
Disk quotas: ACTIVE (accounting + enforcement)
  Filesystem      : /dev/sda4 on / (xfs)
  Separate /home  : no
  Accounting      : on
  Enforcement     : on
  App data counted: yes
```

`App data counted` reports whether `/var/opt/userapps` shares the quota filesystem. When
`/home` is a **separate partition** it does not, and application data is therefore excluded
from every usage figure — the tidier-looking layout is the one that measures less.

### `corepanel quota list`

Lists per-account disk usage, largest first.

Disk and database sizes are shown in separate columns and are deliberately **not** added
together: only the disk figure is accounted by the kernel and enforced against, because the
MySQL data directory belongs to the `mysql` user and no per-uid quota can see it.

```bash
corepanel quota list [--search <domain>] [--json]
```

When quotas are inactive the command prints a warning *before* the table: the figures are
then whatever was last measured, not current usage.

### `corepanel quota inspect`

Shows **where** an account's disk usage is, not just how much it is.

```bash
corepanel quota inspect <account> [--deep] [--json]
```

A disk quota counts every file the account's Linux user owns on the filesystem — not the
contents of its home directory. Usually those are the same thing. When they are not, the
account sees a figure it cannot explain and cannot reduce, because the files are somewhere it
cannot even list.

Some of that is **by design**, and the report says so:

| Location | What is there |
|----------|---------------|
| `/opt/userapps/<user>` | A published application's program and releases. **Root** owns the code — deliberately out of reach of the account's own FTP, so it cannot replace the binary systemd is executing — so this normally contributes nothing to the quota; it is checked because anything account-owned that lands here would |
| `/var/opt/userapps/<user>` | That application's data, which the account owns and survives every deploy |
| `/var/spool/cron/<user>` | The account's crontab |
| `/var/spool/mail/<user>` | The system mail spool |
| `/tmp`, `/var/tmp` | **Not** by design: files left there still count against the quota |

The full picture of what a quota counts is in [Quotas and Usage](https://www.corepanel.net/docs/accounts/quotas).

The rest is the reason this command exists: files that ended up owned by an account by
accident. The account cannot delete them, so a quota applied on top of them puts it
permanently over its limit with nothing it can do.

Anything the scan cannot place is reported as unexplained. `--deep` sweeps the entire
filesystem to locate it and groups the results by directory. That takes minutes on a busy
server, which is why it is not the default.

```
Disk usage of example.com (user example, uid 1007)

  Counted by the kernel : 1.2 GiB
  Inside the home       : 840.0 MiB (/home/example)
  Outside the home      : 384.0 MiB

+----------------------------+-----------+-------+-----------+---------------------------+
| LOCATION                   | SIZE      | ENTRIES | BY DESIGN | WHAT IT IS              |
+----------------------------+-----------+-------+-----------+---------------------------+
| /home/example              | 840.0 MiB |  9214 | yes       | home directory            |
| /opt/userapps/example      | 320.0 MiB |   412 | yes       | published application ... |
| /var/opt/userapps/example  |  64.0 MiB |    88 | yes       | application data ...      |
+----------------------------+-----------+-------+-----------+---------------------------+
```

### `corepanel quota set`

Sets an account's disk quota in MB. A quota of `0` removes the limit.

```bash
corepanel quota set <account> <MB>
```

```bash
# 20 GB for account 12, and no limit at all for dom-7
corepanel quota set 12 20480
corepanel quota set dom-7 0
```

The kernel gets a **hard limit slightly above the quota** — 5 %, at least 50 MB, never more
than double it. That margin is the grace window, and it is not generosity: at exactly 100 %
the failures are indirect and ugly. PHP cannot write its session files, Dovecot cannot
deliver and mail bounces, cron jobs fail half-way. The account goes over its stated quota,
and is warned, before writes are refused.

If the account is already above the new quota, the command says so — its grace period starts
immediately. On a server where quotas are not active the limit is still stored, and applies
as soon as they are enabled.

### `corepanel quota apply`

Pushes every account's stored quota to the kernel and reports which accounts are already over
theirs.

```bash
corepanel quota apply [--dry-run]
```

This is what a server needs when its accounts **predate** disk quotas: the limits are in
CorePanel's database, but the kernel has never been told about them. It is also the repair
path for a limit whose push failed when the account was created.

Run `--dry-run` first. Before enforcement is switched on, the number that matters is how many
accounts are *already* over their limit — those are exactly the ones whose writes will start
failing.

### `corepanel quota refresh`

Forces an immediate refresh of the stored usage instead of waiting for the hourly job.

```bash
corepanel quota refresh
```

When quotas are inactive nothing is measured and the stored figures are left untouched. They
are never zeroed — "cannot measure" must not be recorded as "uses nothing".

## `corepanel account list`

Lists hosting accounts with their **id, username and primary domain**. Any of the three
works as the `<account>` argument of every other command (see
[Naming an account](#naming-an-account)), so this is a way to browse the server rather than
a step you have to run before doing anything else.

```bash
corepanel account list
```

### Flags

| Flag | Description |
|------|-------------|
| `--search <term>` | Filter accounts (case-insensitive substring over the names an account is reachable by: username, primary domain, addon domains and subdomains) |
| `--json` | Output the account list as JSON for scripting |

The default table lists ID, Domain, User, Seller, Email (used/limit), DBs (used/limit),
Quota MB (used/limit), and Package; a limit of `∞` means unlimited. The command pages
through core transparently, so every matching account is returned in one call.

### Examples

```bash
# List every hosting account
corepanel account list

# Find an account when you only remember part of the name
corepanel account list --search example

# Scripting: extract the account id for a domain
corepanel account list --search example.com --json | jq '.[0].accountId'
```

## `corepanel account create`

Creates a new hosting account together with its primary domain. The domain is passed as a
positional argument; core provisions the Linux user, web vhost, DNS, and mail records.

```bash
corepanel account create example.com
```

### Password handling

The account password can be supplied in three ways, listed from most to least secure:

1. **`--password-stdin`** — read the password from standard input. Best for automation, since
   the secret never appears in the shell history or the process list.
2. **Interactive prompt** — when no password flag is given and the session is attached to a
   terminal, the CLI prompts for the password with hidden input.
3. **`--password <value>`** — pass it directly. Convenient but visible in the shell history
   and in `ps`.

The password must meet core's strength requirements: at least 12 characters including
lower-case, upper-case, a digit and a special character.

### Flags

| Flag | Description |
|------|-------------|
| `--password <value>` | Account password (prefer `--password-stdin` or the prompt) |
| `--password-stdin` | Read the account password from standard input |
| `--username <name>` | Linux username for the account. Defaults to a name derived from the domain. Lowercase letters and digits only, not starting with a digit, at most 16 characters; a name already taken by an account, an administrator or a system user is refused |
| `--owner-name <name>` | Owner full name |
| `--owner-email <email>` | Owner email address |
| `--description <text>` | Free-form account description |
| `--quota <mb>` | Disk quota in MB (`0` = unlimited) |
| `--max-emails <n>` | Maximum number of mailboxes (`0` = unlimited) |
| `--max-databases <n>` | Maximum number of MySQL databases (`0` = unlimited) |
| `--max-ftp <n>` | Maximum number of FTP accounts (`0` = unlimited) |
| `--max-apps <n>` | Applications the account may run. **`0` = none**, not unlimited, and there is no unlimited value — an account created without this flag cannot run [applications](https://www.corepanel.net/docs/applications) |
| `--plan <name>` | Hosting plan name to associate with the account |
| `--php <version>` | PHP version to provision (e.g. `8.3`). Defaults to the highest version installed on the server. |

> **Default PHP version**
>
> When `--php` is omitted, core provisions the account with the **highest PHP version installed**
> on the server. Pass `--php <version>` to pin a specific one.
```bash
# Interactive password prompt, default (highest) PHP version
corepanel account create example.com

# Scripted: read the password from stdin, pin PHP 8.3 and a 5 GB quota
printf '%s' "$ACCOUNT_PASSWORD" | \
  corepanel account create example.com --password-stdin --php 8.3 --quota 5120
```

On success the command prints the account ID, Linux username, domain, and home directory.

## `corepanel account update`

Changes an existing account's limits, owner details, hosting package or PHP version
(`core.UpdateAccount`). The account is passed as a positional argument — its username,
any domain it owns, or the numeric id
from `corepanel account list`; the `dom-<id>` form is accepted too.

```bash
corepanel account update <account> [flags]
```

**Only the flags you pass are changed.** Every other limit is left exactly as it is. Since
`0` means unlimited, omitting a flag is *not* the same as passing it with `0`: a limit you
do not mention is untouched, while `--max-emails 0` grants unlimited mailboxes.

### Flags

| Flag | Description |
|------|-------------|
| `--owner-name <name>` | Owner full name |
| `--owner-email <email>` | Owner email address |
| `--description <text>` | Free-form account description |
| `--quota <mb>` | Disk quota in MB (`0` = unlimited) |
| `--max-bandwidth <mb>` | Monthly bandwidth allowance in MB (`0` = unlimited) |
| `--max-emails <n>` | Maximum number of mailboxes (`0` = unlimited) |
| `--max-databases <n>` | Maximum number of MySQL databases (`0` = unlimited) |
| `--max-ftp <n>` | Maximum number of FTP accounts (`0` = unlimited) |
| `--max-aliases <n>` | Maximum number of domain aliases (`0` = unlimited) |
| `--max-subdomains <n>` | Maximum number of subdomains (`0` = unlimited) |
| `--max-addon <n>` | Maximum number of addon domains (`0` = unlimited) |
| `--max-apps <n>` | Applications the account may run. **`0` = none**, not unlimited — the one limit that reads the other way round. Lowering it below the applications already running is refused |
| `--package <id>` | Apply a hosting package; its limits become the account's. Any limit flag given alongside it still wins. `0` detaches the account without changing a limit. |
| `--php <version>` | Move the account to another **installed** PHP version (e.g. `8.3`). Not a limit — see below |
| `--force` | Allow a disk quota below current usage, leaving the account over quota |
| `--keep-mail` | Keep **receiving** mail while the account is suspended — on by default; `--keep-mail=false` bounces instead (sending is cut either way) |

> **A limit below current usage is refused**
>
> The command fails and names every resource in the way
> (`maxEmails: 5 requested, 12 already in use`), and **nothing** is written. CorePanel will
> not delete a customer's mailboxes or databases to make an account fit a smaller number.
>
> The disk quota is the only exception, and only with `--force`: the account is then over
> quota — readable, but writes are refused as soon as it crosses the hard limit (the sold
> quota plus 5 %), which a real downgrade does immediately. Below that, it keeps writing
> until the filesystem's grace period runs out — 7 days by default. See
> [Editing an Account](https://www.corepanel.net/docs/accounts/editing) for what that means in practice.
> **`--php` changes the running server, not a stored number**
>
> It rewrites the account's php-fpm pool on the new version, re-points every one of its
> sites in a single reload, and retires the old pool once nothing uses it — so the sites
> keep serving throughout. It also moves the `php` the account's cron jobs and SSH sessions
> resolve; webmail keeps its own version.
>
> Applied **before** everything else in the request: a version this server does not have
> refuses the whole edit (the error names the versions that are installed) rather than
> leaving the limits stored and the version unchanged. Extensions are per version and do not
> follow — see [Changing the PHP version](https://www.corepanel.net/docs/accounts/editing#changing-the-php-version).
```bash
# Upgrade: more mailboxes and more disk
corepanel account update 7 --max-emails 100 --quota 20480

# Move the account onto a package, then override one of its limits
corepanel account update 7 --package 3 --max-subdomains 50

# Plan downgrade below what the account currently stores
corepanel account update 7 --quota 1024 --force

# Move a customer off an end-of-life PHP release
corepanel account update 7 --php 8.4
```

An account created from a package stays linked to it after a manual edit and is reported as
customized — see [Editing an Account](https://www.corepanel.net/docs/accounts/editing).

## `corepanel account suspend` / `corepanel account unsuspend`

Suspends a hosting account across every plane it touches, and lifts it again
(`core.SuspendAccount` / `core.UnsuspendAccount`).

```
corepanel account suspend <account> [--reason <text>]
corepanel account unsuspend <account>
```

`<account>` is the username, any domain the account owns, or the numeric id. A name that
matches more than one account is refused rather than guessed.

| Plane | While suspended |
|---|---|
| Websites | Every domain of the account serves a **403** suspension page |
| Applications | Stopped; ones already stopped by their owner stay stopped |
| Cron | Jobs stop running, definitions untouched |
| FTP | Login refused |
| Mail — sending | Rejected at `MAIL FROM` with `550` |
| Mail — receiving | Rejected with `550` unless the account keeps its mail |
| Mail — reading | Works normally, webmail included |
| Data | Untouched — nothing is deleted |

Flags:

| Flag | Meaning |
|---|---|
| `--reason <text>` | Recorded for the operator and shown in the panel. Never published: the suspension page says nothing about why |

`suspend` requires **CorePanel Business**. `unsuspend` is **not** gated on any edition, so
a licence that lapses while an account is suspended can never leave a customer's sites
dark with no way back.

```
# Suspend by domain, with a note for your own record
corepanel account suspend example.com --reason "invoice 4471 unpaid"

# Lift it again by account id
corepanel account unsuspend 7
```

`corepanel account list` shows a `Status` column, and `--json` carries `suspended`,
`suspendedAt` and `suspendReason`. See
[Suspending an Account](https://www.corepanel.net/docs/accounts/suspension) for why a suspended customer can still
read their mail.

## `corepanel account transfer`

Moves a hosting account to another reseller (`core.TransferAccount`).

```
corepanel account transfer <account> --to <reseller>
```

`<account>` is the username, any domain the account owns, or the numeric id; `<reseller>`
is a seller id or name. Ownership is the only thing that changes — nothing on the server
knows which reseller owns an account, so no service is restarted and no site goes down.
The account keeps its own limits, its own password and its own suspension state.

| Flag | Meaning |
|---|---|
| `--to <reseller>` | The reseller that will own the account, by id or name (**required**) |

Two things move with it, and the command reports both:

- the account's **hosting package is cleared**, because a package belongs to a reseller and
  the account is leaving that catalog. Limits are untouched — put the account on one of the
  new owner's packages with `corepanel account update <account> --package <id>`;
- past **notifications stay** with the reseller that owned the account when they happened.

Refused when the destination reseller is suspended, and when the account is currently down
*because its own reseller is suspended* — lift that account first with
`corepanel account unsuspend`, which is you deciding it comes back online. An account
suspended in its own right moves normally and stays suspended.

```
# Hand a customer to another reseller
corepanel account transfer alice --to "Acme Hosting"

# Take a reseller's customers over before removing the organisation
corepanel account transfer example.com --to 1
corepanel seller delete "Acme Hosting"
```

See [Editing an Account](https://www.corepanel.net/docs/accounts/editing) for the whole picture.

## `corepanel account panel`

Shows or changes whether a hosting account may sign in to the
[**client panel**](https://www.corepanel.net/docs/client-panel)
(`core.GetAccountPanelAccess` / `core.SetAccountPanelAccess`).

```
corepanel account panel status <account>
corepanel account panel enable <account>
corepanel account panel disable <account>
```

`<account>` is the username, any domain the account owns, or the numeric id.

There is no panel user to create and no panel password to set. The account signs in with
the username and password it already has — the same ones that open FTP and SSH. These
commands only decide whether that credential also opens the panel. To change the password
itself, use the account's password controls; there is one place to do it, not two.

`status` prints four separate things, because "my customer cannot log in" is usually not
the flag:

| Line | What it means |
|---|---|
| `panel access` | This account's flag — what `enable` and `disable` set |
| `licence` | Whether the client panel exists on this server at all (**CorePanel Business**) |
| `suspended` | A suspended account is refused, with its own message |
| `password` | An imported or weak credential must be changed before anything else |
| `can sign in` | The four above, resolved in the order the server enforces them |

```
$ corepanel account panel status example.com
  account:      acmecorp (example.com) (id 7)
  panel access: enabled
  licence:      the client panel is available on this server
  suspended:    no
  password:     must be changed at next sign-in (imported from another panel, never checked against this server's policy)
  last sign-in: never
  can sign in:  yes, but only to change its password
```

`disable` revokes **self-service, not hosting**. Websites, mail, FTP and SSH are untouched,
and the same password keeps working everywhere it worked before. Sessions that are already
open are refused on their next request: the server re-reads this flag on every call rather
than trusting the session token.

Both commands are idempotent, and **neither is gated by the licence**. Revoking access has
to keep working on a server whose licence has lapsed, and gating `enable` would leave you
able to turn access off and unable to turn it back on. The licence is enforced at the login
itself.

## `corepanel account import`

Imports a cPanel account into CorePanel from a `cpmove-<user>.tar.gz` / backup archive —
or from an already-extracted account directory. With `--dry-run` the archive is parsed
server-side and the plan is printed (`core.PreviewImport`, read-only); without it, the
import runs (`core.ImportAccount`).

```bash
# Preview (read-only)
corepanel account import <archive> --dry-run

# Run the import
corepanel account import <archive> [--package <id>] [--preserve-uid]
```

### Flags

| Flag | Description |
|------|-------------|
| `--dry-run` | Parse and validate the archive, print the plan, and touch nothing |
| `--seller <id>` | Target seller id (default: primary seller) |
| `--package <id>` | Target hosting package id; its limits drive the account |
| `--preserve-uid` | Preserve the source UID/GID via `useradd -u/-g` |
| `--on-conflict abort\|skip` | Policy when the linux user or primary domain already exists (default `abort`) |
| `--report <path>` | Write the JSON import report to this path |

The `<archive>` path is resolved on the machine running `corepanel-core` (the archive must
already be staged on the server) and is sent as an absolute path.

**Dry-run** reports:

- the detected source panel and version;
- the account identity (Linux user, UID/GID, plan, contact) — and whether the source has
  it **suspended**, which is marked beside the name because such an account is imported
  suspended;
- resource counts — domains, mailboxes, forwarders, databases, DB users, DNS records, FTP
  accounts, cron jobs, certificates;
- **credential compatibility** — which passwords carry across verbatim versus which need a
  reset (cPanel `$6$`/`$5$`/`$1$`, bcrypt and `$y$` yescrypt hashes are preserved);
- **conflicts** against existing CorePanel accounts and domains;
- every parse **warning** (anything skipped or unsupported — nothing is dropped silently).

A mailbox or FTP login the source had **locked** arrives closed (inactive), keeping the
password stored behind the lock marker. It is not counted among the credentials needing a
reset: there is nothing to reset, and reopening it is a decision rather than a chore.

An account suspended on the source arrives **suspended**, keeping the source's own
suspension date: its sites serve the
[suspension page](https://www.corepanel.net/docs/accounts/suspension) and its logins stay closed until an
administrator lifts it. A site that does not answer after importing one of these is the
correct outcome, not a failed import.

**Real import** creates the system account preserving the source username, UID/GID
(with `--preserve-uid`) and password hash where the scheme is compatible, then provisions
the CorePanel account, the primary domain (vhost + managed DNS zone), the FTP accounts and
the primary domain's mailboxes with their credentials carried over verbatim. A credential
whose scheme cannot be preserved gets a random password and is flagged for a reset (never
surfaced in plaintext). It also restores the account's **heavy data**: the home directory
(site files and Maildir messages), the MySQL databases (schema + data) and their users
(source password hashes preserved), the crontab (imported as panel-managed cron jobs), and
the primary domain's TLS certificate (installed verbatim when not self-signed).
Addon/subdomain provisioning, source DNS-record import and mail forwarders remain later
phases and are listed as `deferred` in the report. The job outcome is persisted and can be
polled via `core.GetImportJob` or listed via `core.ListImportJobs`.

Examples:

```bash
# Preview a staged cpmove archive without importing
corepanel account import /var/lib/corepanel/import/cpmove-example.tar.gz --dry-run

# Preview an already-extracted account directory
corepanel account import /var/lib/corepanel/import/job-42/cpmove-example --dry-run

# Run the import into package 3, preserving the original UID/GID
corepanel account import /var/lib/corepanel/import/cpmove-example.tar.gz \
    --package 3 --preserve-uid --report /root/import-example.json
```

## `corepanel account backup`

Creates a full local backup of a hosting account. Every MySQL database is dumped with
`mysqldump --single-transaction`, each database user's credential is preserved, and the
home directory (files + Maildir) is archived into a self-describing
`cpbackup-<user>-<timestamp>.tar` archive under `/var/lib/corepanel/backups/<user>/`.

Local backups and their schedules are available in every edition; copying an archive to a
remote destination (S3-compatible storage or SFTP) is a Pro/Business feature.

```bash
corepanel account backup <user> [--wait] [--dest <name|id>]
```

The backup runs asynchronously server-side and prints a job id. Pass `--wait` to block
until it finishes and print the resulting archive path and status.

```bash
# Start a backup and return immediately with the job id
corepanel account backup pxdemo

# Start a backup and block until it completes
corepanel account backup pxdemo --wait

# Back up and push the archive off this server
corepanel account backup pxdemo --dest offsite --wait

# Show the status and report of a backup job
corepanel account backup-status 7
```

The local copy is kept even when a destination is configured, so a destination that is
unreachable degrades the job to `partial` with the reason in its report — it never costs
you the backup.

## `corepanel account restore`

Restores a hosting account from a `cpbackup-<user>-<timestamp>.tar` archive produced by
`corepanel account backup`. Recreates the system account (preserving UID/GID and the
password hash where compatible), the CorePanel account row and primary domain, every
secondary domain (aliases and addons) and subdomain — each with its vhost and managed
DNS zone — the home directory (files + Maildir), MySQL databases + users with preserved
credentials, FTP accounts, and every mailbox login (primary and addon domains) with its
password hash carried over verbatim.

```bash
corepanel account restore <archive> [--on-conflict abort|skip] [--dry-run] [--wait]
corepanel account restore --dest <name|id> --remote-key <key> [--on-conflict abort|skip] [--dry-run] [--wait]
```

Use `--dry-run` to preview what would be restored without touching the system, and
`--wait` to block until the restore finishes and print the per-resource report.

With `--dest` and `--remote-key` the archive is pulled from a
[remote destination](#corepanel-backup-destination) instead of read from local disk: it is
downloaded into a private staging directory, restored, and the download removed — the copy
at the destination is left alone. This path is never gated by edition, because pulling a
backup back is recovery.

```bash
# Preview a restore
corepanel account restore /var/lib/corepanel/backups/cph2/cpbackup-cph2-20260721T090349Z.tar --dry-run --wait

# Restore, blocking until done
corepanel account restore /var/lib/corepanel/backups/cph2/cpbackup-cph2-20260721T090349Z.tar --wait
```

## `corepanel account backup-schedule`

Manages recurring backup schedules and their retention. A schedule targets one account
(`--user`) or every account (`--all`), fires on a cron expression or a
`daily`/`weekly`/`monthly` preset, and prunes only its own old archives. CorePanel runs an
in-process scheduler that polls every minute, so no OS crontab entry is created. Retention
never deletes a manual backup and always keeps at least the most recent scheduled archive
per account. `run` starts the run in the background and returns immediately; track progress
with the schedule's last status or `corepanel account backup-status`.

```bash
corepanel account backup-schedule list
corepanel account backup-schedule create (--user <user> | --all) [--cron "m h dom mon dow" | --frequency daily|weekly|monthly] [--keep N] [--days N] [--name <label>] [--disabled]
corepanel account backup-schedule enable <id>
corepanel account backup-schedule disable <id>
corepanel account backup-schedule run <id>
corepanel account backup-schedule delete <id>
```

`create` flags:

- `--user <linux-user>` — Back up a single account (mutually exclusive with `--all`).
- `--all` — Back up every account on the server.
- `--cron "m h dom mon dow"` — Explicit 5-field cron expression (wins over `--frequency`).
- `--frequency <daily|weekly|monthly>` — Preset (daily 02:00, weekly Sun 02:00, monthly 1st 02:00).
- `--keep N` — Keep the N most recent scheduled archives per account (`0` = unlimited).
- `--days N` — Delete scheduled archives older than N days (`0` = disabled).
- `--dest <name|id>` — Push this schedule's archives to a remote destination
  (Pro/Business). Retention then prunes the remote copies as well as the local ones.
- `--name <label>` — Optional human label.
- `--disabled` — Create the schedule disabled. Before 1.6.14 this had no effect and the schedule ran at its first due time.

```bash
# Nightly backup of one account, keep the last 7 archives
corepanel account backup-schedule create --user pxdemo --frequency daily --keep 7 --name "pxdemo nightly"

# Weekly server-wide backup with a custom cron, delete archives older than 30 days
corepanel account backup-schedule create --all --cron "0 3 * * 0" --days 30

# List schedules, run one now, then disable it
corepanel account backup-schedule list
corepanel account backup-schedule run 3
corepanel account backup-schedule disable 3
```

## `corepanel backup-destination`

Manages where finished backup archives are copied. Without a destination every archive
stays on the same disk as the server it protects — the one failure a backup exists to
survive. Two drivers are available: `s3` for any S3-compatible service (AWS, MinIO,
Cloudflare R2, Backblaze B2, Wasabi, and Google Cloud Storage through its S3 endpoint) and
`sftp` for any SSH server.

Configuring and using a destination requires **CorePanel Pro or Business**. Listing,
disabling and deleting one — and restoring an archive back from one — work on every
edition, so a lapsed licence never leaves you with configuration you cannot manage or a
backup you cannot recover.

```bash
corepanel backup-destination list
corepanel backup-destination add <name> --type s3|sftp [driver flags]
corepanel backup-destination test <name|id>
corepanel backup-destination enable <name|id>
corepanel backup-destination disable <name|id>
corepanel backup-destination rm <name|id>
corepanel backup-destination archives <name|id>
```

### S3 flags

| Flag | Description |
|------|-------------|
| `--endpoint <host>` | Service host **without a scheme** (`s3.eu-west-1.amazonaws.com`) |
| `--region <region>` | Region |
| `--bucket <bucket>` | Bucket name |
| `--prefix <folder>` | Folder inside the bucket, so several servers can share it |
| `--path-style` | Address the bucket as `<endpoint>/<bucket>` — MinIO and most self-hosted gateways need this; AWS does not |
| `--insecure` | Plain HTTP; only sensible on a private network |
| `--access-key`, `--secret-key` | Credentials |

### SFTP flags

| Flag | Description |
|------|-------------|
| `--host <host>`, `--port <n>` | Server address (port defaults to 22) |
| `--user <name>` | Username |
| `--path </abs/dir>` | Absolute remote directory archives are written into; it must already exist |
| `--key-file <path>` | Private key, read from disk locally so it never reaches a shell history |
| `--passphrase <text>` | Passphrase for an encrypted private key |
| `--password <password>` | Password authentication |
| `--host-key <line>` | Pin the server key in `authorized_keys` form. Left empty, the key seen on the first successful connection is adopted and every later connection is verified against it |

```bash
# S3-compatible destination, then prove it really accepts uploads
corepanel backup-destination add offsite --type s3 \
  --endpoint s3.eu-west-1.amazonaws.com --region eu-west-1 --bucket cp-backups \
  --access-key AKIA... --secret-key ...
corepanel backup-destination test offsite

# A MinIO on the LAN
corepanel backup-destination add minio --type s3 \
  --endpoint 10.0.0.5:9000 --bucket backups --path-style --insecure \
  --access-key minioadmin --secret-key ...

# An SFTP box, authenticating with a key
corepanel backup-destination add nas --type sftp \
  --host nas.example.net --user backups --path /srv/backups \
  --key-file ~/.ssh/id_ed25519
```

`test` is worth running every time you add or change a destination: it uploads a small
probe object, lists it and removes it again. A plain connection or login check would
report success for a destination that still fails on the first real push.

### Moving an account to another server

A destination is also the shortest path to rebuilding an account somewhere else. On the
new server, add the same destination, find the archive and restore it:

```bash
corepanel backup-destination add offsite --type s3 ...
corepanel backup-destination archives offsite
corepanel account restore --dest offsite --remote-key cpbackup-pxdemo-20260802T031500Z-j42.tar --wait
```

The archive is self-contained, and the DNS zone is regenerated by the new server, so the
records point at it rather than at the old one.

## `corepanel seller`

Manages the resellers this server hosts for. Aliases: `sellers`, `reseller`, `resellers`.

A reseller owns accounts, and which accounts it owns is what its administrators may see and
touch: an administrator whose seller is a reseller reaches that reseller's accounts and
nothing else on the server.

The organisation that owns the server is a seller too, flagged as the **primary vendor** in
the listing. It is not a reseller — its administrators see everything and its limits are
never consulted — so it can be neither edited nor deleted here.

Every subcommand takes a reseller by id **or by name**, matched without regard to case.

```bash
corepanel seller list
corepanel seller show "Acme Hosting"
```

The Accounts and Disk columns read `used / ceiling`, so a glance answers the question you
actually have — whether the next account will go through. A ceiling of `0` prints as
`unlimited`.

For a reseller that came across from a cPanel migration, `corepanel seller show` also prints
the privilege set the **source panel** granted them, in cPanel's own names:

```
Imported from cPanel, where this reseller was granted 7 privileges and no others:
  acct-summary, basic-system-info, basic-whm-functions, cpanel-api, list-pkgs, manage-dns-records, ssl-info
CorePanel does not grant privileges one at a time: this reseller's administrators
can do anything within its own accounts, and nothing outside them.
```

That set is a record of where the reseller came from and is never enforced here — see
[Your resellers come across with their limits](https://www.corepanel.net/docs/cpanel-transform/#your-resellers-come-across-with-their-limits).
It is printed only where the source's grant was a restriction: a reseller cPanel granted
everything had nothing withheld, so there is nothing to say about it.

### `corepanel seller create <name>`

Creates a reseller. The name is what the panel shows and has to be free; it is compared
without regard to case, because that is also how an import resolves a source panel's
reseller to a seller here.

**Requires CorePanel Business.** The entitlement gates *creation* — a new reseller, and
an administrator under one. A reseller that already exists keeps working on any edition:
you can still rename it, change its ceilings, lift a suspension and read everything about
it, because a licence lapsing must never leave you unable to undo something.

| Flag | Description |
|------|-------------|
| `--email <address>` | Address the reseller is notified at (**required**) |
| `--max-accounts <n>` | How many accounts the reseller may create in total (`0` = unlimited) |
| `--max-quota <mb>` | Total disk the reseller may sell, in MB (`0` = unlimited) |
| `--overselling` | Let the reseller exceed its total disk (the account count stays enforced) |

```bash
corepanel seller create "Acme Hosting" --email ops@acme.test \
  --max-accounts 20 --max-quota 102400
```

The two ceilings are totals across every account the reseller owns, and they are enforced —
see [what the ceilings do](#what-the-two-ceilings-do) below.

### `corepanel seller update <seller>`

Changes a reseller's name, contact address or limits. Aliases: `limits`, `set`.

**Only the flags you pass are written**, and everything else is left exactly as it is — so
tightening one ceiling cannot revert a rename that happened in between. Flags: `--name`,
`--email`, `--max-accounts`, `--max-quota`, `--overselling`.

```bash
# Tighten one ceiling and touch nothing else
corepanel seller limits "Acme Hosting" --max-accounts 10
```

Lowering a ceiling below what the reseller already holds is allowed and takes nothing away:
the accounts that exist keep running, and it is the *next* one that is refused.

The primary vendor is refused.

### What the two ceilings do

`--max-accounts` and `--max-quota` are totals across every account the reseller owns, and
`0` means unlimited. They are refusals, not decorations:

- **Creating** an account for the reseller — from the panel, the CLI, WHMCS or an import.
- **Transferring** an account into it, which raises its totals by exactly as much.
- **Raising a customer's disk quota**, or applying a hosting package that raises it.

The refusal names the wall, what it is and what has been used of it, so the reseller knows
whether to delete an account or to call you.

**Disk counts what was assigned, not what is used.** Ten accounts of 10 GB are 100 GB
whether or not anything has been uploaded — that is what selling 100 GB means — and a
server whose filesystem has no active quotas measures nothing at all, which is exactly where
a limit computed from measured bytes would quietly become no limit.

**A reseller with a disk ceiling cannot be given an account with no quota.** An unlimited
account adds nothing to the total, so one of them would exempt the reseller from its own
ceiling for good. Where there is no ceiling, or where the reseller may oversell, unlimited
means what it always meant. Accounts that already have no quota — every reseller arrives
from a cPanel import with some — are counted as accounts and shown separately under the
disk figure, so a total that looks too small explains itself.

**`--overselling` lifts the disk ceiling only.** The account count is not an overselling
switch and stays enforced, which is the same split cPanel makes: there, overselling is a
property of diskspace and bandwidth and never of the account limit.

**A ceiling belongs to the reseller, not to whoever is calling.** Creating an account for a
reseller that is full is refused for you as well — that is what stops a billing system
provisioning past a limit through your own credential. Raise the ceiling first.

### `corepanel seller suspend <seller>`

Stops a reseller trading. Requires CorePanel Business.

Two things happen, and the second is the one to be sure about before you press it:

- **The reseller stops trading.** Its administrators are refused at the login form, and on
  every request of a session that is already open — a session minted an hour ago does not
  outlive the decision by the rest of its life.
- **Every account it owns is suspended.** The same suspension a single account gets: sites
  serve a suspension page, applications stop, cron jobs stop running, FTP is refused and
  inbound mail is rejected. Nothing is deleted.

An account that was **already suspended in its own right** — one of the reseller's customers
who stopped paying weeks ago — is left exactly as it is, keeps its own reason, and is *not*
put back online when you lift the reseller's suspension.

| Flag | Description |
|------|-------------|
| `--reason <text>` | Why, in your words. Recorded on the reseller |
| `--yes` | Do not ask for confirmation |

`--reason` is for you. It is shown on `corepanel seller show` and never reaches the
reseller's customers: their accounts carry a fixed sentence saying only that the reseller
who owns them is suspended. A note about somebody's unpaid invoice is not something to mail
to forty unrelated people.

While it is suspended the reseller is also given **no new accounts** — not from the CLI, not
from an import, and not from WHMCS, none of which go through one of its sessions.

The command asks for confirmation, and refuses to run at all without a terminal unless you
pass `--yes`. The organisation that owns this server cannot be suspended.

```bash
corepanel seller suspend "Acme Hosting" --reason "unpaid January invoice"
```

The output names every account, one group at a time — suspended, already down, and any that
could not be taken down. Partial success is a real outcome: the reseller stops trading even
when one of its accounts would not go offline, and you have to be told which one.

### `corepanel seller unsuspend <seller>`

Lets a reseller trade again. Alias: `resume`.

Its administrators can sign in, and the accounts **this** suspension took down are restored.
Only those.

No confirmation, and no edition gate — a licence that lapsed while a reseller was suspended
must never be the reason its customers cannot come back.

```bash
corepanel seller unsuspend "Acme Hosting"
```

### `corepanel seller delete <seller>`

Deletes a reseller. Alias: `rm`.

Refused while it still owns accounts, administrators, packages or live API tokens, and the
refusal names what is in the way. There is no cascade: cancelling a reseller contract is not
a reason to delete customer hosting accounts — move them first with
[`corepanel account transfer`](#corepanel-account-transfer), one at a time, and then delete
the empty organisation.

## `corepanel admin`

Manages the people who can sign in to the panel. Aliases: `admins`, `administrator`,
`administrators`.

**An administrator is not a reseller.** An administrator is a *person* with a login; a
reseller is an *organisation* that owns hosting accounts, and it is managed with
[`corepanel seller`](#corepanel-seller) above. Every administrator belongs to exactly one
seller, and that is what decides what they see:

- an administrator of the organisation that owns the server sees everything;
- an administrator of a reseller sees only that reseller's accounts.

A **super administrator** additionally may do the server-wide things — licensing, the
firewall, PHP versions, the logs. Only a super administrator may create another
administrator, and a super administrator may only ever belong to the organisation that owns
the server.

### `corepanel admin list`

Lists administrator accounts and prints their key fields — ID, username, email, seller, and
super-admin flag — in a table. Alias: `ls`.

| Flag | Description |
|------|-------------|
| `--seller <id>` | Filter by a specific seller (default: `1`) |
| `--all` | Include administrators from every seller (overrides `--seller`) |

```bash
corepanel admin list
corepanel admin list --all
corepanel admin list --seller 42
```

`corepanel list admins` is the older spelling of this command and keeps working.

### `corepanel admin create <username>`

Creates an administrator.

The username is the login. It must be free among administrators **and** among hosting
accounts: one login form answers both, so a name they share would be two different people
told apart only by their passwords happening to differ.

| Flag | Description |
|------|-------------|
| `--email <address>` | Contact address, unique across administrators (**required**) |
| `--name <name>` | Display name shown in the panel (defaults to the username) |
| `--password <password>` | Initial password (generated and printed when omitted) |
| `--seller <id\|name>` | Reseller this administrator belongs to |
| `--super` | Grant server-wide administration |

```bash
# A second operator for the server's owner
corepanel admin create jane --email jane@example.com --name "Jane Roe"

# An administrator for a reseller, with a password you chose
corepanel admin create acmeops --email ops@acme.test \
  --seller "Acme Hosting" --password 'S0me-Strong!Pass'
```

Without `--password` a strong one is generated and **printed once**. Either way the
administrator is asked to replace it the first time they sign in: whoever created the
account knows the password, and a credential its holder has not chosen is still the
issuer's. This password is never written to `/root/.corepanel_password` — that file has
exactly one subject, the server owner's own administrator.

Creating an administrator under a reseller requires CorePanel Business. A second
administrator for the server's own organisation is not a reseller feature and works on
every edition.

#### Giving an imported reseller its first login prints a note

It is the one thing a cPanel migration makes **wider**. On cPanel a reseller holds a named
set of privileges — they may create accounts but not terminate them, or touch DNS but not
packages. CorePanel has no such setting: a reseller is all-or-nothing within their own
accounts. So a reseller who could not terminate an account on the source can terminate one
here.

Nothing happens at the migration itself, because the reseller that arrives has no login and
can do nothing. It happens the day you run this command:

```
Note: acmehost was imported from cPanel, where it was granted 7 privileges and no others:
acct-summary, basic-system-info, basic-whm-functions, cpanel-api, list-pkgs,
manage-dns-records, ssl-info. CorePanel does not grant privileges one at a time — this
administrator can do anything within acmehost's own accounts, including whatever the source
withheld, and nothing outside them. Run `corepanel seller show acmehost` to see the full set
again.
```

The administrator **was created**: this is a note, not a failure, because widening those
privileges may well be what you intend. If it is not, delete the administrator — the
reseller goes back to having no login, and its accounts keep working the whole time. The
note appears only for a reseller imported from a cPanel that restricted them.

### `corepanel admin delete <admin>`

Deletes an administrator, by id or by username. Alias: `rm`.

It removes the login and nothing else: hosting accounts belong to the seller, not to the
person who created them, so nothing a deleted administrator touched is affected. A live
session of theirs stops working on its next request.

Refused for the last super administrator — a server nobody can administer is not a state to
leave a machine in — and for the administrator behind your own session.

```bash
corepanel admin delete acmeops
```

## `corepanel mail sync`

Triggers a complete resynchronization of all mail domains, mailboxes, and aliases from
`corepanel-core` into `corepanel-auth`'s Postfix lookup database. Use it to recover from data
inconsistencies, initialize the Postfix database after installation, or force a refresh after
bulk imports or manual database changes.

```bash
corepanel mail sync
```

Add `-v` / `--verbose` to see a breakdown of how many domains, mailboxes, and aliases were
synchronized:

```bash
corepanel mail sync --verbose
```

```
Synchronizing Postfix lookup database...
Synced:
  - Domains:   12
  - Mailboxes: 148
  - Aliases:   37
```

The rewrite is a single transaction, and it is all-or-nothing on both sides: if any
part of the server's mail data cannot be read, nothing is sent and the lookup database
keeps the contents it already had. Postfix reads that database live, so a partial
rewrite would mean refusing mail for whatever was left out — worse than the
inconsistency the command was run to fix. A failure prints the reason and changes
nothing.

> **Run it once after upgrading to 1.6.14**
>
> Until 1.6.14 the lookup database could not store a mailbox or forwarder as disabled:
> the value was dropped on the way in and the row was written enabled. Suspending a
> mailbox or switching off a forwarder therefore changed the panel but not Postfix, and
> upgrading does not go back and repair the rows that were already written. One
> `corepanel mail sync` rewrites them from the panel's own data, which is the correct
> version. A server that has never disabled a mailbox or a forwarder has nothing to
> repair.
## `corepanel mail` (mailbox management)

Manage mailboxes for a domain. Each command persists the change in `corepanel-core` and
synchronizes it to the Postfix/Dovecot lookup database.

```bash
# List the mailboxes of a domain (address, status, quota)
corepanel mail list example.com

# Delete a mailbox: removes the Maildir and revokes the credentials
corepanel mail delete intern@example.com

# Set a mailbox storage quota in MB (0 = unlimited)
corepanel mail quota sales@example.com 1024

# Suspend a mailbox (blocks authentication and delivery), then reactivate it
corepanel mail suspend sales@example.com
corepanel mail activate sales@example.com
```

`suspend` closes both halves of a mailbox — delivery and sign-in — and `activate` opens
both. A mailbox listed as **sign-in blocked** is the third state: it still receives mail
and only its login is shut, which is how an imported mailbox arrives when the source panel
had locked its password. `activate` is what reopens it.

A quota is applied by Dovecot at delivery: a message for a full mailbox is refused with
`552 5.2.2 Quota exceeded` and Postfix bounces it back to the sender. The new limit reaches
Dovecot at the mailbox's next authentication, so nothing needs restarting. Remember that the
same messages count against the owning account's disk quota, which stops delivery for every
mailbox at once — see [Quotas and Usage](https://www.corepanel.net/docs/accounts/quotas#mailbox-quotas).

## `corepanel mail forwarder`

Manage mail forwarders (aliases). A forwarder maps a source address to one or more
destinations; a forwarder that shadows an existing mailbox or forms a mail loop with another
alias is rejected, and so is one pointing at an address on a domain this server hosts that
cannot receive — no mailbox, no other forwarder, no catch-all, or a suspended mailbox.
Mail through such a forwarder would be bounced straight back at its sender.

That check guards new saves; a destination deleted or suspended afterwards still leaves a
forwarder pointing at nothing, so `list` flags those where you will see them:

```
support@example.com              -> old-staff@example.com
                                    !! cannot receive: old-staff@example.com
```

```bash
# List forwarders for a domain
corepanel mail forwarder list example.com

# Create or replace a forwarder to one or more destinations
corepanel mail forwarder set sales@example.com a@example.com b@partner.tld

# Keep a copy in the mailbox and send one on
corepanel mail forwarder set sales@example.com sales@example.com boss@partner.tld

# Delete a forwarder
corepanel mail forwarder delete sales@example.com
```

Naming the source address among its own destinations is what keeps the mail in its
mailbox while forwarding a copy elsewhere. It requires a mailbox at that address whose
delivery is open; without one the address resolves to nothing and the forwarder is
refused. Deleting the mailbox drops the local copy, and removes the forwarder if that
was its only destination.

## `corepanel mail catchall`

Manage the domain catch-all: any address that does not match a mailbox or forwarder is routed
to the catch-all destinations.

```bash
# Show the current catch-all
corepanel mail catchall get example.com

# Route all otherwise-unmatched mail to a destination
corepanel mail catchall set example.com bucket@example.com

# Remove the catch-all
corepanel mail catchall delete example.com
```

## `corepanel mail redirect`

Redirect **all** mail for a domain to the same local part at another domain (Postfix
`@source → @target`). Useful when a domain is renamed or consolidated: `sales@old.com`
becomes `sales@new.com`. A domain has a single `@domain` route, so setting a redirect
replaces any catch-all configured on the domain.

```bash
# Show the current whole-domain redirect
corepanel mail redirect get old-example.com

# Redirect every address to the same local part at another domain
corepanel mail redirect set old-example.com example.com

# Remove the redirect
corepanel mail redirect delete old-example.com
```

## `corepanel mail vacation`

Manage a mailbox **vacation autoresponder** (an out-of-office reply). CorePanel renders the
reply as a Sieve script that Dovecot runs at delivery, so replies are sent server-side even
when no mail client is connected. Replies are rate-limited per sender by `--days`.

```bash
# Show the current autoresponder
corepanel mail vacation get sales@example.com

# Enable an autoresponder (optionally bounded to a date window)
corepanel mail vacation set sales@example.com \
  --subject "Out of office" --message "Back on Monday." --days 2 \
  --start 2026-07-20 --end 2026-07-27

# Save it without turning it on yet
corepanel mail vacation set sales@example.com --subject "OOO" --message "…" --inactive

# Remove the autoresponder
corepanel mail vacation clear sales@example.com
```

## `corepanel mail filter`

Manage mailbox **Sieve filter rules**. Each rule matches on message headers and applies
actions; CorePanel compiles the rules to a Sieve script executed at delivery.

- **fields**: `from`, `to`, `cc`, `subject`
- **operators**: `contains`, `is`, `matches` (the last supports `*`/`?` wildcards)
- **actions**: `fileinto` (folder), `redirect` (address), `discard`, `keep`, `stop`

```bash
# Print the current rules as JSON
corepanel mail filter get sales@example.com

# Replace the rules from a JSON array
corepanel mail filter set sales@example.com --file rules.json

# Clear all rules
corepanel mail filter clear sales@example.com
```

Example `rules.json`:

```json
[
  {
    "name": "Newsletters to a folder",
    "matchAll": true,
    "conditions": [{ "field": "subject", "op": "contains", "value": "newsletter" }],
    "actions": [{ "type": "fileinto", "arg": "Lists" }, { "type": "stop" }]
  }
]
```

## `corepanel mail spam`

Manage **spam thresholds and allow/deny sender lists** at a scope, enforced by Rspamd.

- **scope**: `global` (no target), `domain <domain>`, or `mailbox <address>`
- **thresholds**: `reject`, `add_header`, `greylist` (Rspamd action scores); `0` inherits
  the Rspamd default, and the effective values must satisfy `greylist ≤ add_header ≤ reject`
- **lists**: `allow` (never mark matching mail as spam) and `deny` (always reject it); a
  pattern is a sender email address or a bare domain

More specific scopes and explicit allow/deny lists take precedence over broader ones.

```bash
# Show a mailbox's thresholds and lists
corepanel mail spam show mailbox sales@example.com

# Set per-domain thresholds (0 = inherit default; all-zero clears the policy)
corepanel mail spam threshold domain example.com --reject 12 --add-header 5

# Allow / deny senders
corepanel mail spam allow mailbox sales@example.com friend@partner.tld
corepanel mail spam deny global spammer.example

# Remove a list entry by its id (from `show`)
corepanel mail spam remove domain example.com 7
```

A message that reaches the add-header threshold is filed in the recipient's **Junk**
folder; one above the reject threshold never reaches a mailbox. Spam training is
automatic from there: moving a message to **Junk** teaches the filter it is spam, and
moving it back out teaches it as legitimate.

## `corepanel mail deliverability`

Reports whether mail **leaving** this server is accepted — the server-wide counterpart to
the per-domain SPF/DKIM/DMARC records. It checks the name the server announces (HELO)
against the panel domain, the reverse DNS of every address it sends from, their blocklist
reputation, which address family Postfix prefers, and record coverage across the server's
domains. See [Email Deliverability](https://www.corepanel.net/docs/email/deliverability) for what each check means.

```bash
# Run the checks (results are cached for an hour)
corepanel mail deliverability

# Re-run everything, blocklist queries included
corepanel mail deliverability --refresh

# Set the HELO name to this server's panel domain
corepanel mail deliverability repair-helo

# Choose which address family outbound mail tries first
corepanel mail deliverability prefer ipv4
corepanel mail deliverability prefer any
```

```
Email deliverability — checked 2026-08-10T14:38:00Z
HELO: vps-1a0b77bc.vps.ovh.net  (panel domain: server.example.com)

[FAIL] helo                 the HELO name is "vps-1a0b77bc.vps.ovh.net" but this server's domain is "server.example.com"
[FAIL] reputation           2001:db8:305:2100::1:3d3a on Spamhaus Zen: CSS — snowshoe/low-reputation sending detected
                            https://check.spamhaus.org/query/ip/2001:db8:305:2100::1:3d3a
[warn] ptr                  the reverse DNS of 203.0.113.25 does not match the HELO name
[ ok ] outbound_preference  outbound mail prefers IPv4
[ ok ] dkim                 the signer is running and all 4 domains publish a DKIM key
```

Each check prints one of `[ ok ]`, `[warn]`, `[FAIL]` or `[ ?  ]`. **`[ ?  ]` is not a
pass**: it means the check could not be made at all — most often a blocklist refusing
queries that arrive through a public resolver such as `8.8.8.8`, or one that is over its
daily budget. Point the server at its own resolver and run it again with `--refresh`.

`prefer ipv4` is what CorePanel installs with. Postfix's own default, `any`, chooses
between IPv4 and IPv6 **at random** for every destination that publishes both — so on a
server whose IPv6 address has no reputation, roughly half of all mail is a coin flip.

## `corepanel edition websites`

Reports how many websites this server may host and how many are already in use.

```bash
corepanel edition websites
corepanel edition websites --json
```

```
Edition:   personal
Websites:  12 / 20
Available: 8
```

A **website** is a primary or an addon domain — the two kinds that own a document root
and serve a site of their own. **Domain aliases (parked domains) and subdomains are not
counted**: they republish a document root that is already counted, and stay unlimited in
every edition. The figure is server-wide, across every account.

The allowance is enforced by the backend on every creation path — `account create`,
`domain add --type addon`, a [cPanel import](https://www.corepanel.net/docs/cpanel-import) and a
[backup restore](https://www.corepanel.net/docs/backups) — so hitting the limit refuses the operation before
anything is provisioned. The free **Personal** edition hosts up to 20 websites; Pro and
Business are unlimited (reported as `"limit": 0, "unlimited": true`).

The **exit status is 1 once the allowance is exhausted**, so a provisioning script can
check for room before it starts:

```bash
# Bail out early when the server is full
corepanel edition websites >/dev/null || { echo "no room left"; exit 1; }

# How many are left?
corepanel edition websites --json | jq '.remaining'
```

See [the server-wide website limit](https://www.corepanel.net/docs/accounts/#the-server-wide-website-limit) for
the full picture.

## `corepanel license`

Manages this server's license — the credential that unlocks the **Pro** and **Business**
features. `edition` reports what the server is allowed to do; `license` is where that
allowance comes from.

```bash
corepanel license status [--json]
corepanel license activate [key] [--key-stdin] [--json]
corepanel license trial --edition pro|business --email ADDRESS [--consent] [--json]
corepanel license refresh [--json]
corepanel license deactivate [--yes]
corepanel license upgrade [--edition pro|business] [--cycle monthly|yearly] [--wait] [--json]
corepanel license claim [--json]
corepanel license cancel-purchase
```

Two rules explain the whole command group:

- **The license binds to the public IPv4 the license server observes on the connection**,
  never to one this machine reports. There is nothing to configure and nothing to spoof;
  a change of address is handled by `refresh`, which rebinds and retries by itself.
- **Licensing never stops sites from being served.** If the license server cannot be
  reached, the last verified license keeps working for **14 days** (a warning starts at
  7); only a verdict — expired or revoked — degrades the server immediately. Degrading
  means falling back to Personal features; nothing is taken offline.

### `corepanel license status`

Reports the licensing state from the license cached locally, so it works offline and is
safe to call from a monitoring script.

```
Edition:   pro
Key:       CPL-EQ0T6-…-2ES83
Status:    active
Bound to:  203.0.113.24
Valid to:  2027-08-01 00:00 UTC
Verified:  2026-08-01 04:12 UTC
```

The report distinguishes the edition **in effect** from the edition the license **pays
for**; they differ exactly when something needs attention, and then the licensed edition
is printed as `not in effect`.

The **exit status is 1 once the licensed edition is not in effect** and 0 otherwise. A
server with no license at all is not a degradation — Personal is a supported edition and
the status stays 0.

| Alert (`alert` in JSON) | Meaning |
|-------------------------|---------|
| *(empty)* | Healthy, or unlicensed Personal |
| `validation_failing` | Check-ins have been failing for over 7 days; the edition still holds until the grace deadline |
| `grace_expired` | Check-ins never recovered; the server fell back to Personal |
| `expired` | The license ran out (payment stopped) |
| `revoked` | The license was revoked (refund or chargeback) |
| `unverifiable` | The stored license no longer verifies against the trusted signing key |

The key is always shown masked (`CPL-EQ0T6-…-2ES83`): the panel never returns it in full.

### `corepanel license activate`

Binds a license key to this server and unlocks its edition. The key looks like
`CPL-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`; it is case-insensitive and the dashes are optional.

A key already bound to another server is rebound to this one, spending one of the
license's rebinds (3 per 30 days). Releasing the old server first with
`corepanel license deactivate` does **not** spend one.

The key is a bearer credential: passing it as an argument leaves it in the shell history
and in the process list, so `--key-stdin` is the right form on shared machines. With no
argument and no `--key-stdin`, an interactive run prompts for it and a scripted one
fails rather than hanging.

```bash
corepanel license activate --key-stdin < license.key
```

### `corepanel license refresh`

Forces a check-in instead of waiting for the daily job. Use it after paying an invoice,
after changing the server's IP address, or to confirm connectivity to the license server.
A failed check-in does not degrade the server — the grace window has to run out first.

### `corepanel license deactivate`

Releases the license so it can be activated on another server: the license server drops
the binding and this server forgets the key. The server returns to Personal features
immediately.

Because the key is forgotten locally, the command asks for confirmation — keep the key at
hand before running it. A non-interactive run must pass `--yes`; without it, it refuses
rather than proceeding silently.

### `corepanel license trial`

Starts a **14-day free trial** of Pro or Business on this server. No card and no key: the
license server mints the trial, binds it to the public IPv4 it observes and this server
stores it, exactly as if it had been bought. It is the same thing the panel offers on the
first sign-in, for a server being set up over SSH.

```bash
corepanel license trial --edition business --email ops@example.com
```

`--email` is required — it is where the warnings go before the trial ends (7, 3 and 1 days
before, and once after). `--consent` is a separate opt-in for product news and changes
nothing about the trial.

- **One trial per server and per email address.** A second request is refused and the
  answer is the checkout, not a retry.
- **Switching edition mid-trial is free and keeps the original end date.**
- **When it ends the server returns to Personal.** Sites, mail, databases and certificates
  keep running; what stops is the paid features and creating sites beyond the allowance of
  20.

On a **Business** trial, plan for one consequence: hosting accounts sign in to the client
area, which Personal does not include. When the licence ends they keep access for **5
days**, with a notice telling them to contact their provider, and then those logins stop.
Their sites and mail are unaffected.

### `corepanel license upgrade`

Opens a checkout for this server and prints the URL to complete it. Nothing is charged by
the command: it mints a single-use token, ties it to this server and hands back the link.

`--edition` takes `pro` or `business`; the catalogue lives in `corepanel-core`, so an
edition it does not sell is refused here rather than opening a checkout that fails at the
far end.

The link works from any browser — it does not have to be one on this server. The license
is fetched afterwards **by this server**, which is what binds it to this server's public
address rather than to anything entered at checkout. The first server to redeem a link
takes the license, so a link is not something to share.

`--wait` keeps the terminal open until the payment is confirmed, polling every 5 seconds
for up to 10 minutes. Interrupting it (or never using it) costs nothing: the panel
finishes the purchase in the background, and an unredeemed one is retried at every
check-in.

A server that already holds a working license is refused — changing plan is a change to
the subscription, not a second purchase.

### `corepanel license claim`

Installs the license for a purchase that was already paid for. The panel does this by
itself, so this is for when nobody was watching: a browser closed at the wrong moment, or
a restart mid-checkout. It is safe to repeat — a repeated claim from the same server
returns the same license — and while the payment has not reached the license server yet it
says so and changes nothing.

### `corepanel license cancel-purchase`

Forgets a checkout that was never completed. It redeems it first: a purchase that turns
out to have been paid for is **installed** rather than discarded. It never cancels a
payment.

```bash
# Buy Pro, yearly, and wait until the payment lands
corepanel license upgrade --edition pro --cycle yearly --wait

# Buy Business, monthly
corepanel license upgrade --edition business

# Paid in the browser, closed the tab: install the license now
corepanel license claim

# Monitoring: alert when the paid edition is not in effect
corepanel license status >/dev/null || echo "licensing needs attention"

# Moving to a new server: release here first so the rebind is free
corepanel license deactivate --yes

# Scripting: how long until the grace window closes?
corepanel license status --json | jq -r '.graceExpiresAt // "n/a"'
```

## `corepanel telemetry`

Controls whether this server takes part in the daily check-in. It has nothing to
do with licensing despite sharing an endpoint: it carries no credential, unlocks nothing,
and runs on every edition including Personal. See
[Usage statistics](https://www.corepanel.net/docs/telemetry) for the complete field list.

```bash
corepanel telemetry status  [--json]
corepanel telemetry enable  [--json]
corepanel telemetry disable [--json]
```

It is **on by default**. What it sends, once a day: a random installation id, the CorePanel
version and edition, the OS and architecture, how long ago it was installed, counts of
accounts/domains/mailboxes/databases/applications/WordPress sites, and the names of the
features that are switched on. What it never sends: hostnames, domain names, email
addresses, account names, or anything typed into the panel. The receiving end keeps the
public IP the check-in arrives from, resolves it to a country and may look up its reverse
DNS; CorePanel staff can see both.

`status` exits 0 either way — not reporting is a supported configuration, not a fault.

Turning it off stops future check-ins. It does not remove what was already reported: your
server cannot reach into our records to delete rows.

For a fleet, `COREPANEL_NO_TELEMETRY=1` in the environment of `corepanel-core` turns it off
without a database write, so a golden image decides it once for every machine cloned from
it.

```bash
# Are we reporting, and under which id?
corepanel telemetry status

# Opt out
corepanel telemetry disable

# Scripting: the id this server is counted under
corepanel telemetry status --json | jq -r .installId
```

## `corepanel notifications`

Reads what CorePanel has been trying to tell you — and proves that the email side
of it works before you need it. See [Notifications](https://www.corepanel.net/docs/notifications) for what
produces these events and how they are delivered.

```bash
corepanel notifications list [--unread] [--severity <s>] [--source <m>]
                             [--audience admin|seller|owner|all] [--account <user>]
                             [--limit <n>] [--json]
corepanel notifications ack  [--all | --id <id>...] [--audience <a>] [--account <user>]
corepanel notifications test [--audience admin|seller|owner] [--account <user>] [--seller-id <id>]
```

Two things are specific to the CLI:

- **It is root-local, so it may read every audience.** The panel cannot — a session
  only ever sees its own notifications. This is what answers "did my customer
  actually get that mail?" without opening the database.
- **`--account` implies `--audience owner`.** Asking for one account's notifications
  as the administrator would otherwise return *your* copies of events about that
  account, which is a different list.

### `corepanel notifications list`

Lists notifications newest-first with the delivery state of each one. The `DELIVERY`
column is the useful part when something did not arrive:

| State | Meaning |
|---|---|
| `pending` | Not decided yet; the next delivery pass (once a minute) will |
| `sent` | Handed to the local mail server — which is not the same as delivered |
| `skipped` | Deliberately not mailed, with the reason beside it |

The skip reasons are `no_address`, `disabled`, `below_severity`,
`local_recipient_affected` (the recipient's mailbox lives inside the very account
the alert is about, so the message could not have arrived) and `send_failed`.

A `×N` beside the event means repeats were folded into one row while it was unread.

### `corepanel notifications ack`

Marks notifications as read, by id or all at once. Read state is per recipient:
acknowledging your copy leaves the customer's unread, because they have not seen it.
A read notification stops counting towards the bell badge and is kept for 90 days
rather than 180.

### `corepanel notifications test`

Writes a test notification for one recipient and mails it immediately, bypassing the
severity policy and the hourly rate limit. It fails loudly — rather than quietly
doing nothing — when the recipient has no address on file or has notification email
turned off, because those are the two things you would otherwise spend an afternoon
discovering.

"Sent" means the local mail server accepted it. A fresh VPS sending as
`corepanel@<hostname>` with no SPF, no DKIM and no matching PTR is exactly the
profile large providers drop silently, so check the receiving mailbox **including
its spam folder** — and see
[Making the email actually arrive](https://www.corepanel.net/docs/notifications/#making-the-email-actually-arrive).

```bash
# What has the server been trying to tell me?
corepanel notifications list --unread

# Only the things that are actually on fire
corepanel notifications list --severity critical

# Did this customer get their "WordPress is ready" mail?
corepanel notifications list --account john

# Everything, every audience — support view
corepanel notifications list --audience all --limit 200

# Prove email works before something breaks
corepanel notifications test
corepanel notifications test --account john

# Clear the badge
corepanel notifications ack --all

# Monitoring: anything unread and critical?
corepanel notifications list --unread --severity critical --json | jq '.total'
```

## `corepanel version`

Prints the CLI build version embedded at compile time.

```bash
corepanel version
```

## FTP accounts

Manage the FTP accounts of a hosting account. Every account is provisioned with a
**primary** FTP account named after its system user, reaching the whole home; these
commands manage the **additional** ones beside it. Accounts are identified by their **username, any domain they own, or their numeric account id** (see [Naming an account](#naming-an-account)).

```bash
corepanel ftp list <account>
corepanel ftp add <account> <username> --domain <domain> [flags]
corepanel ftp passwd <account> <ftp-account-id>
corepanel ftp remove <account> <ftp-account-id>
```

An additional account is named `user@domain`, and the domain has to be one the account
owns. That is not cosmetic: FTP authenticates by username alone, so the names are unique
across the whole server — requiring a domain you already hold is what stops one customer
claiming a name another account may need later.

These accounts are **virtual**: no Linux user is created. They log in with the hosting
account's own uid and gid, which means they share its disk quota and see exactly what
that user can see — there is no way for one to reach outside the account.

`--directory` confines the login to a subdirectory of the account home, which is the
usual reason to make one: a designer who should reach `uploads` and nothing else. The
directory is created if it does not exist (`0700`, owned by the account); a path that
would leave the home is refused, symlinks included.

Flags for `add`:

| Flag | Description |
|------|-------------|
| `--domain <domain>` | A domain of this account, forming `user@domain` (required) |
| `--directory <path>` | Directory relative to the account home, created if missing (default: the whole home) |
| `--password <pw>` | Password — visible in shell history; prefer the options below |
| `--password-stdin` | Read the password from standard input |

`passwd` takes `--password` / `--password-stdin` with the same meaning and changes one
account's password, the primary included. Omitting both on a terminal prompts for the
password without echoing it.

```bash
corepanel ftp add 12 designer --domain example.com --directory domains/example.com/uploads
corepanel ftp add 12 deploy --domain example.com --password-stdin < /root/ftp.pw

corepanel ftp list 12
corepanel ftp passwd 12 4
corepanel ftp remove 12 4
```

Deleting an account removes the credential and **leaves its files alone** — the directory
it pointed at stays exactly as it was. The primary account cannot be deleted: it is the
account's own credential, and removing it would leave the owner unable to reach their
files.

How many additional accounts an account may have comes from its package
(`--max-ftp` on [`corepanel account create`](#corepanel-account-create) and
[`update`](#corepanel-account-update), where `0` means unlimited), with a hard ceiling of
100 underneath it. The primary account is mandatory and counts against neither.

## Cron jobs

Manage per-account cron jobs. CorePanel stores the jobs and renders the account's
crontab (`/var/spool/cron/<user>`), which **cronie** executes as the account user — so
scheduled jobs keep running independently of the panel and behave exactly like cPanel.
A bare `php` in a command resolves to the account's own PHP version, so standard
WHMCS/WordPress cron lines work unchanged. Accounts are identified by their **username, any domain they own, or their numeric account id** (see [Naming an account](#naming-an-account)).

```bash
corepanel cron list <account>
corepanel cron add <account> --schedule <spec> --command <cmd> [flags]
corepanel cron update <account> <job-id> --schedule <spec> --command <cmd> [flags]
corepanel cron remove <account> <job-id>
corepanel cron enable|disable <account> <job-id>
corepanel cron run <account> <job-id>
```

The `--schedule` value is a five-field cron spec (`"*/5 * * * *"`) or one of the
macros `@hourly`, `@daily`, `@midnight`, `@weekly`, `@monthly`, `@yearly`,
`@annually`. It is validated and the next run computed before the job is stored.
`@reboot` and `@every <duration>` are **not** supported — use a five-field spec
(`*/30 * * * *` for "every 30 minutes").

Flags for `add` / `update`:

| Flag | Description |
|------|-------------|
| `--schedule <spec>` | Five-field cron spec or `@macro` (required) |
| `--command <cmd>` | Command to run (required) |
| `--comment <text>` | Human-readable label |
| `--mailto <email>` | Email job output to this address (empty disables mail) |
| `--timezone <tz>` | IANA timezone for the schedule (e.g. `America/Santiago`) |
| `--disabled` | Create/leave the job disabled (had no effect before 1.6.14: the job was created enabled) |
| `--single-instance` | Skip a run if the previous one is still active (`flock`) |
| `--timeout <sec>` | Kill the job after N seconds |
| `--cpu <sec>` | CPU-time cap |
| `--mem-kb <kb>` | PHP memory limit in KB (applied to PHP jobs via `memory_limit`) |
| `--nproc <n>` | Max processes |
| `--nice <0-19>` | Niceness (higher = lower priority) |

```bash
# WordPress and WHMCS cron, cPanel-compatible:
corepanel cron add 12 --schedule "*/5 * * * *" --command "php /home/user/public_html/wp-cron.php"
corepanel cron add 12 --schedule "@daily" --command "php ~/whmcs/crons/cron.php" --mailto ops@example.com --single-instance

corepanel cron list 12
corepanel cron run 12 3      # run once, now, and show the output
corepanel cron disable 12 3
```

Jobs run through a wrapper that applies the resource limits, single-instance lock, and
a timeout, and captures output. Disabling a job keeps it in the list but comments it out
of the crontab.

### The command environment

There is no allowlist: the command is any shell line the account could type itself. It
runs as the account user (never root), so it can only reach what that user can reach —
homes are `0700` and files `0600`.

Every job is executed as `/bin/sh -c '<your command>'` with:

```
SHELL=/bin/sh
PATH=/opt/corepanel/bin:/usr/local/bin:/usr/bin:/bin
```

What that means in practice:

- **It is `sh`, not `bash`.** No `[[ ]]`, arrays or process substitution. If you need
  bash features, wrap them: `bash -c '...'`.
- **`php` works on its own** and resolves to *that account's* PHP version, even though
  the host has no global PHP. The shim also points PHP's temporary files, uploads and
  sessions at the account's private `~/tmp`, and applies the job's `--mem-kb` as PHP's
  own `memory_limit`. Do **not** hardcode `/usr/bin/php` — it does not exist on a
  CorePanel host, and an absolute Remi path such as
  `/opt/remi/php84/root/usr/bin/php` pins the job to one PHP version and loses the
  temp-dir isolation and memory limit.
- **`wp` (WP-CLI) is on the PATH** and runs with the account's PHP.
- **Pipes, redirections, `&&` and variables are all fine** — it is a normal shell line.
- Base tools available to jobs include `curl`, `wget`, `git`, `tar`, `unzip`, `rsync`,
  `jq`, `find` and the MariaDB client (`mysqldump`).
- **`%` needs no escaping.** CorePanel escapes it for cron, so `date +%Y` reaches the
  shell verbatim.
- `~` expands to the account home, since cron sets `HOME` — but only at the start of a
  word. Inside a flag (`--path=~/public_html`) it stays literal; write `"$HOME/..."`
  there instead.

### More valid commands

```bash
# Application cron entry points
php ~/public_html/cron.php
php ~/whmcs/crons/cron.php
php ~/app/artisan schedule:run                      # Laravel scheduler (run every minute)

# WordPress via WP-CLI (after disabling wp-cron.php in wp-config.php)
wp --path="$HOME/public_html" cron event run --due-now
wp --path="$HOME/public_html" plugin update --all
wp --path="$HOME/public_html" db export ~/backups/wp-$(date +%F).sql

# Trigger something over HTTP instead of on disk
curl -fsS https://example.com/tasks/run > /dev/null
curl -fsS -m 30 https://example.com/health || echo "health check failed"

# Database dump with rotation (credentials in ~/.my.cnf, mode 0600)
mysqldump --defaults-extra-file="$HOME/.my.cnf" mydb | gzip > ~/backups/db-$(date +%F).sql.gz
find ~/backups -name 'db-*.sql.gz' -mtime +14 -delete

# Housekeeping
find ~/tmp -type f -mtime +7 -delete
find ~/logs -name '*.log' -size +100M -delete

# Your own scripts (must be executable, or invoke the interpreter explicitly)
~/bin/nightly-report.sh >> ~/logs/report.log 2>&1
sh ~/bin/sync-assets.sh
bash -c 'for d in ~/sites/*/; do echo "checking $d"; done'

# Archive a directory
tar -czf ~/backups/uploads-$(date +%F).tar.gz -C ~/public_html uploads
```

Passing a password on the command line (`mysqldump -p'secret'`) works but exposes it in
the process list to other processes on the host; a `~/.my.cnf` with mode `0600` is the
safer form shown above.

### What does not work

| Command | Why |
|---------|-----|
| `/usr/bin/php script.php` | No global PHP on the host — exits `127`. Use `php`. |
| `sudo ...`, anything as root | Jobs run as the account user; cronie drops privileges first. |
| Multi-line commands | Newlines and control characters are rejected — a job is one line. |
| `--schedule "@reboot"` | Not a supported macro (see above). |
| Reading another account's files | Homes are `0700`; jobs are confined to their own account. |

An account may define up to **100 jobs**.

## Domains

Manage an account's domains beyond the primary. Two types are supported:

- **alias** (parked): serves the account's primary docroot under its own DNS zone (pointing
  at the server IP), and its mail is routed to the primary — it has no independent mailboxes.
  Aliases count against the account's parked-domain limit.
- **addon**: a fully independent domain inside the same account — its own docroot at
  `~/domains/<domain>`, its own DNS zone, and its own mail identity (DKIM + a
  `webmail.<domain>` vhost). Addon domains can have their own mailboxes and count against
  the account's dedicated addon-domain limit.

Both limits use `0` = unlimited. Accounts are identified by their **username, any domain they own, or their numeric account id** (see [Naming an account](#naming-an-account))
(list them with [`corepanel account list`](#corepanel-account-list)); domain names are
globally unique.

```bash
corepanel domain list <account>
corepanel domain add <account> <domain> [--type alias|addon] [--alias-for <domain>] [--web-mode <mode>] [--canonical <host>]
corepanel domain delete <domain>
corepanel domain canonical show <domain>
corepanel domain canonical set <domain> <none|www|nonwww|primary>
corepanel domain optimizations show <domain>
corepanel domain optimizations set <domain> [--webp on|off|default] [--minify-js on|off|default] [--minify-css on|off|default] [--early-hints on|off|default] [--page-cache off|shadow|on] [--state-cookies <names>]
```

- `list` prints every domain the account owns — primary and secondary — with its type,
  alias target, **document root**, web mode and canonical host (`core.ListAccountDomains`).
  The document root is printed because it is not derivable from the name: an addon owns
  `~/domains/<domain>`, an alias shares the primary's `~/public_html`, and an account
  imported from another panel keeps the layout it arrived with.
- `add` validates the FQDN, global uniqueness, ownership and the matching per-type limit,
  provisions the vhost + DNS zone (and, for an addon, its own docroot, DKIM and
  `webmail.<domain>`) via `sys.AddDomain`, and moves the usage counter (`core.AddDomain`).
  An alias republishes the account's primary domain by default; `--alias-for` parks it on
  one of the account's **addon** domains instead, so it serves that site's document root
  and its mail is redirected there. The target must belong to the same account and be a
  primary or addon domain — an alias of an alias is refused.
- `delete` removes a secondary domain (`core.DeleteDomain`). For an addon this also tears
  down its mailboxes, DNS zone, DKIM, docroot, subdomains **and any alias parked on top of
  it** — those serve the addon's document root, so they cannot outlive it. The account's **primary domain cannot be
  removed this way** — it is only removed by deleting the account.
- `canonical show` prints which hostname of the domain serves the site and what the
  others do (`core.GetDomainCanonicalHost`).
- `canonical set` picks the real hostname (`core.SetDomainCanonicalHost`) and re-emits the
  site config. Every domain answers under **both** `<domain>` and `www.<domain>`, and an
  alias adds two more hostnames serving a copy of the primary's site — identical content on
  several hostnames is duplicate content, which splits link equity and leaves search
  engines guessing which URL is authoritative. The hostnames that are not canonical answer
  a **301 that preserves the path and query string**, so deep links and indexed URLs keep
  working.
- `optimizations show` prints a domain's web optimization overrides
  (`core.GetDomainOptimizations`).
- `optimizations set` sets them (`core.SetDomainOptimizations`) and re-emits the site
  config. **WebP-on-the-fly and Early Hints (HTTP 103) are baseline in every edition and
  on by default** — WebP transparently serves WebP to browsers that accept it (safe
  fallback to the original image); Early Hints learns a page's preload sub-resources and
  replays them as a 103 on later requests (HTTP/2 and HTTP/3 only, inert for static/SPA
  sites). **CSS/JS minification and the dynamic page cache are Speed Optimizer
  (Pro/Business) and off by default** — minification can break sites (source maps,
  already-minified assets, execution order), and a page cache should be measured before
  it serves anything, so both are opt-in. Use these commands to override each per domain.

Flags for `add`:

| Flag | Description |
|------|-------------|
| `--type <type>` | Domain type: `alias` (parked, default) or `addon` (independent) |
| `--alias-for <domain>` | For an alias, the site it republishes: an **addon domain of the same account**. Empty parks it on the primary (the default) |
| `--web-mode <mode>` | Web serving mode `plain\|cms\|spa` (empty ⇒ `plain`) |
| `--canonical <host>` | Canonical hostname `none\|www\|nonwww\|primary` (empty ⇒ `www`) |

Values for `canonical set` (and `--canonical`):

| Value | Effect |
|-------|--------|
| `none` | Every hostname serves the site directly, no redirect. Domains created before this feature keep this after an upgrade — **nothing changes for a live site until you choose a canonical host**. |
| `www` | `www.<domain>` serves; `<domain>` answers 301 to it. **Default for new domains.** |
| `nonwww` | `<domain>` serves; `www.<domain>` answers 301 to it. |
| `primary` | **Alias (parked) domains only.** Every hostname of the alias answers 301 to the primary's canonical hostname, in a single hop, instead of serving a copy of its site. Changing the primary's canonical host re-points every alias set to `primary` automatically. |

> **WordPress sites**
>
> If WordPress's `siteurl`/`home` name the hostname that now redirects, WordPress redirects
> back to it and the browser ends in a redirect loop. Update the site URL in the same change
> — the panel offers to do it for you.
Flags for `optimizations set`:

| Flag | Description |
|------|-------------|
| `--webp <state>` | WebP-on-the-fly: `on` (force), `off` (disable), or `default` (follow the server default, on). Omitting the flag resets to `default`. |
| `--minify-js <state>` | CSS/JS minify — JavaScript (Speed Optimizer, Pro/Business): `on` (force), `off` (disable), or `default` (follow the server default, off). Omitting the flag resets to `default`. |
| `--minify-css <state>` | CSS/JS minify — CSS (Speed Optimizer, Pro/Business): `on` (force), `off` (disable), or `default` (follow the server default, off). Omitting the flag resets to `default`. |
| `--early-hints <state>` | Early Hints (HTTP 103): `on` (force), `off` (opt this domain out), or `default` (follow the server default, on). Omitting the flag resets to `default`. |
| `--page-cache <mode>` | Dynamic page cache (Speed Optimizer, Pro/Business): `off`, `shadow` (evaluate without serving) or `on`. Not tri-state — there is no server default to follow, so omitting the flag turns the cache off. Refused on alias (parked) domains. |
| `--state-cookies <names>` | Extra cookies that mean "this visitor has state", comma-separated; a request carrying one bypasses the page cache and its response is never stored. A trailing `*` matches a prefix; at most 32. Added to the cookies CorePanel already knows (WordPress session, comments, WooCommerce, Aelia currency), which always apply. **Not whole-state:** omitting the flag keeps the site's current list; pass `""` to clear it. |

The page cache stores whole pages so a repeat visitor is answered without running PHP at
all. Two things make it safe to switch on:

- **Only what WordPress itself declares shareable is stored.** CorePanel's companion
  plugin decides, page by page, whether a response can be handed to the next visitor, and
  purges the right URLs when a post is published or edited. A site without the plugin is
  not cached at all rather than guessed at.
- **`shadow` measures before anything is served.** In shadow the cache decides and stores
  exactly as it would in production and serves none of it, so you can see the hit ratio
  a site would get before a single visitor is affected. Leaving shadow is self-healing:
  those entries are dropped on sight, with nothing to purge by hand.

> **Tip**
>
> Run a site in `shadow` for a few days before switching it `on`. How much of a site's
> traffic is actually cacheable cannot be guessed from the outside — a logged-in membership
> site and a news site with the same traffic land in completely different places.
```bash
corepanel domain list 12
corepanel domain add 12 parked.example.com
corepanel domain add 12 shop.example.net --type addon --web-mode cms
corepanel domain add 12 parked.example.com --canonical primary
corepanel domain add 12 alias-of-shop.example.com --alias-for shop.example.net
corepanel domain delete parked.example.com
corepanel domain canonical show example.com
corepanel domain canonical set example.com www
corepanel domain canonical set example.com nonwww
corepanel domain canonical set parked.example.com primary
corepanel domain optimizations show shop.example.net
corepanel domain optimizations set shop.example.net --webp off
corepanel domain optimizations set shop.example.net --webp default
corepanel domain optimizations set shop.example.net --minify-js on --minify-css on
corepanel domain optimizations set shop.example.net --early-hints off
corepanel domain optimizations set shop.example.net --page-cache shadow
corepanel domain optimizations set shop.example.net --page-cache on
corepanel domain optimizations set shop.example.net --state-cookies "my_currency,my_shop_*"
corepanel domain optimizations set shop.example.net --state-cookies ""
```

### Reverse proxy routes

Publish a local service under one of the server's domains. A route forwards the requests
matching a path to a `host:port` upstream; every unmatched path keeps being served from the
docroot. Routes are evaluated **first match wins**, and CorePanel keeps them ordered
most-specific-first so a catch-all never hides a more specific route. The full guide —
what the feature is for, when to use it and how the panel presents it — is in
[Reverse Proxy Routes](https://www.corepanel.net/docs/reverse-proxy).

> **Caution**
>
> CorePanel writes the route but **does not supervise the upstream**. Starting the service,
> restarting it on failure and bringing it up at boot are yours to handle (a systemd unit of
> your own). A route pointing at a process nobody supervises answers **502 after the next
> reboot**.
These commands are **administrator-only**, and that is a security boundary rather than a UI
choice: the caller picks the upstream, so pointed at an internal service the route would
publish it on the public internet under the domain's name. Targets on the local loopback are
filtered accordingly — the web server's own ports (80/443) are refused outright because they
would loop, and well-known service ports (corepanel-api, MySQL, PostgreSQL, Redis, Memcached,
SSH, SMTP) require `--allow-reserved-port`.

```bash
corepanel domain proxy list <domain> [--check]
corepanel domain proxy add <domain> <path> <upstream> [--strip-prefix] [--tls] [--allow-reserved-port]
corepanel domain proxy remove <domain> <path>
```

- `list` prints the domain's whole route table in evaluation order
  (`core.GetDomainRoutes`). The MANAGED column marks which entries these commands own:
  `yes` for a proxy route you added, `auto` for the docroot fallback CorePanel maintains,
  `no` for a hand-written route it leaves untouched. With `--check` every proxy target is
  dialed (`core.CheckProxyUpstreams`) and a STATUS column reports `up (Nms)` or
  `DOWN: <reason>` — the fastest way to tell "my service died" from "my site is broken",
  which from the outside are the same 502.
- `add` adds or replaces a route (`core.SetDomainProxyRoute`). `<path>` is the matcher — a
  prefix (`/api/*`), an exact path (`/health`) or a catch-all (`/*`) — and it is the route's
  **identity**: re-running `add` with the same path edits that route in place instead of
  adding a second, unreachable one. `<upstream>` is `host:port`, never a URL — or the unix
  socket of an application CorePanel published, written as
  `unix:/run/corepanel/apps/<account>/<app>.sock`. Only that exact layout is accepted, so no
  other socket on the server can be named; CorePanel writes those routes itself. The upstream
  is probed right after the route is applied, and a warning is printed if nothing is
  listening (the route is applied either way).
- `remove` drops the route matching `<path>` (`core.DeleteDomainProxyRoute`). Removing the
  last one returns the site to serving its docroot directly; removing a path that has no
  route is not an error.

Flags for `add`:

| Flag | Description |
|------|-------------|
| `--strip-prefix` | Remove the matcher prefix from the proxied request, so `/api/*` reaches the upstream as `/*`. Ignored for a catch-all matcher. |
| `--tls` | Dial the upstream over HTTPS instead of plain HTTP. |
| `--allow-reserved-port` | Confirm a target on a well-known local service port. |

```bash
corepanel domain proxy list app.example.com
corepanel domain proxy list app.example.com --check
corepanel domain proxy add app.example.com '/api/*' 127.0.0.1:8080 --strip-prefix
corepanel domain proxy add app.example.com '/*' 127.0.0.1:8080
corepanel domain proxy remove app.example.com '/api/*'
```

## Subdomains

Manage an account's subdomains (e.g. `blog.example.com`). A subdomain gets its own document
root (`~/domains/<fqdn>`, beside the primary's `public_html` rather than inside it) and web
vhost, plus a single **A record inside its parent domain's existing DNS zone** — it does **not** create a new zone and has no mail of
its own. TLS is still issued for the subdomain FQDN. The parent must be an existing
**primary or addon** domain owned by the account. Subdomains count against the account's
subdomain limit (`0` = unlimited). Accounts are identified by their **username, any domain they own, or their numeric account id** (see [Naming an account](#naming-an-account))
— find it with [`corepanel account list`](#corepanel-account-list).

```bash
corepanel subdomain list <account>
corepanel subdomain add <account> <label> <parent-domain> [--web-mode <mode>]
corepanel subdomain delete <fqdn>
corepanel subdomain optimizations show <fqdn>
corepanel subdomain optimizations set <fqdn> [--webp on|off|default] [--minify-js on|off|default] [--minify-css on|off|default] [--early-hints on|off|default] [--page-cache off|shadow|on] [--state-cookies <names>]
```

- `list` prints every subdomain the account owns, with its parent, web mode, and document
  root (`core.ListSubdomains`).
- `add` validates the label, parent ownership, global uniqueness (across both domains and
  subdomains) and the subdomain limit, provisions the docroot + vhost + parent-zone A
  record via `sys.AddSubdomain`, and moves the usage counter (`core.AddSubdomain`).
  `<label>` is what the subdomain adds in front of the parent zone: usually one label
  (`blog`), but **several are allowed for a nested subdomain** (`dev.blog` under
  `example.com` ⇒ `dev.blog.example.com`), which other panels create and an import has to
  reproduce. As a whole label, `www` and `webmail` are reserved (they are the parent's own
  hostnames); nothing auto-provisions `www.blog.example.com`, so `www.blog` is accepted.
  A nested name is **not covered by the parent's wildcard certificate** — a wildcard spans
  one label — so it gets a certificate of its own.
- `delete` removes a subdomain by its fully-qualified name (`core.DeleteSubdomain`): the
  vhost, the parent-zone A record, the certificate and the document root.
- `optimizations` reads and writes the subdomain's web optimizations — WebP, CSS/JS
  minification, Early Hints and the dynamic page cache. Same flags, same defaults and
  the same edition gating as `corepanel domain optimizations` in [Domains](#domains)
  above; see that section for what each flag does.

A subdomain is a **site of its own** — its own document root, usually its own
application — and inherits nothing from its parent domain: switching the page cache on
for `example.com` does not touch `blog.example.com`, and vice versa. Each is tuned
separately. (The alias refusal does not apply here: a subdomain serves its own content,
not a copy of another site's.)

Flags for `add`:

| Flag | Description |
|------|-------------|
| `--web-mode <mode>` | Web serving mode `plain\|cms\|spa` (empty ⇒ `plain`) |

```bash
corepanel subdomain list 12
corepanel subdomain add 12 blog example.com
corepanel subdomain add 12 shop example.com --web-mode cms
corepanel subdomain add 12 dev.blog example.com
corepanel subdomain delete blog.example.com
corepanel subdomain optimizations show blog.example.com
corepanel subdomain optimizations set blog.example.com --page-cache shadow
corepanel subdomain optimizations set blog.example.com --webp off --minify-css on
```

## Page cache

Turning the [dynamic page cache](https://www.corepanel.net/docs/web/optimizations) on or off is part of a site's
optimizations, above. These two commands cover what is *not* configuration: emptying the
cache, and reading what an evaluation measured.

Sites are named by **hostname** — a domain, an alias or a subdomain — because that is how
the cache itself is keyed.

```bash
corepanel cache purge <hostname> [path...]
corepanel cache stats [hostname]
```

`purge` with no paths empties the whole site; otherwise each argument is an absolute path,
optionally ending in `*` to purge a prefix. A path that is not absolute, or with a `*`
anywhere but at the end, is rejected rather than silently matching nothing.

**Every hostname the site answers on is purged**, not just the one you typed: a page is
stored once per hostname a visitor arrived through, so `example.com` and `www.example.com`
hold separate copies. The command prints the hostnames it reached — a missing one is a
separate site. Emptying the cache is never blocked by edition: pages cached while the
server was licensed are still on disk, and clearing a wrong page must always be possible.

`stats` only reports sites in **evaluation** mode (`--page-cache shadow`). A site with the
cache switched on produces no counters at all — the question the report answers is "what
would this site gain" — and the tallies reset when the web server restarts. `HIT%` counts
hits over the requests that were cacheable **at all**, so a bypassed request is not a miss;
`BYPASS%` is reported separately for exactly that reason.

```bash
corepanel cache purge example.com
corepanel cache purge blog.example.com / /feed
corepanel cache purge example.com "/2026/*"
corepanel cache stats
corepanel cache stats blog.example.com
```

## .htaccess

[`.htaccess` compatibility](https://www.corepanel.net/docs/web/htaccess) needs no configuration: the web server
compiles the rules it supports and picks a changed file up within a second, however it was
written. These commands answer the two questions a migrated site raises — what did the
server make of my file, and can I see the change now.

```bash
corepanel htaccess report <hostname> [--problems] [--json]
corepanel htaccess reload <hostname> [directory...]
```

`report` reads every `.htaccess` under the site's document root and says what happened to
each directive:

| Status | Meaning |
|---|---|
| `applied` | The rule is in force. |
| `no-op` | It asks for what already happens here. |
| `unsupported` | Out of scope by design — the explanation says why. |
| `error` | It could not be compiled, and the line is skipped. |

There is deliberately no "pending". `unsupported` is a decision, not a queue position:
most of those lines configure something this server does its own way — browser cache
policy, response headers, what runs as PHP — and the site is better off without them. The
ones worth acting on say so in their explanation.

The report reads the files **on disk**, which is what *would* apply; `--problems` hides
the applied lines, and `--json` prints the whole thing for scripting. The same view lives
in the panel under an account's **Apache rules** section.

```bash
corepanel htaccess report example.com
corepanel htaccess report example.com --problems
```

It drops the site's compiled rules **and the pages stored for them**, in that order and in
the same call — a cached page is answered before any rule is consulted, so dropping the
rules alone would leave a visitor being served exactly what you just changed.

With no directories the whole site is covered; otherwise each argument is a directory
relative to the document root (`/` for the root itself). Name the directory, not the
`.htaccess` file inside it. `Dropped: 0` means nothing was compiled for those directories:
normal on a site nobody has visited, a wrong path on a busy one — and the document root
printed above it is what settles that. Never blocked by edition.

```bash
corepanel htaccess reload example.com
corepanel htaccess reload example.com wp-content wp-content/uploads
```

## WordPress Manager

Manage WordPress installations end to end: install, adopt, update, harden, verify and
roll back sites. Every operation runs WP-CLI **as the account user** (never as root)
with the account's own PHP version. Accounts are identified by their **numeric account
id**; instances by the **instance id** shown in `corepanel wp list`.

```bash
corepanel wp list <account>
corepanel wp scan <account>
corepanel wp install <account> --domain <fqdn> --title <t> --admin-user <u> --admin-email <e> [flags]
corepanel wp attach <account> <docroot-path>
corepanel wp detach <account> <instance-id>
corepanel wp login <account> <instance-id> [--user <login>]
corepanel wp components <account> <instance-id>
corepanel wp update <account> <instance-id> [--core] [--all-plugins] [--all-themes] [--plugins a,b] [--themes x,y]
corepanel wp plugin|theme <action> <account> <instance-id> <slug>
corepanel wp policy <account> <instance-id> <none|minor|all>
corepanel wp hardening <account> <instance-id> [--set key=on|off ...]
corepanel wp integrity <account> <instance-id> [--reinstall]
corepanel wp maintenance|debug|indexing <account> <instance-id> [on|off]
corepanel wp set-url <account> <instance-id> <url>
corepanel wp restore-point create|list|rollback|delete
```

Highlights:

- **1-click install** — `wp install` provisions the database and DB user, downloads
  core as the account, writes a hardened `wp-config.php` (fresh salts, system-cron
  takeover, direct filesystem access), creates the native `*/5` cron job that replaces
  WordPress's visitor-paid pseudo-cron, and sets the domain's web mode to `cms`. When
  `--admin-password` is omitted a strong password is generated and **shown once**.
- **Adopt existing sites** — `wp scan` finds installations anywhere under the account
  home (including sites migrated with the cPanel importer) and manages them without
  touching their behavior. `wp detach` leaves a `.corepanel-wp-ignore` marker so the
  site stays unmanaged.
- **One-time login links** — `wp login` prints a single-use admin URL that expires in
  60 seconds. The token is minted inside WordPress and only its hash is stored; no
  passwords are involved.
- **Safe updates** — `wp update --core` automatically snapshots the site (files +
  database) first; `wp restore-point rollback` brings it back.
- **Reversible hardening** — file-editor lockdown, XML-RPC blocking, version hiding,
  user-enumeration blocking, and application-password disabling, each individually
  switchable (`on` = protection enabled).
- **Integrity** — `wp integrity` verifies core files against wordpress.org checksums;
  `--reinstall` replaces core files in place (keeping `wp-content` and
  `wp-config.php`), the standard malware-cleanup step.
- **Maintenance mode** — a server-level 503 page served by the web server itself, so
  it keeps working even when WordPress is broken mid-upgrade.

Action verbs for `wp plugin` / `wp theme`: `install` (`--version`, `--activate`),
`activate`, `deactivate` (plugins only), `delete`, `autoupdate-on`, `autoupdate-off`.

Hardening keys for `--set`: `file-editor`, `xmlrpc`, `version-hiding`,
`user-enumeration`, `app-passwords`.

```bash
# Install and open the dashboard
corepanel wp install 12 --domain example.com --title "My Blog" --admin-user admin --admin-email me@example.com
corepanel wp login 12 3

# Keep it updated, safely
corepanel wp components 12 3
corepanel wp update 12 3 --core --all-plugins
corepanel wp restore-point list 12 3

# Harden and verify
corepanel wp hardening 12 3 --set xmlrpc=on --set user-enumeration=on
corepanel wp integrity 12 3

# Upgrade window
corepanel wp maintenance 12 3 on
corepanel wp update 12 3 --all-themes
corepanel wp maintenance 12 3 off
```

## MySQL databases

Create and manage an account's databases and the users that connect to them.

```bash
corepanel database list <account> [--users]
corepanel database create <account> <name> [--user <suffix>]
corepanel database delete <account> <database> [--yes]
corepanel database user list <account> [<database>]
corepanel database user create <account> <database> <name> [--host <host>]
corepanel database user password <account> <user>
corepanel database user privileges <account> <user> --privileges <list>
corepanel database user delete <account> <user> [--yes]
```

A schema is always named `<account_user>_<suffix>`, and so is a user. That is why every
command takes the account first and the suffix after it: the prefix is not yours to
choose, and repeating it on the command line is noise. A database or a user can be named
in full (`example_blog`), by its suffix (`blog`) or by its numeric id — and an id that
belongs to a different account is refused here, not sent on.

`database` is also available as `db`.

### A user is name **and** host

`example_blog@localhost` and `example_blog@%` are **two different MySQL users** with two
different passwords, and an account can have both. New users are created at `localhost`,
which is what a site or an application on this server connects as.

A bare name that matches two users is refused, listing both, rather than resolved to one:

```
$ corepanel database user password example blog
Error: "blog" names more than one user; a host is part of a user's identity,
so say which: example_blog@localhost (on example_blog), example_blog@% (on example_blog)
```

Dropping the wrong credential — or resetting a password that leaves the other user
working, while you believe you changed it — is not something to decide by whichever row
came back first.

### Creating a database

```bash
# The schema and its user in one command, password prompted
corepanel database create example blog --user blog

# Scripted, reading the password from standard input
printf '%s' "$DB_PASS" | corepanel database create example shop --user shop --password-stdin
```

`--user` is what a new database is almost always for; without it the schema exists and
nothing can connect to it yet. The password is resolved **before** the schema is created,
so a mistyped prompt does not leave a database behind that you have to delete before
retrying the same command. If the user fails once the schema exists, the schema is
**kept** and the command prints the retry — dropping a database because a user could not
be added is the more destructive answer to a retryable problem.

Creating one counts against the account's database allowance, like any other.

> **Passwords are given, never read back**
>
> CorePanel hands MySQL passwords to the server and forgets them; MariaDB keeps only the
> hash. There is no command that prints an existing password — only one that sets a new
> one. The same three ways to supply it apply everywhere here, most to least preferred:
> `--password-stdin` (what a script should use), nothing at all (prompts hidden, when
> interactive), and `--password` (visible in shell history and in the process list).
### Users and what they may do

```bash
corepanel database user create example blog reports --privileges select,show\ view
corepanel database user privileges example blog_reports --privileges select
corepanel database user password example blog_reports
```

A grant is always **on one database**, never on the server, and `ALL PRIVILEGES` is as
wide as it goes. `user privileges` **replaces** the grant rather than adding to it: the
list you pass is what the user ends up with.

Accepted privileges: `ALL PRIVILEGES`, `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `CREATE`,
`DROP`, `ALTER`, `INDEX`, `CREATE TEMPORARY TABLES`, `LOCK TABLES`, `CREATE VIEW`,
`SHOW VIEW`, `EVENT`, `TRIGGER`, `REFERENCES`. A mistyped one is refused with that list
in hand, rather than three services away as MariaDB's "invalid privilege".

`--host` opens a user to an address other than `localhost`, and the command says so when
you do. Whether it is reachable at all is a matter of the server's bind address and its
firewall, both of which are the operator's decision.

### Dropping things

```bash
corepanel database user delete example blog_reports        # asks first
corepanel database delete example blog --yes               # for scripts
```

Dropping a **user** leaves the database and its data alone; what goes is the credential
and its grant, so anything still connecting as it starts being refused. Dropping a
**database** takes its tables and its users with it, and nothing in CorePanel brings it
back — only the account's backup can. Both ask for confirmation, and both refuse outright
when there is no terminal to ask on, so `--yes` is a deliberate act rather than a default.

### An application's database is protected

While an [application](#applications) holds a database, these commands refuse to break it:

- `corepanel database delete` refuses that schema, naming the application.
- `corepanel database user delete` refuses that user, naming the application.
- `corepanel database user password` **re-injects the new password into the application
  and restarts it**, so the process is never left holding a dead credential.

`corepanel app database detach` is the way out: it drops the application's user and keeps
the schema.

## Applications

Run a long-lived program for a hosting account and publish it under one of the account's
domains — a Go API behind `/api`, a service on its own subdomain, anything that listens
for HTTP. Accounts are identified by their **username, any domain they own, or their numeric account id** (see [Naming an account](#naming-an-account)); an application by that
id plus its slug.

Each application gets a systemd unit of its own (`corepanel-app-<user>.<app>.service`)
running as the account user in a private network namespace, with memory, CPU and task
limits applied as cgroup properties. It never listens on a TCP port the rest of the
server can reach: CorePanel binds a **unix socket** for it
(`/run/corepanel/apps/<user>/<app>.sock`) and the web server proxies to that socket, so
two applications can never collide on a port.

The code is shipped as a `.tar.gz` the account uploads (over FTP, to
`~/apps/<name>.tar.gz` by default). CorePanel extracts it as a new release, starts it and
checks it answers before publishing it; a release that does not answer is **rolled back
automatically** and the previous one keeps serving. Three releases are kept.

```bash
corepanel app list [<account>]
corepanel app show <account> <name>
corepanel app create <account> <name> --domain <fqdn> --port <n> [flags] -- <program> [args...]
corepanel app update <account> <name> [flags] [-- <program> [args...]]
corepanel app deploy <account> <name> [--artifact <path>]
corepanel app rollback <account> <name> [--to <release>]
corepanel app start|stop|restart <account> <name>
corepanel app releases <account> <name>
corepanel app logs <account> <name> [-n 200] [--follow]
corepanel app env list|set|unset <account> <name> [KEY=VALUE|KEY] [--secret]
corepanel app database show|create|attach|detach <account> <name> [<database>]
corepanel app webhook show|rotate|revoke <account> <name>
corepanel app delete <account> <name> [--purge-data]

corepanel app runtime status
corepanel app runtime prepare [--yes]
```

Applications belong to a hosting account, identified by their **username, any domain they own, or their numeric account id** (see [Naming an account](#naming-an-account)) — list
accounts with [`corepanel account list`](#corepanel-account-list) to find it. Every command
below takes it as `<account>`, followed by the application's name.

> **Tip**
>
> This is the command reference. For what applications are, the shapes they take and how to
> configure each one, see the [Applications](https://www.corepanel.net/docs/applications/) section.
> **Administrator-only, by design**
>
> Creating an application names the program systemd will execute, the limits it runs under
> and the site path it takes over. This surface is reserved for super-administrators.
### Creating an application

The start command goes **after a `--` separator**, as a program relative to the release
root followed by its arguments:

```bash
corepanel app create <account> api --domain example.com --path /api --port 8080 \
    --health /healthz --mem 512 --cpu 100 -- server --addr :8080
```

`create` reserves the name and derives the unit and the socket from it, but it does
**not publish the application yet** — the route is added by the first deploy that
answers. Pointing the site at a socket nothing is listening on would 502 the path, and
for an application mounted at `/` that is the whole site.

| Flag | Description |
|------|-------------|
| `--domain <fqdn>` | Domain or subdomain of the account to publish under (required). A domain alias is refused: it has no site file of its own |
| `--path </sub>` | Path under the site (default `/`); a sub-path mount gives the application its own root |
| `--port <n>` | Port the application listens on **inside its network namespace**, 1024-65535 (required) |
| `--health </healthz>` | Path requested after a deploy to decide whether the release is serving; without it the check is a plain connection to the socket |
| `--link <dir>` | Release-relative directory symlinked into the data volume, for applications that write inside their own tree; repeatable |
| `--mem <mb>` | Memory ceiling in MB (default 256) |
| `--cpu <pct>` | CPU quota as a percentage; `100` is one whole core (default 50) |
| `--tasks <n>` | Maximum number of tasks (default 64) |
| `--network outbound\|none` | Outbound access, `outbound` by default. `none` leaves the application with loopback only — no internet, no DNS — and saves the network backend's ~25 MB. Neither value exposes services on the server's own `127.0.0.1`, and neither publishes the application's port |
| `--runtime go` | Application runtime; only `go` is supported in this release |

`update` takes the same flags and applies them to the release already running. Omitted
fields are left as they are, the previous settings are restored if the application stops
serving under the new ones, and an application that was stopped stays stopped.
`--clear-links` removes every linked directory.

### Deploying and rolling back

```bash
# Deploy the conventional ~/apps/api.tar.gz
corepanel app deploy <account> api

# Or an artifact uploaded somewhere else in the account home
corepanel app deploy <account> api --artifact uploads/api-2026-07-27.tar.gz

# Go back
corepanel app releases <account> api
corepanel app rollback <account> api --to 20260727T101500Z
```

The artifact must live **inside the account's home** — it is extracted as root, so a path
outside the home is refused. A release that does not answer is rolled back to the
previous one, and `deploy` exits non-zero printing the reason from the application's
journal.

### Looking at what the machine says

```bash
corepanel app show <account> api
corepanel app logs <account> api -n 500
corepanel app logs <account> api --follow
```

`show` prints the specification alongside the live systemd status — active state, main
pid, last exit, restarts and whether the socket exists. What CorePanel recorded and what
systemd answers are different questions: an application the panel believes is running can
be crash-looping, which is exactly the case worth diagnosing.

It also prints **Health**, which is a third question again: the
[periodic check](https://www.corepanel.net/docs/applications/operations/#the-health-check) asks every running
application whether its endpoint still answers, and this is its last verdict with the time
behind it. `not checked` means the application is stopped or has never been deployed.

`logs` prints a snapshot of the journal, which holds both the application's own output and
the gateway's. With `--follow` (`-f`) it keeps printing what arrives until you interrupt
it, the same way `journalctl -f` does — the line count applies to the first screenful, and
from there on it only shows what is new.

### Environment and secrets

```bash
corepanel app env set <account> api LOG_LEVEL=debug
corepanel app env set <account> api API_TOKEN=s3cr3t --secret
corepanel app env set <account> api NEXT_FLAG=on --no-restart
corepanel app env list <account> api
corepanel app env reveal <account> api API_TOKEN
corepanel app env import <account> api ./.env
corepanel app env unset <account> api LOG_LEVEL
```

The variables live in a file systemd reads **as root before dropping privileges**, so the
application receives them while the account cannot read the file that holds them.

A `--secret` value is hidden from `env list`; `env reveal` prints it and **records that it
was asked for** in the server log. Naming a variable reveals that one, leaving the name out
reveals the whole environment as a single audit entry. No password is asked for: whoever
can run this can already replace the value and deploy code that prints it.

`--secret` is only sent when you actually pass it, so changing a secret's value does not
quietly turn it into a plain one. Going the other way — a secret becoming plain text — is
only accepted in a call that carries the value, because a demotion without it would list a
variable in the clear that the caller never held.

| Flag | Command | Effect |
|---|---|---|
| `--secret` | `env set` | Hide the value from listings |
| `--no-restart` | `env set`, `env import` | Store the value without restarting a running application |
| `--replace` | `env import` | Also remove the variables the file does not mention |
| `--plain` | `env import` | Do not mark credential-looking names as secret |

`env import` reads a `.env` — comments, blank lines, an optional `export` prefix and
quoted values — and applies the whole file in **one restart**, whatever its length.
Setting the variables one at a time restarts a running application per line. Use `-` as
the filename to read standard input.

Names that look like credentials (ending in `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `DSN`,
plus `DATABASE_URL`) are marked secret on import unless `--plain` is given.

Setting or removing a variable restarts a running application unless `--no-restart` says
otherwise. A value can be up to 16 KB, the environment as a whole up to 256 KB, and no
value may contain a line break.

### Giving an application a database

```bash
corepanel app database create <account> api            # schema + user + password
corepanel app database show   <account> api
corepanel app database attach <account> api blog       # one the account already has
corepanel app database detach <account> api            # keeps the data
```

`create` provisions the schema, a user dedicated to the application and a password,
writes the connection into the environment and restarts the process — one command, and
**no credential passes through anybody's clipboard**. The application reads it from:

| Variable | What it holds |
|---|---|
| `CP_DB_NAME` | The schema, `<account_user>_<suffix>` |
| `CP_DB_USER` | The user the application connects as, always `@localhost` |
| `CP_DB_PASSWORD` | The password, stored as a secret |
| `CP_DB_SOCKET` | The MariaDB unix socket to connect to |
| `CP_DB_HOST`, `CP_DB_PORT` | Present and **empty**: there is no TCP route (see below) |
| `DATABASE_URL` | The whole thing as a DSN, for a library that takes one |

These are **managed** variables: `app env set` refuses to write one, and the panel shows
them read-only. Detaching is what removes them. `DATABASE_URL` is the exception — set it
yourself and your value wins, for a driver whose DSN dialect is not the one CorePanel
writes.

> **There is no host, and that is the point**
>
> An application runs in a **private network namespace**, so it has no route to the
> server's own `127.0.0.1` — it reaches MariaDB through the unix socket and nothing else.
> `CP_DB_HOST` and `CP_DB_PORT` are published empty rather than omitted, so a library that
> insists on a host reports an empty value instead of looking like a variable somebody
> forgot to set.
`--name <suffix>` chooses the schema name; the full name is always
`<account_user>_<suffix>`. Without it the suffix comes from the application's name, with
a number appended when that one is taken. A suffix you pass may hold letters, digits and
`_` only, and `<account_user>_<suffix>` has to fit in the 32 characters MySQL allows a
user name.

`attach` links a schema the account already owns — named in full (`example_blog`), by its
suffix (`blog`) or by its numeric id — and **still creates a new user** for the
application. It cannot do anything else: CorePanel does not store MySQL passwords, so
there is no existing credential to hand over, and resetting one would break every
`wp-config.php` that shares it. The data is untouched and no other grant is changed.

`detach` drops the user CorePanel created and removes the variables, restarting a running
application. **The database and everything in it are kept.**

While an application holds a database, the [database commands](#mysql-databases) protect
it:

- `corepanel database delete` refuses to drop that schema, naming the application.
- `corepanel database user delete` refuses to drop that user, naming the application.
- `corepanel database user password` **re-injects the new password and restarts the
  application**, so a rotation never leaves the process holding a dead credential.
- Deleting the application drops its user and **never** the schema, `--purge-data` or
  not. The files under the application's directory are its own; the database is the
  customer's.

### Deploying from CI/CD

A build pipeline can deploy without a person in the loop:

```bash
corepanel app webhook rotate <account> api    # issues the token and prints both URLs
corepanel app webhook show   <account> api    # prints them again
corepanel app webhook revoke <account> api    # disables it
```

The token works two ways. The pipeline can `POST` the artifact itself, deploying it in the
same call and needing no FTP account:

```bash
curl -fsS -X POST https://panel.example.com/api/apps/upload \
     -H 'Authorization: Bearer cpd_...' --data-binary @api.tar.gz
```

Or it can upload to `~/apps/<name>.tar.gz` as the account and `POST` to the deploy URL,
which is what a GitHub or GitLab webhook — a caller that can be given nothing but an
address — has to do.

Either way, what runs is the same deploy as `corepanel app deploy`, automatic rollback
included. Two properties decide how the pipeline is written: the call answers **`202` as
soon as the deploy starts**, not when it finishes, and a second call while one is running
is refused with `409`.

Full guide, with GitHub Actions and GitLab CI examples, signatures and every status code:
[Deploying from CI/CD](https://www.corepanel.net/docs/applications/ci-cd/).

### Can this server run containers?

Applications run **natively** on every supported release, and nothing on this page needs
preparing. Running them in containers — a Podman backend that is still in development —
additionally needs podman and a kernel booted with the unified cgroup hierarchy:

```bash
corepanel app runtime status
```

It reports podman's version, the cgroup hierarchy the kernel booted with, the controllers
delegated to per-user slices and how SELinux labels the container image store, and ends in
one of three verdicts: **ready**, **reboot pending** or **unavailable**, each with the step
that matches it.

On AlmaLinux 9 and 10 a stock server is ready. On AlmaLinux 8 the kernel boots with the
older cgroup hierarchy, and that is not a detail: there, podman **accepts** a container's
memory and CPU limits, ignores them, and starts the container anyway. CorePanel would
rather refuse than run a customer's application on a limit that does not exist, so the
backend is not offered until the server can enforce it.

```bash
corepanel app runtime prepare
```

`prepare` installs the systemd delegation drop-in and adds
`systemd.unified_cgroup_hierarchy=1` to every boot entry. It **does not reboot the
server** — the argument only takes effect on the next boot, and when to take a hosting
machine down is your decision, not the panel's. Run it, then reboot when it suits you;
`status` says `reboot pending` in the meantime. It asks for confirmation unless `--yes` is
given, and running it twice changes nothing.

The same reading and the same button are on **Settings → Container runtime** in the panel.

### Removing an application

```bash
corepanel app stop <account> api
corepanel app delete <account> api
```

`delete` unpublishes the application, stops it, and removes its unit, program tree and
socket. Its **data directory is kept** unless `--purge-data` is given: it is the one thing
a redeploy cannot recreate. Deleting a domain that still publishes an application is
refused with a message naming them; `corepanel domain delete --force` removes them in
cascade.

> **`--purge-data` is irreversible**
>
> The `data/` directory survives a redeploy and a plain delete. It **is** part of the
> account backup, so a deleted application can be restored with its data — but only from a
> backup taken before the deletion. `--purge-data` removes it now, and nothing in the panel
> brings it back.
## `corepanel firewall`

Manages the **host firewall**. CorePanel drives firewalld directly: a `corepanel` zone
that drops inbound traffic by default, the ports of the installed stack opened
explicitly, and two address lists — blocked addresses and a bastion allowlist. The
concepts are explained in [Host Firewall](https://www.corepanel.net/docs/security/host-firewall); this is the
command reference.

```bash
corepanel firewall status
corepanel firewall seed
corepanel firewall enable [--yes]
corepanel firewall disable
corepanel firewall factory-reset [--yes] [--stop-firewall]

corepanel firewall ports list
corepanel firewall ports open <port|from-to> [--udp] [--source CIDR]... [--comment C]
corepanel firewall ports close <port|from-to> [--udp] [--force]
corepanel firewall ports edit <port|from-to> [--udp] (--source CIDR... | --any) [--comment C]

corepanel firewall deny <ip|cidr> [--ttl 24h] [--comment C]
corepanel firewall deny list [--offset N] [--limit N]
corepanel firewall deny remove <ip|cidr>
corepanel firewall deny clear [--yes]

corepanel firewall allow <ip|cidr> [--comment C]
corepanel firewall allow list [--offset N] [--limit N]
corepanel firewall allow remove <ip|cidr>

corepanel firewall autoblock
corepanel firewall autoblock set [--enable|--disable] [--sources L] [--ttl D] [--max-ttl D] [--cooldown D]
```

Four rules explain the whole group:

- **Managing the firewall requires Pro or Business; `status` and `disable` never do.** A
  server whose licence lapsed while the firewall was enforcing must always be able to see
  that state and end it.
- **This group is the emergency exit.** Locked out of the panel? SSH in and run
  `corepanel firewall disable`: firewalld stops immediately and nothing is destroyed.
- **The seed cannot cut SSH.** The port sshd listens on is read from the running daemon,
  not guessed from a config file, so enabling the firewall over an SSH session does not
  end it. Closing that port, or 443, is refused unless `--force` is passed.
- **Expiry belongs to CorePanel, not the kernel.** Blocks are stored flat and released by
  a sweeper that runs once a minute and once at startup, so a block survives a reboot and
  still expires on time.

### `corepanel firewall status`

Reports whether firewalld is installed, running and managed by CorePanel, the default
zone and its inbound policy, the backend, the rule and list counts, and anything that
would stop management from working. When the server is unprotected, or firewalld is
running unmanaged, the output says which command fixes it.

### `corepanel firewall seed`

Prints the ports `enable` would open — port, protocol and the service each one serves —
without opening them. Worth reading before adopting a server you reach over the network.

### `corepanel firewall enable`

Adopts the firewall: builds the zone, creates the address lists, applies the seed, and
only then makes the zone default, so the step that starts dropping traffic happens last.
Prints the seed and asks for confirmation; a non-interactive run must pass `--yes`.
Running it again on an adopted server repairs anything missing.

Adoption is refused, with an explanation, when firewalld is not installed, when it is on
the deprecated iptables backend, or when the host has direct rules.

### `corepanel firewall disable`

Stops firewalld. The host accepts everything again immediately; every rule stays in
firewalld's permanent configuration and `enable` puts it back. Asks nothing, and no
edition may refuse it.

### `corepanel firewall factory-reset`

Removes CorePanel's zone, its rules and both address lists, and hands the default zone
back to what it was before adoption. Blocked and allowed addresses are destroyed with the
lists. This is not how the firewall is turned off — `disable` is, and it is reversible.
`--stop-firewall` also stops firewalld afterwards.

### `corepanel firewall ports`

A port is a single number (`8080`) or a range (`40000-50000`), TCP unless `--udp` is
passed. `--source` restricts it to source networks (repeatable, or comma-separated); with
none, the port is open to everyone.

`edit` replaces a rule's sources with the new ones in place before the old ones are
withdrawn, so an address that keeps access never loses it for an instant. The port and
protocol are the rule's identity and cannot change. Dropping every restriction is
explicit (`--any`), and omitting `--comment` keeps the comment the rule already had.

### `corepanel firewall deny`

Blocks an address or network, permanently or until a deadline (`--ttl`, from `60s` to
`168h`). `deny list` shows the time left, the provenance and the comment for each entry;
an address the kernel holds that CorePanel has no record for is listed as `untracked`
rather than hidden. Unblocking something that is not blocked succeeds, so `deny remove`
is safe in a script that does not check first.

### `corepanel firewall allow`

The bastion allowlist. An allowed address is accepted before every other rule, so it
reaches ports that are closed to everyone else and cannot be blocked. For the office or
the jump host access must never be lost from — and nothing else.

### `corepanel firewall autoblock`

The automatic brute-force policy. CorePanel's own detectors report the addresses that
cross their threshold, and this decides what happens to them: the first ban lasts `--ttl`
(30m by default), every repeat offence doubles it, and `--max-ttl` (7 days) is where the
doubling stops. `--cooldown` (10m) is how long an address is left alone between
escalations, so the counter measures how *often* an attacker comes back rather than how
fast they knock.

`--sources` replaces the whole list, so pass every source that may block; an empty value
means none. `set` changes only the flags given.

Three addresses can never be banned, whatever the policy says: this server's own, the
loopback, and anything on the bastion allowlist. An address an administrator blocked by
hand is never escalated either.

Automatic blocking requires Pro or Business. On Personal the detectors keep reporting, so
the attack stays visible, and nothing is blocked; reading the policy works on every
edition.

### Examples

```bash
# What is the firewall doing right now?
corepanel firewall status

# See exactly what adopting this server would open, then do it
corepanel firewall seed
corepanel firewall enable

# Provisioning: adopt without a prompt
corepanel firewall enable --yes

# Locked out of the panel — SSH in and stop filtering
corepanel firewall disable

# Open the FTP passive range, and a staging port only to the office
corepanel firewall ports open 40000-50000
corepanel firewall ports open 8443 --source 203.0.113.0/24 --comment "staging"

# The office moved: replace the sources without closing the port
corepanel firewall ports edit 8443 --source 198.51.100.0/24

# Block an address for a day, and a whole network for good
corepanel firewall deny 203.0.113.7 --ttl 24h --comment "ssh brute force"
corepanel firewall deny 198.51.100.0/24 --comment "scanner"

# What is blocked, and for how much longer?
corepanel firewall deny list

# Never lock out the jump host
corepanel firewall allow 203.0.113.10 --comment "office"

# What gets banned automatically, and for how long?
corepanel firewall autoblock

# Ban harder and for longer, but only on panel logins
corepanel firewall autoblock set --enable --sources panel --ttl 1h --max-ttl 48h

# Keep seeing the attacks, stop acting on them
corepanel firewall autoblock set --disable
```

## `corepanel auth`

Reads the **authentication activity** of the server's mail and FTP services.
`corepanel-auth` brokers every credential check on the box — IMAP, POP3, SMTP, FTP, plus
the certificate and mail-map operations around them — and records each one with the
account, the source address, the country it resolved to and, when it failed, why. The
store itself is described in [Mail System](https://www.corepanel.net/docs/mail-system#authentication-audit-trail).

```bash
corepanel auth events [--service S] [--user U] [--ip IP] [--country CC] [--result R] [--reason R] [--since D] [--limit N]
corepanel auth attackers [--since D] [--limit N]
corepanel auth stats [--since D]
corepanel auth user <account> [--since D] [--limit N]
```

Available on **every edition**. Seeing an attack is never gated; blocking one
automatically is what requires Pro or Business (`corepanel firewall autoblock`).

Three things are worth knowing before reading the output:

- **No password material is ever recorded** — not the value, not a hash, not a length.
  That is a rule of the store, not a display choice, so there is nothing of the kind to
  print.
- **Recording an event never delays a login.** When the writer falls behind, the store
  drops the event and counts it. `corepanel auth stats` prints that count: when it is
  non-zero, every number beside it is a floor rather than a total.
- **`--since` takes a duration** (`30m`, `24h`, `168h`) and defaults to 24 hours.

### `corepanel auth events`

Lists attempts newest-first. `--service` accepts `ftp`, `imap`, `pop3`, `smtp`, `extcert`
or `sync`, and the usual aliases (`imaps`, `submission`) resolve to the same rows. When
the filter matches more than `--limit`, the footer says so rather than letting a
truncated list read as the whole story.

### `corepanel auth attackers`

The source addresses with the most failed logins in the window, worst first. The
**ACCOUNTS** column is the one to read: one account failing repeatedly from a single
address is usually a stale password on somebody's phone, while the same address failing
against many accounts is a dictionary run. That distinction is what decides whether an
address is ever reported to the firewall.

### `corepanel auth stats`

The window against the one before it, followed by the health of the audit trail itself:
how many events are stored, the retention window, whether the row cap was reached, how
many events were dropped, and whether country lookup is working.

A third block reports the **brute-force detector**: how many addresses it is watching, how
many responses it delayed, how many it reported to the firewall — and how many reports it
deliberately withheld. Those refusals are listed separately rather than folded into a total,
because "nothing was blocked" has several very different meanings:

| Line | What it means |
|---|---|
| `Addresses with a recent sign-in` | Somebody authenticated successfully from them in the last seven days, so they cannot be blocked automatically at all |
| `Withheld — address had signed in` | A report was ready and dropped for exactly that reason |
| `Withheld — events did not confirm` | The running counters proposed a block the stored events did not support |
| `Slow campaigns found` | Addresses working through accounts that do not exist here over the last 24 hours, however slowly — the tier that catches what real attacks look like. Printed even at zero, because "looked and found nothing" is a different answer from "never looked", and normally higher than the number reported: the gap is the immunity and the cooldowns |
| `Delays skipped` | The concurrency ceiling was reached and a response was answered immediately instead of being held |
| `Automatic blocking is paused` | Core answered that this server will not block — the edition does not include it, or the operator turned it off. Detection and the delays carry on |

The thresholds in force are printed with them, so the numbers can be read without consulting
this page. See [Access protection](https://www.corepanel.net/docs/security/access-protection) for what each one protects.

### `corepanel auth user`

One account's record. The header spans its whole retained history — last success, last
failure, how many distinct addresses it was used from, which countries — while the list
below covers only the requested window, so an empty list means "nothing in the last day",
not "never logged in".

### Examples

```bash
# Everything that failed in the last hour
corepanel auth events --result fail --since 1h

# Who is hammering the mail server this week
corepanel auth attackers --since 168h

# A customer says their mail client stopped working
corepanel auth user ana@example.com --since 48h

# Only FTP, only from one country
corepanel auth events --service ftp --country CN

# Is the audit trail keeping up?
corepanel auth stats
```

## `corepanel api-token`

Issues and withdraws the **machine credentials** that let a program use CorePanel's
JSON-RPC API — a WHMCS install, a provisioning script, a monitoring probe. See
[API Tokens](https://www.corepanel.net/docs/api-tokens) for what a token can reach and how to use one.

```bash
corepanel api-token create <name> --scope S [--scope S ...] [--expires DATE]
corepanel api-token list [--all]
corepanel api-token revoke <token-id>
```

Issuing a token requires **Pro or Business**. Listing and revoking work on every
edition — a lapsed licence must never stand between an operator and a credential they
want to withdraw.

### `corepanel api-token create`

Prints the token once and never again. `--scope` is repeatable and at least one is
required:

| Scope | What it allows |
|---|---|
| `readonly` | List and read. Safe for monitoring. |
| `provisioning` | Create accounts, suspend and unsuspend them, change packages and passwords. Implies `readonly`. |
| `terminate` | Delete accounts. Never implied by anything. |

`--expires` takes `YYYY-MM-DD` or a full RFC3339 timestamp; a bare date means the **end**
of that day. Omitted, the token does not expire.

> **Shown once**
>
> Only the hash of a token is stored, so nothing can print it again — not the panel, not
> the CLI, not the database. Copy it when it appears. If you lose it, revoke it and create
> another.
### `corepanel api-token list`

Shows active tokens with their prefix, scopes, status and last use. Revoked ones are
hidden unless `--all` is given.

### `corepanel api-token revoke`

Withdraws a token immediately. The row is kept rather than deleted, so a token named in
an old audit line can still be resolved. Revoking an already-revoked token succeeds.

### Examples

```bash
# A token for WHMCS that provisions and suspends, but cannot delete
corepanel api-token create "WHMCS production" --scope provisioning

# Add cancellations as a second, separate credential
corepanel api-token create "WHMCS terminations" --scope provisioning --scope terminate

# A monitoring credential that expires at the end of the year
corepanel api-token create "status page" --scope readonly --expires 2026-12-31

# What can reach this server right now?
corepanel api-token list

# The laptop was stolen
corepanel api-token revoke 4
```

## `corepanel agent-token`

Issues and withdraws the credentials an **MCP client** presents to read this server on
your behalf — your Claude Code, your Cursor, whatever you already work in. See
[MCP for AI agents](https://www.corepanel.net/docs/mcp) for what an agent can ask and what it cannot do.

```bash
corepanel agent-token create <name> [--operate] [--expires DATE | --never-expires]
                                    [--allow ADDR]...
corepanel agent-token list [--all]
corepanel agent-token sources <token-id> <address>... | --clear
corepanel agent-token activity [--since D] [--token ID] [--tool NAME] [--action A]
                               [--outcome O] [--ip ADDR] [--before ID] [--limit N]
                               [--details | --json]
corepanel agent-token revoke <token-id>
```

Available on **every edition**, Personal included. It is not an API token, and the
differences are the whole design:

- **Read-only unless `--operate` is asked for.** It answers questions about this one
  server and carries none of the rights that create, suspend or delete an account, at any
  setting. `--operate` adds the right to change what already exists, through a two-step
  confirmation.
- **It expires in 90 days unless you say otherwise.** An API token lives on a server you
  control; this one lives in a configuration file on a workstation, in a dotfiles
  repository, in a backup.
- **It can be pinned to the addresses it may be used from.** `--allow`, off unless you
  ask for it. It is the only measure that stops a credential somebody else got hold of;
  expiry, revocation and the activity log all deal with a leak once you know about it.
- **The two are not interchangeable.** An agent token presented to the API is refused,
  and an API token presented to the MCP endpoint is refused.

Minting the first one also **turns the MCP endpoint on**: until a usable agent token
exists, `/mcp` answers as though the path did not exist. Revoking the last one turns it
off again.

### `corepanel agent-token create`

Prints the credential once. Only its hash is stored, so nothing can print it again.

`--expires` takes `YYYY-MM-DD` or a full RFC3339 timestamp; a bare date means the **end**
of that day. Omitted, the token expires in 90 days — omitting it does *not* mean "never".
`--never-expires` does, and has to be asked for by name.

`--operate` grants the **write right**. What it buys is the ability to change what
already exists — restarting an application, backing up an account, flushing a site's
page cache — and every such change takes two calls: the agent's first call returns a plan and changes nothing, and
the change happens only on a second call carrying the confirmation that plan issued,
which lasts 60 seconds and works once. It never reaches an account's lifecycle, and it
deletes nothing. See [Letting it change things](https://www.corepanel.net/docs/mcp#letting-it-change-things).

`--allow` pins the credential to an address or a CIDR range, IPv4 or IPv6, and is
repeatable. A token presented from anywhere else is refused with the same "invalid
token" a wrong secret produces — the response must not tell a caller which check it
failed, or the failure confirms that the credential it holds is real. Only the log, and
`activity --action auth_refused`, say what happened.

Omitted, the token works from wherever it is copied to. That is the default because a
list breaks the day you work from somewhere new, and the answer to that is
`agent-token sources`, not turning the list off.

If you use [`corepanel mcp`](#corepanel-mcp) on the server itself, put `127.0.0.1` on
the list: the bridge reaches the endpoint over loopback and sends no address of its own.
Loopback is never implicit — every local user on the machine can reach that address.

A token cannot be widened afterwards. Read-only is what you get by not choosing, and
changing your mind means revoking and minting again.

The `access:` line reports the right the token actually **got**, not the one you asked
for. `--operate` against a `corepanel-core` that predates the write right succeeds and
mints a read-only token — an unknown parameter is ignored, not refused — so the command
says so in as many words rather than letting you find out when a change is turned away.
The `sources:` line is the same shape for the same reason: `--allow` against such a core
mints a token pinned to nothing, and the command says so.

### `corepanel agent-token sources`

Replaces the list of addresses a token may be presented from. The list is a
**replacement**, not an addition: whatever you pass is what the token ends up with.

Each entry is an address or a CIDR range. A bare address is stored as a single-host
range, so `192.0.2.7` and `192.0.2.7/32` are the same rule — and what the command prints
back is what the server **stored**, which is what it will compare against, not what you
typed.

A range with host bits set is refused rather than widened. `10.0.0.5/8` is a typo for one
address far more often than it is a request for sixteen million, and quietly storing
`10.0.0.0/8` would grant them; the error names both readings.

`--clear` removes the restriction and has to be asked for by name. A command that read
"no addresses given" as "allow from anywhere" would unpin a credential on a mistyped
invocation, and nothing would look wrong afterwards — the token keeps working, from
everywhere.

What this command cannot change is what the token may **do**. That is fixed when the
token is minted; widening it is still a revoke and a new token.

| Flag | Meaning |
|------|---------|
| `--clear` | Remove the restriction, making the token usable from any address |

### `corepanel agent-token list`

Shows active tokens with their prefix, access, status, expiry, last use and the address
it was last used from. API tokens are never in this list. Revoked ones are hidden unless
`--all` is given.

The **Access** column answers "which of these could have changed my server": `read-only`
or `read + operate`. What one of them actually changed is `activity --action tool_apply`.

The **Sources** column says where each credential may be used from. `any` is a token
that works from wherever it is copied to; anything else is one that does not.

The address column is the other one worth reading: a credential in use from an address you do
not recognise. It is half the question — a credential somebody is *guessing* at never
reaches this table, because it never resolves to a row. That half is `activity`.

### `corepanel agent-token activity`

What the `/mcp` endpoint was asked for, newest first, read from the machine itself. The
panel shows the same trail at **Server → Agent tokens → Activity**; this is the answer
for a terminal, which on a server is usually where you are.

| Action | What it means |
|---|---|
| `tool_call` | A tool was called. `--details` shows the arguments it was given |
| `tool_apply` | A change was authorised and carried out on this server |
| `initialize` | An MCP client completed the handshake — somebody attached one |
| `tools_list` | A client asked what this server offers |
| `auth_refused` | A token was presented and did not resolve |

`tool_apply` is the short answer to "did my agent change anything". A write tool takes
two calls; the first returns a plan and touches nothing, and is recorded as an ordinary
`tool_call`. Only the second is a `tool_apply`, so `--action tool_apply` is every change
ever made through this endpoint and nothing else. A failed one is still a `tool_apply` —
the outcome says whether it landed.

`auth_refused` is what the command is worth running for. A refusal names **nobody**: the
credential did not resolve, so there is no token to attribute it to, and the string
presented is never stored — the address is the whole of what is knowable, and the TOKEN
column says `(refused)` rather than going blank. A request that carried no credential at
all is deliberately not recorded; those are port scans and stale bookmarks.

| Flag | What it does |
|---|---|
| `--since` | A duration: `30m`, `24h`, `168h`. Omitted, everything the store still holds |
| `--token` | One agent token, by the id `agent-token list` shows |
| `--tool` | One tool, e.g. `read_logs` |
| `--action` | `tool_call`, `tool_apply`, `initialize`, `tools_list` or `auth_refused` |
| `--outcome` | `ok`, `tool_error`, `refused` or `error` |
| `--ip` | One source address |
| `--before` | Only events older than this id — how you page backwards |
| `--limit` | Rows to show, default 25, maximum 1000 |
| `--details` | One block per event, with the arguments a tool was called with |
| `--json` | The page as it came off the wire, for a script |

Paging is by id rather than by offset, because the trail only grows at the top and an
offset would repeat rows. When older events match, the command prints the id to pass to
`--before`.

Whatever the store dropped is printed under the list without being asked for. The `/mcp`
endpoint never waits for the audit trail, so a burst of traffic — or a core that was
unreachable — costs events rather than latency, and a trail that is quietly losing rows
looks exactly like a quiet one.

Two answers are not faults, and the command says so rather than reporting a failure: a
`corepanel-core` that predates the trail, and one whose store did not open. Both leave
your tokens working; what is missing is the record of what they did.

### `corepanel agent-token revoke`

Withdraws a token immediately. The id of an API token is not found here — those are
revoked with `corepanel api-token revoke`.

### Examples

```bash
# A token for the laptop you work from, expiring in 90 days
corepanel agent-token create "laptop"

# One that may also change things, through the two-step confirmation
corepanel agent-token create "deploy box" --operate

# One that never expires, for a workstation that never leaves the office
corepanel agent-token create "office desktop" --never-expires

# One that only works from the office, and from this server itself
corepanel agent-token create "office desktop" --allow 192.0.2.0/24 --allow 127.0.0.1

# Change where an existing token may be used from
corepanel agent-token sources 3 192.0.2.0/24 198.51.100.0/24

# Let it be used from anywhere again
corepanel agent-token sources 3 --clear

# Which agents can read this server, and from where?
corepanel agent-token list

# What has been asked of the endpoint lately?
corepanel agent-token activity

# Has anybody been trying credentials that are not mine?
corepanel agent-token activity --action auth_refused --since 168h

# Everything an agent has ever CHANGED on this server
corepanel agent-token activity --action tool_apply --since 720h --details

# What did the laptop's token do today, arguments included?
corepanel agent-token activity --token 101 --since 24h --details

# That address is not mine
corepanel agent-token revoke 7
```

## `corepanel mcp`

Speaks MCP on stdin and stdout, for a client running on this same server.

```bash
corepanel mcp [--token-file PATH] [--url URL]
```

It is a pipe to the endpoint the panel already serves at `/mcp`: every message is
forwarded, unchanged, to `corepanel-api` on the loopback address, and the answer is
written back. Nothing else changes — the same tools, the same two-step confirmation
before anything is altered, and the same audit trail, which
`corepanel agent-token activity` shows exactly as it would a call over HTTPS.

Use it when the client runs on the box itself and pointing it at
`https://<panel>/mcp` would mean that hostname resolving there and that certificate
being trusted by that client. This talks to `http://127.0.0.1:16087/mcp`: no hostname,
no certificate, no proxy. Some MCP clients also accept only a command, never a URL.

**It needs an agent token and does not create one.** Mint it with
`corepanel agent-token create`, then put it in `COREPANEL_AGENT_TOKEN` or in a file
named by `--token-file`. There is deliberately no `--token` flag: an argument is
visible in `ps` to every user on the server.

| Flag | Meaning |
|---|---|
| `--token-file` | Read the token from this file instead of `$COREPANEL_AGENT_TOKEN`. It must contain the token and nothing else; a warning is printed if other users can read it |
| `--url` | The endpoint to forward to (default `http://127.0.0.1:16087/mcp`) |

Nothing but protocol goes to stdout. Everything a human needs to read is on stderr,
where an MCP client will not mistake it for a message.

### Examples

```bash
# Mint the credential once
corepanel agent-token create "local agent"

# What a client's configuration looks like
#   {
#     "mcpServers": {
#       "corepanel": {
#         "command": "corepanel",
#         "args": ["mcp"],
#         "env": { "COREPANEL_AGENT_TOKEN": "cpa_..." }
#       }
#     }
#   }

# Or keep the credential in a root-only file instead of the configuration
corepanel mcp --token-file /root/.corepanel/agent-token
```

See [MCP](https://www.corepanel.net/docs/mcp#from-a-client-on-the-server-itself) for the whole picture.

## `corepanel whmcompat`

Controls whether this server answers the **WHM API on :2087** — the compatibility layer
that lets a WHMCS which has been provisioning this machine through cPanel keep working
after a transform, with nothing changed on the WHMCS side. See
[WHMCS](https://www.corepanel.net/docs/whmcs#the-compatibility-layer) for what it is and when you want it.

```bash
corepanel whmcompat status [--json]
corepanel whmcompat enable [--json]
corepanel whmcompat disable [--json]
corepanel whmcompat sso enable
corepanel whmcompat sso disable
corepanel whmcompat redirects enable
corepanel whmcompat redirects disable
corepanel whmcompat import-credential [--user U] [--kind token|accesshash] [--label L]
corepanel whmcompat credentials
corepanel whmcompat revoke-credential [--user U] (--digest PREFIX | --all)
corepanel whmcompat selftest
```

> **Installed is not listening**
>
> Installing `corepanel-whmcompat` does **not** open :2087. The daemon starts, asks the
> panel whether the layer is switched on, and binds nothing until the answer is yes. The
> package is not part of the `corepanel` metapackage either, so a normal server never
> installs it at all.
>
> `:2087` is historically the most attacked port in shared hosting. Opening it is a
> decision, never a side effect of an upgrade.
### `corepanel whmcompat status`

Answers two questions together, because either alone is misleading: whether the server
is **configured** to serve the WHM API, and whether a daemon is actually **listening**.
A switch that is on with no package installed serves nothing; an installed package with
the switch off is running and holding no port. Both are normal, and both look like a
fault if you only see half of it.

The exit status is 0 in every one of those states — not serving the WHM API is a
supported configuration, not an error. Use `--json` to branch on it.

### `corepanel whmcompat enable` / `disable`

Open and close :2087. Both take effect within half a minute and neither needs a restart,
which matters most in the OFF direction: closing the port must not wait for somebody to
remember there is a service to bounce. Requests in flight are allowed to finish.

They also open and close the port **in the host firewall**, and print which zone they
touched:

```
WHM API compatibility: enabled
Passwordless sign-in:  disabled
Daemon:                listening on [::]:2087
Credentials held:      1
Firewall:              port 2087/tcp opened in the "public" firewall zone
```

The zone is whichever one is in force — the `corepanel` zone on a server that adopted
[the host firewall](https://www.corepanel.net/docs/security/host-firewall), the distribution's default zone on one
that did not,
which is where the installer already seeds the stack's ports. On a server with firewalld
stopped or not installed, nothing is filtering the port and the line says so instead of
claiming a change.

> **Both halves are needed**
>
> A daemon bound to :2087 behind a firewall that drops the traffic passes `selftest` —
> which dials loopback — while being unreachable for the WHMCS it exists to serve. The
> only symptom then appears on the other side, as a server reported down. That is why the
> switch manages the port rather than leaving it as a step to remember.
>
> The port managed is **2087**. If you moved the API elsewhere in `whmcompat.ini`, open the
> new port yourself.
### `corepanel whmcompat sso enable` / `disable`

A **second switch**, over the one WHM command that signs a customer into their panel
without their password — `create_user_session`, the "Log in to cPanel" button in a WHMCS
client area. Off by default, and off on servers that were already serving :2087 before
this switch existed.

It is separate from the layer's own switch because it is a different kind of thing to
grant. Everything else the layer does is **loud**: a password change stops the customer's
own password working, a suspension takes their site down. A minted session is **silent** —
it *is* the customer's own session, which is what makes it the right thing for that button
and what makes it worth deciding on. Most billing setups only provision and suspend and
never need it.

> **What turning it on grants**
>
> Anything holding a WHM credential for this server can then obtain a working session in
> **any** account's panel, with no password and nothing the customer would notice.
>
> The link itself is single-use, lives sixty seconds, and is authorised twice — once when
> it is minted and again when the browser arrives — so an account suspended in between
> cannot be signed into.
Both directions take effect within half a minute, without a restart. Turning it on
requires Business; turning it off never does.

This does **not** affect the [native CorePanel module for WHMCS](https://www.corepanel.net/docs/whmcs). That module
reaches the same feature through an API token this server issued, over the port the panel
already owns, and its login button works whatever this switch says.

### `corepanel whmcompat redirects enable` / `disable`

A **third switch**, over the legacy cPanel ports a **browser** lands on: 2082, 2083, 2086,
2095 and 2096. Off by default, including on servers that already serve the WHM API.

This half of the compatibility layer is not for a billing system at all. On a cPanel
server, `:2083` is *the* URL every customer has bookmarked and `:2096` is how they reach
their webmail. After a transform, those are a refused connection — no page, no explanation
— for everybody at once, and it is the most visible breakage of the whole migration.

With it on, this server answers those ports with a redirect and nothing else:

| Port | TLS | A browser is sent to |
|------|:---:|----------------------|
| 2082 | no  | the panel |
| 2083 | yes | the panel |
| 2086 | no  | the panel |
| 2095 | no  | `webmail.<domain>`, for the domain it asked for |
| 2096 | yes | `webmail.<domain>`, for the domain it asked for |

**Available on every edition**, unlike the two switches above — that is why it is a switch
of its own. Enabling the WHM API requires Business, so a transformed Personal or Pro
server could otherwise never reach a redirect that costs nothing and grants nothing, while
its customers' bookmarks are just as broken.

What these ports will not do:

- **They never redirect an API client.** A `/json-api/` path, or any request carrying an
  `Authorization` header, gets a 404 — or, on `:2086` alone (cPanel's *cleartext* API
  port), a parseable refusal naming `https://<hostname>:2087`. Redirecting a provisioning
  call to a login page is a failure that reports success.
- **They never redirect to an address from the request.** The webmail target is built from
  the `Host` header only after the panel confirms this server serves that domain;
  anything else falls back to the panel. Nothing from the path or query string is ever
  used.
- **They never send a 301, and never HSTS.**

The ports are opened and closed in the host firewall along with the switch.

> **The certificate warning on :2083 and :2096 is expected**
>
> Those two are TLS, served with this server's own host certificate, so
> `https://sudominio.com:2083` warns about the name before the redirect happens — exactly as
> it did on cPanel, where that URL warned too unless the domain had its own certificate. It
> is one click. A customer who types `sudominio.com:2083` without a scheme reaches the
> plaintext port and sees no warning at all.
`status` prints the switch in both states and, when it is on, names which of the five
ports are actually bound. Fewer than five means something else on this server holds the
rest.

### `corepanel whmcompat import-credential`

Teaches this server the credential the billing system already uses, read from **stdin**.

| Flag | Default | Meaning |
|---|---|---|
| `--user` | `root` | The WHM user the credential authenticates as |
| `--kind` | `token` | `token` (a WHM API token) or `accesshash` (the contents of `/root/.accesshash`) |
| `--label` | — | A note for whoever revokes it later |

> **Only the digest is kept**
>
> The server stores proof of the credential, never the credential — SHA-512, which is
> exactly what cPanel itself stores for a WHM API token. That is what makes zero-touch
> continuity real: a transform can import a hosting provider's existing token without anybody ever
> learning its value, and the WHMCS that holds the secret keeps authenticating unchanged.
> A stolen credential store authenticates nowhere.
The secret is read from stdin and never from a flag — a credential in a flag is a
credential in the shell history and in every `ps` on the box. Importing the same
credential twice changes nothing.

### `corepanel whmcompat credentials`

Lists what the server accepts: user, kind, label, import date, and the digest that
identifies each credential. There are no secrets to print — the store keeps digests,
which is what makes holding a hosting provider's token safe.

The digest is what names a credential when revoking one. A label cannot: several
credentials can carry the same note.

### `corepanel whmcompat revoke-credential`

Stops this server accepting a credential.

| Flag | Default | Meaning |
|---|---|---|
| `--user` | `root` | The WHM user whose credential is being revoked |
| `--digest` | — | Digest prefix from `credentials`; names exactly one credential |
| `--all` | off | Revoke every credential this user holds |
| `--yes`, `-y` | off | Do not ask for confirmation |

One of `--digest` or `--all` is required, and they cannot be combined. Revoking
everything has to be spelled out: an empty store accepts nothing, so `--all` is how a
server stops answering its billing system entirely — there is no way to ask for that by
leaving a flag out, and it asks for confirmation unless you pass `--yes`.

A revoke that matched nothing says so rather than reporting a revocation. And since only
the digest was ever stored, a revoked credential comes back only by importing the secret
again.

> **Update the daemon first**
>
> The command refuses to run against a `corepanel-whmcompat` older than itself. That
> version read the target from a different field and ignored `--digest`, so a request
> naming one credential arrived as "revoke everything" — and reported success.
>
> `corepanel-whmcompat` is not part of the `corepanel` metapackage, so `dnf update
> corepanel` upgrades the CLI and leaves the daemon behind. `dnf update
> corepanel-whmcompat` and retry.
### `corepanel whmcompat selftest`

Drives a real WHM API request against this server over loopback — the port, TLS, the
credential header, the response envelope and the call into the panel, with nothing
stubbed — using a one-shot credential the daemon mints for it.

It proves this side. Only WHMCS proves WHMCS: when it passes, press **Test Connection**
in WHMCS → System Settings → Servers.

### Examples

```bash
# Is this server serving the WHM API, and is anything listening?
corepanel whmcompat status

# After a transform: open the port the hosting provider's WHMCS is already pointed at
corepanel whmcompat enable

# Teach it the token WHMCS already holds
corepanel whmcompat import-credential --user root --kind token < token.txt

# The hosting provider still authenticates with the old access hash
cat /root/.accesshash | corepanel whmcompat import-credential --kind accesshash

# What does this server accept, and since when?
corepanel whmcompat credentials

# That one belonged to a WHMCS that is gone
corepanel whmcompat revoke-credential --digest a9e70ee8

# Prove our side end to end before touching WHMCS
corepanel whmcompat selftest

# Close it again
corepanel whmcompat disable
```

## `corepanel php`

The PHP this server runs: which versions are installed, which can be, what
extensions each one loads, and the per-request limits every account is held to. See
[How PHP Runs](https://www.corepanel.net/docs/php/how-php-runs) for the version model and
[PHP Isolation and Limits](https://www.corepanel.net/docs/php/isolation-and-limits) for what each limit does.

```
corepanel php versions
corepanel php install <version> [--yes]
```

`versions` lists every PHP version on the server — how many accounts run each,
and how long PHP upstream still supports it — and underneath, the versions
available to install.

"End of life" means end of life **upstream**. A package repository may go on
building a version PHP itself no longer fixes; the accounts on it stop receiving
security fixes whatever the repository does.

`install` adds a version and brings it under CorePanel's configuration, so it
gets the same per-account pools as every other version. Only versions `versions`
lists are accepted. Nothing is moved onto it: accounts change one at a time, with
`corepanel account update <account> --php`.

Which versions are offered depends on the operating system: 7.4 and 8.0 to 8.5
everywhere, plus 5.6 and 7.0 to 7.3 on EL8, the only release Remi still publishes those
for. See [PHP versions](https://www.corepanel.net/docs/php/how-php-runs#adding-a-php-version).

It runs a package transaction and can take several minutes; the package manager's
output is printed when it finishes, including on failure. Installing an
end-of-life version asks for confirmation — allowed, since 7.4 is what most
cPanel migrations arrive on, but never silent. `--yes` skips the prompt and is
required where there is no terminal to ask on.

```
# What this server runs, and what it could run
corepanel php versions

# Add the version a cPanel migration needs, then move one account onto it
corepanel php install 8.1
corepanel account update acme --php 8.1

# In a provisioning script, where nothing can answer a prompt
corepanel php install 7.4 --yes
```

```
corepanel php extensions [--php <version>]
corepanel php extensions install <version> <extension> [--yes]
corepanel php extensions remove <version> <extension> [--yes]
```

`extensions` lists what each installed version loads, from the sixteen CorePanel
manages — `imagick`, `redis`, `memcached`, `soap`, `ldap`, `imap`, `bcmath`,
`posix`, `ioncube-loader`, `sodium`, `gmp`, `tidy`, `apcu`, `igbinary`,
`mongodb`, `mcrypt`.
Anything an administrator installed over SSH is listed separately as installed
outside CorePanel: it works, it is simply not managed here. See [PHP Extensions and ionCube](https://www.corepanel.net/docs/php/extensions).

An extension belongs to a **PHP version, not to an account**: there is one
php-fpm master per version, so installing one gives it to every account running
that version.

The state of each one:

| State | Meaning |
|-------|---------|
| `installed` | The package is here and the interpreter loads it |
| `NOT LOADED` | The package is here but PHP does not load it — its ini was disabled by hand |
| `available` | Not installed; it can be installed from here |
| `unavailable` | Your package repository does not offer it for that version |
| `unknown` | The repository could not be asked at all — not the same as `unavailable` |
| `not for this version` | It does not exist for that PHP branch. Normal, not a fault |

`install` and `remove` take the extension's logical name; which package that is
depends on the version and is decided by the daemon, since the repository spells
the same extension differently from one branch to the next.

**Either direction restarts that version's php-fpm master**, interrupting
requests in flight for every account on it — the packages carry a trigger that
does it whenever their configuration directory changes. Both ask before running;
`--yes` skips the prompt and is required where there is no terminal.

A removal is **refused** when another installed package depends on the one being
removed, and says which: `redis` requires `igbinary`, so removing igbinary would
take redis with it and break caching for somebody who was never asked.

The package manager's output is printed as it arrives. Interrupting the command
stops the watching, not the transaction — that runs in `corepanel-sys`, and
killing rpm halfway would leave the package database half-applied.

```
# What every version loads
corepanel php extensions

# Just one version
corepanel php extensions --php 8.3

# WooCommerce needs imagick; object caching needs redis
corepanel php extensions install 8.3 imagick
corepanel php extensions install 8.4 redis

# Take it away again, having checked nothing else needs it
corepanel php extensions remove 8.4 redis
```

```
corepanel php limits [--json]
corepanel php limits set [--memory-limit MB] [--max-execution-time S] [--max-input-time S]
                         [--max-input-vars N] [--upload-size MB] [--post-size MB]
                         [--terminate-after S] [--adopt-defaults] [--json]
```

These are per *request*, not per account: the worst case one account can reach is
`pm.max_children` (8) multiplied by the memory limit, and the output prints that
product for you.

The header tells you whether the values were configured or are CorePanel's
defaults, and that difference decides what happens to pools. While nothing is
configured the limits are a **floor**: a pool that already names a value keeps
it. Once you set anything here, every CorePanel-managed pool is rewritten to
match.

| Flag | Sets | Can a site override it? |
|---|---|---|
| `--memory-limit` | `memory_limit`, in MB (64–16384) | **No** |
| `--terminate-after` | `request_terminate_timeout`, in seconds (30–3600) | **No** |
| `--max-execution-time` | `max_execution_time`, in seconds (10–3600) | Yes, with a [`.user.ini`](https://www.corepanel.net/docs/php/user-ini) |
| `--max-input-time` | `max_input_time`, in seconds (10–3600) | Yes |
| `--max-input-vars` | `max_input_vars` (100–100000) | Yes |
| `--upload-size` | `upload_max_filesize`, in MB (1–16384) | Yes |
| `--post-size` | `post_max_size`, in MB (1–16384) | Yes |

Only the flags you pass change, and **with no flags it does nothing**. On a server
where nothing has been configured the values shown are defaults acting as a floor;
holding every pool to them is a real change and takes `--adopt-defaults`. Applying
reloads one php-fpm master per PHP version, not one per account.

Two rules are enforced, because breaking either produces a silent wrong answer
rather than an error:

- `--terminate-after` must exceed `--max-execution-time`, or php-fpm kills the
  worker before PHP can write the error and every runaway script becomes a 502
  with an empty error log.
- `--post-size` must be at least `--upload-size`, or PHP rejects a large upload
  on the POST size first and the upload limit can never be reached.

Requires super-admin: the limits are server-wide, so a reseller cannot read or
change them.

```bash
# What is every account held to right now?
corepanel php limits

# A WooCommerce shop needs more room; everything else stays as it is
corepanel php limits set --memory-limit 512

# Bigger uploads for a media-heavy server
corepanel php limits set --upload-size 256 --post-size 256

# Free stuck workers sooner, keeping PHP as what reports the error
corepanel php limits set --max-execution-time 60 --terminate-after 120

# For scripting
corepanel php limits --json
```

## `corepanel resources`

What every hosting account is using, and how much it is allowed. See
[PHP Isolation and Limits](https://www.corepanel.net/docs/php/isolation-and-limits#limiting-one-account)
for the mechanism.

```
corepanel resources list [--json]
corepanel resources set --limit N|0|none [--account NAME] [--json]
```

The limit is **concurrent PHP requests per account**. An account already running
as many as it is allowed has its next request answered `508` while its
neighbours are untouched — that is what turns the per-request `memory_limit`
into a per-account bound: an account can hold at most its limit multiplied by
that memory limit.

`list` prints one row per account with memory, CPU, process count, PHP requests
in flight, the peak, and how many were refused; the header adds the ceiling for
all of PHP together and how much of it is in use.

> **A dash is not a zero**
>
> The figures come from two services — memory and CPU from `corepanel-sys`, the
> PHP columns from the web server — and either can be unavailable on its own. A
> column that could not be read prints `-`, with the reason under the table.
> Showing it as `0` would report an unreachable web server as an idle one.
>
> CPU is a rate between two samples, so the first reading after `corepanel-sys`
> starts is `0%` for everyone.
| `--limit` | Meaning |
|---|---|
| `N` | At most N concurrent PHP requests |
| `0` | Unlimited, deliberately — how one account is exempted on a server that limits the others |
| `none` | Clear it: the server stops limiting, or the account follows the server default again |

`0` and `none` differ on the server: `none` means nobody has decided anything and
no limits are written at all, which is how a freshly upgraded server behaves.
**Nothing is limited until you set a default here.**

Applying reloads the web server. Requests already running are never killed by
lowering a limit — they finish, and the new ceiling applies to what arrives next.

Requires super-admin.

```bash
# Who is using what?
corepanel resources list

# Hold every account to 4 concurrent PHP requests
corepanel resources set --limit 4

# One busy shop gets more room
corepanel resources set --account shop.example --limit 12

# ...and one account is exempt entirely
corepanel resources set --account internal.example --limit 0

# Back to following the server default
corepanel resources set --account internal.example --limit none

# For scripting
corepanel resources list --json
```

## Configuration

The CLI connects to `corepanel-core` through a Unix socket. By default it uses:

```
/run/corepanel-core/corepanel-core.sock
```

Override the socket path with the `COREPANEL_CORE_SOCKET` environment variable — useful in
non-standard deployments or when testing against a different Core instance:

```bash
COREPANEL_CORE_SOCKET=/run/corepanel-core/corepanel-core.sock corepanel status
```

> **Connection errors**
>
> If commands that reach Core (such as `list admins` or `mail sync`) fail with a connection
> error, confirm that `corepanel-core` is running with `corepanel status` and that the socket
> path matches your deployment.
