# Reverse Proxy Routes

> Publish a local service — an API, a Node or Go app, a dashboard — under one of your domains, with CorePanel's HTTPS, WAF and logs in front of it.

Source: https://www.corepanel.net/docs/reverse-proxy/
Last updated: 2026-08-20
Part of the CorePanel documentation — https://www.corepanel.net/docs

---

A **reverse proxy route** publishes a service running on your server under one of the
domains CorePanel already manages. Requests matching a path are forwarded to a
`host:port` you choose; every other path keeps being served from the domain's document
root as usual.

```
https://app.example.com/api/orders   ──▶  127.0.0.1:8080   (your service)
https://app.example.com/index.html   ──▶  ~/public_html    (the docroot)
```

The service does not need a public port, a certificate, or a hostname of its own. It
listens on the loopback interface, and the domain — with everything CorePanel already
puts in front of it — becomes its front door.

## What you get by proxying instead of exposing a port

Running a service on `:8080` and telling people to visit `http://example.com:8080`
works, but it is naked. Behind a CorePanel route, the same service gets:

- **Automatic HTTPS.** The domain's certificate, issued and renewed by CorePanel. Your
  service keeps speaking plain HTTP on loopback.
- **The WAF and the firewall.** Requests are filtered before they reach your service, and
  the service's own port never has to be open to the internet.
- **Traffic statistics and logs.** Proxied requests are counted on the dashboard's Web
  Traffic panel like any other.
- **A real hostname.** `app.example.com` instead of an IP and a port number.

## When to use it

Typical cases:

| Case | Route |
|------|-------|
| An API next to a normal website | `/api/*` → `127.0.0.1:8080`, docroot keeps serving the rest |
| A whole app (Node, Go, Python, Java) on its own domain or subdomain | `/*` → `127.0.0.1:3000` |
| A single-page app with a separate backend | SPA files in the docroot, `/api/*` → the backend with **strip prefix** |
| A service you installed yourself (Gitea, a metrics dashboard, a webhook receiver) | `/*` → its local port |
| A gradual rewrite | move one path at a time to the new service, leave everything else on the old site |
| A service on another machine in your private network | `/*` → `10.0.0.20:8080` |

## Before you start: CorePanel does not run your service

This is the one thing to be clear about.

> **Caution**
>
> CorePanel writes the route. **Starting your service, restarting it when it crashes and
> bringing it back up after a reboot are yours to handle.** A route pointing at a process
> that nobody supervises answers **502 Bad Gateway** the moment that process stops — and it
> will stop, at the latest, on the next reboot.
The supported way to keep a service alive on a RHEL-family server is a systemd unit.
A minimal one, in `/etc/systemd/system/myapp.service`:

```ini
[Unit]
Description=My application
After=network.target

[Service]
User=myaccount
WorkingDirectory=/opt/userapps/myaccount/myapp
ExecStart=/opt/userapps/myaccount/myapp/myapp
Restart=always

[Install]
WantedBy=multi-user.target
```

```bash
systemctl daemon-reload
systemctl enable --now myapp
```

With that in place the route is stable: systemd keeps the process up, CorePanel keeps
the door open.

> **Do not put the executable inside `/home`**
>
> On a server with SELinux enforcing — the default on RHEL, AlmaLinux and Rocky — a
> service whose executable lives under `/home` **will not start**. systemd fails it with
> `status=203/EXEC` and, to make it worse, **logs no AVC at all**, so nothing points at
> SELinux as the cause.
>
> The reason is the file's label: everything under `/home` is `user_home_t`, which systemd
> is not allowed to execute. Anywhere outside it — `/opt`, `/usr/local/bin` — the binary
> gets a label systemd can transition from, and the service starts with no extra
> configuration.
>
> `/opt/userapps/<account>/<app>/` is the convention CorePanel itself will use when it
> starts managing application services, so putting your own there now means nothing moves
> later. Keeping the artifact out of the home also keeps it out of the account's backups
> and out of reach of the account's own FTP access.
>
> Anything your application **writes** belongs somewhere else again:
> `/var/opt/userapps/<account>/<app>/`. `/opt` is for the program, `/var/opt` is for its
> data — the split the filesystem standard asks for, and the one that lets you replace the
> program without touching what it has stored.
## The shortcut: proxy a domain as you create it

Most addon domains and subdomains created for an application want the whole
hostname pointed at that application, so the **Add domain** and **Add subdomain**
dialogs offer it directly: switch on **Serve from a local service**, give the
`host:port`, and the site is published through your service the moment it exists.
That is exactly equivalent to adding a `/*` route afterwards, and the route shows
up in the Reverse proxy section like any other.

The option is not offered for a domain **alias**: an alias is served by the domain
it points at and has no configuration of its own, so the route belongs on that
domain instead.

If the domain is created but the route fails (a typo in the port, for instance),
the domain is kept — you get a warning saying the route is still pending, and you
add it from the section below.

## Adding a route from the panel

The editor lives in the account's workspace, under **Reverse proxy**. It is
**administrator-only** — see [Why administrators only](#why-administrators-only).

1. Pick the **site** the route belongs to. Every primary domain, addon domain and
   subdomain of the account is listed. (Domain aliases are not: they have no
   configuration of their own and are served by the domain they point at, so the route
   goes on that domain.)
2. Click **Add route** and fill in four fields:
   - **Path** — what the route matches: a prefix (`/api/*`), an exact path (`/health`) or
     `/*` for the whole site.
   - **Upstream** — where the requests go, as `host:port`. Never a URL.
   - **Strip the path prefix** — off by default. See below.
   - **Connect over HTTPS** — only if your service speaks TLS on that port.
3. Use **Test** to dial the upstream before saving. It tells you whether anything is
   listening there right now.
4. Save. The route applies immediately; no restart, no downtime for the rest of the site.

Once saved, each route shows whether its target is **Responding** (with the round-trip
time) or gives **No response** and the reason. That indicator is the difference between
"my site is broken" and "my service is down", which from the outside are the same 502.

### Path or prefix

The matcher is literal, and `/api` and `/api/*` are not the same thing:

| Matcher | Matches | Does not match |
|---------|---------|----------------|
| `/api/*` | `/api/`, `/api/orders`, `/api/v1/x` | `/api`, `/apidocs` |
| `/api` | `/api` exactly | `/api/orders` |
| `/*` | everything | — |

If your service should own both `/api` and everything under it, add the two routes.

### Strip the path prefix

By default the upstream receives the path exactly as the visitor typed it: a request to
`/api/orders` arrives at your service as `/api/orders`. With **strip prefix** on, the
matcher's prefix is removed and the same request arrives as `/orders`.

Turn it on when your service is written as if it owned the root — which most frameworks
are, unless you configure a base path. Turn it off when the service already expects to
live under `/api`. It is ignored for a `/*` matcher, where there is nothing to strip.

### Reading the route table

The panel lists the site's **whole** route table in the order the web server evaluates
it, not just the routes you added. **The first match wins**, so the order is what decides
which route answers a request — hiding the rest would make a shadowed route look like a
bug. CorePanel keeps proxy routes ordered most-specific-first, so a catch-all never
swallows a more specific route.

Rows you cannot edit are shown for context:

- **Automatic** — maintained by CorePanel. The most common one is the docroot fallback:
  as soon as a site has an explicit route table, something has to keep serving the files,
  and that entry is it. It appears with your first proxy route and disappears with your
  last one.
- **Manual** — a route written by hand in the site's configuration file. CorePanel
  displays it and leaves it alone.

## Doing it from the command line

The same operations, from `corepanel`:

```bash
# See the whole table, in evaluation order
corepanel domain proxy list app.example.com

# ...and dial each target while you are at it
corepanel domain proxy list app.example.com --check

# Publish a service under /api, which the service sees as /
corepanel domain proxy add app.example.com '/api/*' 127.0.0.1:8080 --strip-prefix

# Put the whole domain in front of an app
corepanel domain proxy add app.example.com '/*' 127.0.0.1:3000

# Back to serving the docroot
corepanel domain proxy remove app.example.com '/api/*'
```

`add` probes the upstream right after applying the route, so a typo in the port shows up
as a warning instead of as a 502 in someone's browser. Quote the path: your shell would
otherwise expand the `*`.

Full flag reference in the [CLI documentation](https://www.corepanel.net/docs/cli#reverse-proxy-routes).

## Why administrators only

Reverse-proxy routes are restricted to super-administrators, and that is a security
boundary rather than a policy about who is trusted with the UI.

Whoever creates a route chooses the upstream. Pointed at a service running on the server,
a route publishes it on the public internet under the domain's name — including services
that are only safe because they listen on loopback. Handing this to an account would mean
handing it the ability to expose the panel's own API under its hostname.

CorePanel filters the obvious cases on top of that:

- The web server's own ports (**80** and **443**) on loopback are refused outright: a site
  proxying to its own server is an endless request loop.
- Well-known local service ports (the CorePanel API, MySQL, PostgreSQL, Redis, Memcached,
  SSH, SMTP) require an **explicit confirmation**, because the overwhelmingly likely
  explanation for `127.0.0.1:3306` in that field is a mistake.

Neither filter applies to remote hosts: proxying to `10.0.0.20:3306` is your network's
business.

## When something does not work

A proxied path can only fail in a few ways, and they are easy to tell apart:

| Symptom | Where to look |
|---------|---------------|
| **502** on the proxied path, docroot fine elsewhere | Your service is not listening. Check the route's status indicator in the panel, or `corepanel domain proxy list <domain> --check`. |
| Your service **will not start** | `systemctl status <unit>` and `journalctl -u <unit>`. A `203/EXEC` means systemd could not execute the binary — see the SELinux note above. |
| The service **works from the shell but not under systemd** | Almost always SELinux or a `ProtectHome`/`ProtectSystem` setting in the unit. |
| The upstream answers, but with the wrong paths | The prefix strip. `/api/orders` reaches your service as `/orders` with it on, `/api/orders` with it off. |

For anything SELinux-shaped, the audit log is `/var/log/audit/audit.log`:

```bash
ausearch -m AVC -ts recent
grep denied /var/log/audit/audit.log | tail    # when ausearch comes back empty
```

Run both. `ausearch` filters by time and event type, and it will occasionally report
nothing for a denial that is sitting in the log file — so a plain `grep` on the raw log is
the check that settles it.

> **An empty audit log does not mean SELinux is innocent**
>
> Some denials are marked *dontaudit* and are never written to the log — the operation
> just fails with `Permission denied` and nothing is recorded. If SELinux is your suspect
> and `ausearch` comes back empty, turn those rules off, reproduce the failure, and turn
> them back on:
>
> ```bash
> semodule -DB          # rebuild the policy without dontaudit rules
> # reproduce the failure, then: ausearch -m AVC -ts recent
> semodule -B           # restore
> ```
>
> The other quick test is `setenforce 0`, reproduce, `setenforce 1`. If it works in
> permissive mode, it is SELinux — never leave a production server that way.
> **A proxied path ignores the site's .htaccess**
>
> The route wins over any `.htaccess` in the document root, including a WordPress or
> Laravel front controller that rewrites everything to `index.php`. That is what makes it
> possible to put a service under `/api` of a CMS site at all — see
> [.htaccess](https://www.corepanel.net/docs/web/htaccess#proxied-paths-are-outside-its-reach).
>
> The other half of it: a `Require all denied` or a password prompt written in that
> `.htaccess` does **not** protect the proxied service. Authentication for a proxied path
> belongs in the service behind it.
## Limits and what comes next

- A route survives web-mode changes, optimization changes and certificate renewals. It
  does **not** survive deleting and recreating the domain.
- Up to 50 proxy routes per site.
- **Removing a route never touches the service behind it.** The path simply goes back to
  being served from the document root.

Proxy routes are the exposure half of running an application on a CorePanel server. The
other half — CorePanel creating the systemd unit, keeping the process alive, applying
resource limits, showing you its logs and deploying new versions — is the **application
runtime**, and it is available: see [Applications](https://www.corepanel.net/docs/applications/). Use a proxy route
for a service you want to supervise yourself, and an application for one you would rather
CorePanel supervised.

### Upstreams on a unix socket

You may also see an upstream that is not a `host:port` at all:

```
unix:/run/corepanel/apps/<account>/<app>.sock
```

That is how the application runtime publishes an application, and it is worth knowing why
before you meet one. A port on `127.0.0.1` is reachable by **every** process on the server,
another customer's account included — so on a shared machine one account's traffic could
reach another's backend behind the web server, the WAF, and whatever authentication you put
in front. A socket file cannot be reached that way: it is owned by the account, its group is
the web server's, its mode is `0660`, and the directory above it is closed to everyone else.
The permissions are the isolation, and you can check them with `ls`.

Only that exact layout is accepted — one account directory, one `.sock` file. No other
socket on the server can be named, so the route editor can never be pointed at a system
service. In practice CorePanel writes these routes itself; you will see them in the table
rather than type them.

Your own service does not have to speak unix sockets to benefit from this: the runtime puts
a small forwarder in front of it, so the application keeps listening on whatever port it
already listens on, inside a network namespace where nothing else can reach it.
