# Deploying from CI/CD

> Give a build pipeline a token that deploys one CorePanel application and nothing else: upload-and-deploy in one call, GitHub Actions, GitLab CI, signatures, and what each status code means.

Source: https://www.corepanel.net/docs/applications/ci-cd/
Last updated: 2026-08-01
Part of the CorePanel documentation — https://www.corepanel.net/docs

---

A build pipeline can deploy an application without a person in the loop. A token names
**one** application and authorises **one** thing: deploying it.

There are two endpoints, and the choice between them is about where the artifact is:

| | [Upload and deploy](#upload-and-deploy-in-one-call) | [Deploy what is already there](#deploy-an-artifact-you-uploaded-yourself) |
|---|---|---|
| The pipeline sends | The `.tar.gz` itself | An empty POST |
| Credentials in CI | The deploy token | The deploy token **and** the account's FTP password |
| Where the token goes | An `Authorization` header | The URL |
| Artifact size | Bounded by the web server (64 MB by default) | Unbounded |
| Where you get it | The panel or the CLI | The CLI only |
| Good for | Almost every pipeline | Large artifacts, and callers that can only be given a URL |

Start with the first one. Reach for the second when the artifact is too large, or when
whatever is calling — a GitHub or GitLab *webhook*, say — can configure a URL and nothing
else.

## Enabling it

An application is created **without** a token — a credential nobody asked for is a
credential nobody rotates. Issue one when you need it.

In the panel, that is the **Deploy webhook** card on the application: press **Enable** and
the token is issued, next to the address your pipeline posts to.

![The Deploy webhook card with a token issued: the token masked behind an eye button, the upload URL in clear, and the notes on how to call it](https://www.corepanel.net/_astro/apps-webhook-dark.oujvto9e.png)

The two values are treated differently on purpose. The **token** is a credential, so it
arrives masked and the copy button appears only once you reveal it — a copy button next to
a mask either copies the mask, which is useless, or hands over a secret you believed was
hidden. The **upload URL** carries no token at all, so it is shown in clear and is always
copyable.

What the card deliberately does **not** show is the deploy URL that carries the token in
its path. That endpoint still exists — see [Deploy an artifact you uploaded
yourself](#deploy-an-artifact-you-uploaded-yourself) — but the web server writes every
request URL to its access log, so a panel that offered it would put a live credential on
disk by default. Ask for it explicitly, with `corepanel app webhook show`, if something in
your pipeline can configure a URL and nothing else.

From then on the card offers **Rotate token**, which issues a new one and breaks any
pipeline still using the old one, and **Disable**, which clears it and refuses every
pipeline call from that moment. Both ask for confirmation; **Enable** does not, because
there is nothing yet to break.

The same, from `corepanel`:

```bash
corepanel app webhook rotate <account-id> api
```

```
Application:   api (account example)
Webhook:       enabled
Token issued:  2026-07-27 14:22:10
Token:         cpd_9f3c…
Deploy URL:    https://panel.example.com/api/apps/deploy/cpd_9f3c…
Upload URL:    https://panel.example.com/api/apps/upload

From a pipeline, uploading and deploying in one call:
  curl -fsS -X POST https://panel.example.com/api/apps/upload \
       -H 'Authorization: Bearer cpd_9f3c…' \
       --data-binary @api.tar.gz

Or, to deploy an artifact already uploaded to ~/apps/api.tar.gz:
  curl -fsS -X POST https://panel.example.com/api/apps/deploy/cpd_9f3c…
```

| Command | What it does |
|---|---|
| `corepanel app webhook show <account-id> api` | Prints it again — the token is stored, not shown once |
| `corepanel app webhook rotate <account-id> api` | Issues a new token; the previous one stops working immediately |
| `corepanel app webhook revoke <account-id> api` | Clears it; every pipeline call is refused from then on |

Rotate when you want the pipeline to keep working once you update it. Revoke when you want
it to stop now. Both endpoints use the same token, so both stop together.

## Upload and deploy in one call

The artifact is the request body and the deploy runs on the same call:

```bash
curl -fsS -X POST https://panel.example.com/api/apps/upload \
     -H 'Authorization: Bearer cpd_9f3c…' \
     -H 'Content-Type: application/gzip' \
     --data-binary @api.tar.gz
```

```yaml
# GitHub Actions
name: deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.25' }

      - name: Build
        run: |
          CGO_ENABLED=0 go build -o server .
          tar -czf api.tar.gz server templates static

      - name: Deploy
        run: |
          curl -fsS -X POST https://panel.example.com/api/apps/upload \
               -H "Authorization: Bearer ${{ secrets.COREPANEL_DEPLOY_TOKEN }}" \
               --data-binary @api.tar.gz
```

```yaml
# GitLab CI
deploy:
  stage: deploy
  only: [main]
  script:
    - tar -czf api.tar.gz server templates static
    - curl -fsS -X POST https://panel.example.com/api/apps/upload
        -H "Authorization: Bearer $COREPANEL_DEPLOY_TOKEN"
        --data-binary @api.tar.gz
```

Store the token as a **masked secret**. It is the whole credential, and it is the only one
this form needs: no FTP account in CI.

> **Keep the token out of the URL where you can**
>
> The web server writes the full request URL of every request to the panel's access log. A
> token in the address is therefore on disk after every deploy, and in any proxy in between.
> A token in a header is not.
>
> The deploy endpoint accepts a header too — `POST /api/apps/deploy` with
> `Authorization: Bearer …`, no token in the path. Use the URL form only where the caller
> cannot send headers, which is why the panel no longer offers that address at all and the
> CLI is where you ask for it.
### Where the artifact goes

Straight into a spool CorePanel owns, never into the account's home, and **it is deleted
as soon as it has been extracted**. Uploading a new version does not pile up beside the
old one, because there is no old one to pile up beside. What accumulates is
[releases](https://www.corepanel.net/docs/applications/deploying/#releases-and-rollback), which are pruned.

An upload that is refused — a bad signature, an unknown token, a body that is not an
archive — is discarded the same way.

### Size

The body is bounded by the web server in front, **64 MB by default**
(`MaxPostSizeMB` in CoreHttpd's configuration, `/etc/corehttpd.ini`). Over that you get a `413` naming the
alternative. A built Go binary with assets is usually well under it; a container-sized
artifact is not.

## Deploy an artifact you uploaded yourself

The other form deploys whatever is at `~/apps/<name>.tar.gz`, which the pipeline puts
there first. Its address is the one with the token in the path, so it comes from
`corepanel app webhook show <account-id> api` — the panel does not display it:

```yaml
# GitHub Actions
      # Hosting accounts have no shell, so the channel is FTP over TLS, not scp.
      - name: Upload the artifact
        run: |
          curl --ssl-reqd --ftp-create-dirs -T api.tar.gz \
               --user "${{ secrets.FTP_USER }}:${{ secrets.FTP_PASSWORD }}" \
               ftp://example.com/apps/api.tar.gz

      - name: Deploy
        run: curl -fsS -X POST "${{ secrets.COREPANEL_DEPLOY_URL }}"
```

Store the URL as a **masked secret**, not in the repository: it is the whole credential.

The upload uses the account's own **FTP credentials**, which are a separate secret: the
deploy token cannot upload anything, and the FTP account cannot trigger a deploy. Losing
one does not give away the other.

> **No SSH, so no scp**
>
> Hosting accounts are created with `nologin` — they have no shell, and `scp`, `sftp` and
> `ssh` are unavailable to them. CorePanel's file transfer channel is **FTP over TLS**
> (`--ssl-reqd`); a plaintext session is refused. The session is chrooted to the account's
> home, which is why the path is `apps/api.tar.gz` and not an absolute one.
>
> If your CI runner cannot reach the FTP passive port range (42000–50000), use `lftp` with
> `set ftp:passive-mode true`, or open that range for the runner's addresses.
Whichever form you use, what runs is the **same deploy** as the button in the panel,
automatic rollback included.

## Two properties that decide how you write the pipeline

### It answers before the deploy finishes

Both endpoints return **`202 Accepted` as soon as the deploy starts** — for the upload,
once the artifact has been received in full. That is not a shortcut: CI providers abandon
a webhook delivery after about ten seconds and then retry it, and a retry mid-deploy would
deploy the same artifact twice.

> **A green pipeline is not a deployed release**
>
> `curl` succeeding means the deploy **started**. The outcome — including a release that
> failed its health check and was rolled back — lands on the application, not on the HTTP
> response. Read it with `corepanel app show <account-id> api`, or on the application in the
> panel, where the state badge, the active release and **Last failure** report a pipeline
> deploy exactly as they do a manual one.
If your pipeline must gate on the result, have it check the application afterwards —
`corepanel app show` from a machine with administrator access to the server, or a request
to your own health path once the release should be live. The hosting account cannot do
this itself: `corepanel` is an administrator tool, and the account has no shell.

### One deploy at a time

A second call while one is running is refused with `409`, not queued. Two concurrent
deploys would race over the same release tree and the same `current` symlink, and the
loser would leave a half-extracted directory behind.

A pipeline that pushes twice in quick succession should treat `409` as "the newer build
will go out on the next push", or retry after a short wait.

On the upload endpoint the lock is taken **after** the artifact has arrived, not before,
so a slow upload never blocks anyone. The cost is that a `409` there comes at the end: the
bytes were sent and then discarded. That is the right way round — the alternative is
holding an application's deploy lock for the length of a transfer that may never finish.

## Signatures

If your pipeline signs the request the way GitHub does, CorePanel verifies it. Use the
**same token** as the secret — one credential to rotate rather than two to keep in
agreement:

```
X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body, keyed with the token>
```

- A request with **no** signature is authenticated by the token alone. That is the normal
  case, and it is what a plain `curl` sends.
- A request **with** a signature must verify. A caller that signs and gets it wrong is
  either misconfigured or forging, and falling back to the weaker check is how signature
  verification becomes decorative.

On the upload endpoint the signature covers the artifact itself, which makes it worth
more than it is on the other: it proves the bytes that become the release are the bytes
your pipeline built.

```bash
SIG=$(openssl dgst -sha256 -hmac "$COREPANEL_DEPLOY_TOKEN" -hex api.tar.gz | awk '{print $2}')
curl -fsS -X POST https://panel.example.com/api/apps/upload \
     -H "Authorization: Bearer $COREPANEL_DEPLOY_TOKEN" \
     -H "X-Hub-Signature-256: sha256=$SIG" \
     --data-binary @api.tar.gz
```

## Status codes

| Code | Meaning | What to do |
|---|---|---|
| `202` | The deploy started | Read the outcome in the panel or with `app show` |
| `400` | The body is not a `.tar.gz` (upload only) | Check the artifact actually got built and attached |
| `401` | No token was sent, or a signature does not verify | Check the header, that the signing secret is the deploy token, and that nothing rewrites the body |
| `404` | The token matches no application | It was rotated, revoked, or truncated when copied |
| `409` | A deploy is already running | Wait; do not retry immediately |
| `413` | The body is over the limit | Upload: the artifact is larger than the web server accepts — use the FTP route. Deploy: the payload is over 1 MB and is only used for the signature |
| `429` | Too many calls | Honour `Retry-After`; the limit is per token and per source address |
| `500` | CorePanel could not start the deploy | Check the service logs on the server |

The response body is JSON, so a CI log shows a readable reason on failure.

## Security notes

- The token grants **deploying one application**. It cannot read logs, change settings,
  reach other applications, or touch the account in any other way.
- Rotating invalidates the old token immediately, and it invalidates the signature key at
  the same time.
- The token never appears in CorePanel's own logs, including on failed attempts. It does
  appear in the **web server's access log** when it travels in the URL, which is the
  reason to prefer the header form.
- Calls are rate-limited per token and per source address — the two endpoints share one
  budget — so a leaked token cannot be used to hammer the machine into rebuilding
  continuously.
- An uploaded artifact is written to a directory CorePanel owns, readable by nobody else,
  and removed as soon as it has been extracted. It never lands in the account's home.
- The URL is issued for the panel's hostname over HTTPS. Do not put the token in a query
  string, an issue comment, or a build log.
