← Tutorials
CorePanel intermediate · ~35 min ·

Deploy a Go API and a React frontend on one domain with CorePanel

Publish a React app and its Go API under one hostname, with MySQL, HTTPS, automatic rollback and your domain's email accounts, all managed from the CorePanel panel.

Digital art of a futuristic city at dusk: a crystalline violet and magenta tower rises from a heavy engine block with glowing cyan seams, joined by a bright stream of light and enclosed together in a golden protective dome

This tutorial puts a small web application on your server the way most modern ones are built: a React frontend and a Go API behind it, with a MySQL database. Both live under one hostname:

https://app.example.com/ → the React build (static files)
https://app.example.com/notes/42 → the React build too: client-side routing
https://app.example.com/api/notes → the Go program

The example is a notes app, small enough to read in one sitting. Everything that matters for a real project is here: the API reads its port and its database from the environment, the frontend calls it on a relative path, and a broken release never takes the site down.

You do it all from the panel. CorePanel writes the systemd unit, runs the program as the account’s user in its own network namespace, puts HTTPS and the WAF in front of it, and rolls a release back on its own if it does not answer. After each screen you also get the corepanel command that does the same, for when you would rather script it.

You need:

Let the account run applications

Applications are off for every account until you turn them on. Open Accounts → example → Overview → Edit account and set Applications to 1.

Here 0 means none, not unlimited, unlike every other limit in CorePanel. Until you raise it, Add application stays disabled and says why.

The Edit account dialog for example.com, its description noting that 0 means unlimited except for Applications, where it means none; below the hosting package, PHP version and the other limits, the Applications field is focused and set to 1, with the hint In use: 0 and 0 = none

Or, from the command line, as root on the server:

Terminal window
corepanel account update example --max-apps 1

Create the subdomain in SPA mode

In the account, open Subdomains → Add subdomain: label app, parent domain example.com, and Web mode: SPA.

SPA mode decides what happens to a path that is not a file. In the default mode, reloading https://app.example.com/notes/42 would return 404, because there is no notes/42 file. In SPA mode it returns index.html, and React’s router takes it from there.

The Add subdomain dialog with the label app, the parent domain example.com, the preview Will create app.example.com, Web mode set to SPA, the Serve from a local service switch off, and the Add subdomain button

Or, from the command line, as root on the server:

Terminal window
corepanel subdomain add example app example.com --web-mode spa

The subdomain gets its document root at ~/domains/app.example.com and is covered by the domain’s certificate, so HTTPS works from the first request.

Write the API in Go

On your computer, make a folder for the project and one for the API:

Terminal window
mkdir -p ~/notes/api
cd ~/notes/api
go mod init notes
go get github.com/go-sql-driver/mysql@v1.8.1

Then create main.go in that folder:

main.go
package main
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"os"
_ "github.com/go-sql-driver/mysql"
)
type note struct {
ID int64 `json:"id"`
Text string `json:"text"`
}
func main() {
// CorePanel writes the connection here when the app is given a database.
db, err := sql.Open("mysql", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS notes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
text VARCHAR(500) NOT NULL)`); err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
// Asked after every deploy: a release that cannot reach its database is not serving.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := db.PingContext(r.Context()); err != nil {
http.Error(w, "database unreachable", http.StatusServiceUnavailable)
return
}
w.Write([]byte("ok\n"))
})
mux.HandleFunc("GET /notes", func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(), "SELECT id, text FROM notes ORDER BY id DESC")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
notes := []note{}
for rows.Next() {
var n note
if err := rows.Scan(&n.ID, &n.Text); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
notes = append(notes, n)
}
json.NewEncoder(w).Encode(notes)
})
mux.HandleFunc("GET /notes/{id}", func(w http.ResponseWriter, r *http.Request) {
var n note
err := db.QueryRowContext(r.Context(), "SELECT id, text FROM notes WHERE id = ?",
r.PathValue("id")).Scan(&n.ID, &n.Text)
if err == sql.ErrNoRows {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(n)
})
mux.HandleFunc("POST /notes", func(w http.ResponseWriter, r *http.Request) {
var n note
if err := json.NewDecoder(r.Body).Decode(&n); err != nil || n.Text == "" {
http.Error(w, "send {\"text\": \"...\"}", http.StatusBadRequest)
return
}
res, err := db.ExecContext(r.Context(), "INSERT INTO notes (text) VALUES (?)", n.Text)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
n.ID, _ = res.LastInsertId()
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(n)
})
port := os.Getenv("PORT")
log.Printf("listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, mux))
}

Three things in it are what CorePanel expects from any application:

  • It listens on $PORT. CorePanel sets it to the internal port you choose in the panel. The port only exists inside the application’s own network namespace: nothing else on the server can reach it, and the web server talks to the program through a unix socket.
  • It reads its database from DATABASE_URL. You will not write that value anywhere. CorePanel creates the database and puts the connection in the environment.
  • It has a health path. After every deploy CorePanel requests /healthz, and only a release that answers 200 gets published.

Look at the routes: /notes, not /api/notes. The application will be mounted at /api, and CorePanel strips that prefix before the request reaches it. A visitor’s /api/notes/42 arrives as /notes/42, so the same binary works at any mount path.

Build it for the server and pack it:

Terminal window
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o server .
tar czf api.tar.gz server

CGO_ENABLED=0 makes a static binary that runs on any RHEL-family release, whatever glibc your computer has. Use GOARCH=arm64 if the server is an ARM machine. The .tar.gz holds the built program: CorePanel does not compile anything, and today it runs Go binaries only.

Register the application

In the account, open Advanced → Applications → Add application and fill in:

FieldValue
Nameapi
Published onapp.example.com
Path/api
Start commandserver (the binary’s name, with no ./ in front)
Internal port8080
Health path/healthz

Leave the limits at their defaults: 256 MB of memory and half a core are plenty for this API. On Personal they are fixed.

The Add application form filled in for an application named api, published on app.example.com at the path /api, started with the command server on internal port 8080 with the health path /healthz, no linked directories, the default limits of 256 MB, 50 % CPU and 64 tasks, and outbound network set to Internet access

Or, from the command line, as root on the server:

Terminal window
corepanel app create example api --domain app.example.com --path /api --port 8080 --health /healthz -- server

The application now exists but serves nothing yet: its state is Not deployed, and Start and Restart are greyed out until there is a release. That is on purpose. Publishing /api before something answers there would only return 502.

The application right after creation: state "Not deployed", no active release, no last deploy, with Start and Restart unavailable

Or, from the command line, as root on the server:

Terminal window
corepanel app show example api

Give it a database

On the application’s page, in the Database card, press Add a database, keep Create a new one, name it notes and press Add and restart. CorePanel creates the schema example_notes, a MySQL user for the application only, and its password.

The Add a database dialog with Create a new one selected, the name notes, the preview The database will be called example_notes, and the Add and restart button

Or, from the command line, as root on the server:

Terminal window
corepanel app database create example api --name notes

The Environment card below now lists the connection, each variable with a Managed badge:

The Database card of an application attached to its schema, with a Detach button; below it the Environment table, where CP_DB_NAME, CP_DB_PASSWORD, CP_DB_HOST and CP_DB_SOCKET each carry a Managed badge and a read-only value, CP_DB_PASSWORD still masked behind an eye button and CP_DB_HOST shown empty

Or, from the command line, as root on the server:

Terminal window
corepanel app env list example api

DATABASE_URL is already in the format the Go driver reads: example_api:…@unix(/var/lib/mysql/mysql.sock)/example_notes. That is a unix socket, not 127.0.0.1:3306. The application runs in a private network namespace with no route to the server’s loopback, so the socket is how it reaches MariaDB, and CP_DB_HOST is empty on purpose. If you build the connection yourself from the CP_DB_* variables, use CP_DB_SOCKET.

Deploy the API

On the application’s page, press Deploy, keep Upload an archive, drop api.tar.gz on the dialog and press Upload and deploy.

The Deploy dialog on its Upload an archive tab, next to Already on the server: api.tar.gz (4.2 MB) chosen in the drop zone, and the Upload and deploy button

Or, from the command line, upload the archive over FTPS as the account and deploy it as root on the server. A hosting account has no shell unless you grant it one (see below), so the upload goes over FTPS or SFTP:

Terminal window
curl --ssl-reqd --ftp-create-dirs -T api.tar.gz --user 'example:<ftp password>' ftp://example.com/apps/api.tar.gz
corepanel app deploy example api

The upload goes in pieces with a progress bar, and the deploy starts on its own when the last piece lands. CorePanel extracts the archive as a new release, starts it, requests /healthz, and only then publishes /api on the subdomain. The dialog ends with Deploy complete and the name of the release now serving.

If the release does not answer within 45 seconds (a typo in the start command, a crash at startup, a database it cannot reach), the dialog says so instead, with the end of the program’s log, and the previous release keeps serving:

The result dialog of a rolled-back deploy, naming the release that did not answer, the one serving again, and the tail of the journal explaining why

Or, from the command line, as root on the server:

Terminal window
corepanel app releases example api
corepanel app logs example api -n 50

That is the safety net, not an error: visitors never saw the broken release. On the very first deploy there is no previous release, so the application simply stays unpublished until a release answers.

Open https://app.example.com/api/healthz in your browser. It answers ok, through the web server, with HTTPS and the WAF in front of the program.

Build the React frontend

Back on your computer, next to the API, make a folder for the frontend. The four files below are a complete Vite app: a list of notes, a form, and a page per note.

Terminal window
mkdir -p ~/notes/web/src
cd ~/notes/web

package.json pins React and Vite:

package.json
{
"name": "notes-web",
"private": true,
"type": "module",
"scripts": { "dev": "vite", "build": "vite build" },
"dependencies": { "react": "19.1.0", "react-dom": "19.1.0" },
"devDependencies": { "vite": "6.3.5", "@vitejs/plugin-react": "4.5.2" }
}

vite.config.js sends /api to the Go program while you develop:

vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
// In development, do what CorePanel does in production: send /api to the Go
// program and strip the prefix.
server: {
proxy: {
'/api': { target: 'http://localhost:8080', rewrite: (p) => p.replace(/^\/api/, '') },
},
},
});

index.html is the page React mounts on:

index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Notes</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

And src/main.jsx is the app:

src/main.jsx
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
// A two-page router in ten lines: enough to show why the server needs SPA mode.
function usePath() {
const [path, setPath] = useState(location.pathname);
useEffect(() => {
const onPop = () => setPath(location.pathname);
addEventListener('popstate', onPop);
return () => removeEventListener('popstate', onPop);
}, []);
const go = (to) => { history.pushState(null, '', to); setPath(to); };
return [path, go];
}
function Link({ to, go, children }) {
return <a href={to} onClick={(e) => { e.preventDefault(); go(to); }}>{children}</a>;
}
function NoteList({ go }) {
const [notes, setNotes] = useState([]);
const [text, setText] = useState('');
const load = () => fetch('/api/notes').then((r) => r.json()).then(setNotes);
useEffect(() => { load(); }, []);
async function add(e) {
e.preventDefault();
await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
setText('');
load();
}
return (
<>
<form onSubmit={add}>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="Write a note" required />
<button>Add</button>
</form>
<ul>
{notes.map((n) => <li key={n.id}><Link to={`/notes/${n.id}`} go={go}>{n.text}</Link></li>)}
</ul>
</>
);
}
function Note({ id, go }) {
const [note, setNote] = useState();
useEffect(() => {
fetch(`/api/notes/${id}`).then((r) => (r.ok ? r.json() : null)).then(setNote);
}, [id]);
return (
<>
<p><Link to="/" go={go}>← All notes</Link></p>
<p>{note === undefined ? 'Loading…' : note ? note.text : 'Not found.'}</p>
</>
);
}
function App() {
const [path, go] = usePath();
const match = path.match(/^\/notes\/(\d+)$/);
return (
<main>
<h1>Notes</h1>
{match ? <Note id={match[1]} go={go} /> : <NoteList go={go} />}
</main>
);
}
createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMode>);

The frontend calls the API on relative paths, /api/notes. The page and the API share one origin, so there is no CORS to configure and no API address to change between development and production. The proxy in vite.config.js gives npm run dev the same layout on your computer.

Build it, and zip the contents of dist/:

Terminal window
npm install
npm run build
cd dist
zip -qr ../web.zip .
cd ..

Upload the frontend with the File Manager

In the account, open File manager and pick app.example.com under Places. That is the subdomain’s document root, ~/domains/app.example.com. Then:

  1. Upload ▾ → Upload files… and choose web.zip.
  2. Select it and press Extract. Under Where the contents go, choose Here, so index.html and assets/ land in the document root itself rather than in a web folder.
  3. Move web.zip to the trash.

The Extract dialog of the file manager: how many files and folders the archive holds and how much they weigh, its top entries, and the choice between extracting into a new folder or here

Or, from the command line, the same upload over FTPS from your computer:

Terminal window
lftp -u 'example,<ftp password>' -e 'set ftp:ssl-force true; mirror -R dist domains/app.example.com; bye' example.com

Uploads and extractions run as the account, exactly as an FTP upload would, so every file belongs to example. To publish a new version of the frontend later, do the same and choose Replace when it asks about names that are already there.

Check the whole thing

Open https://app.example.com, add a note such as Hello from Go, click it to open its page, and reload that page. It comes back, because SPA mode served index.html and React read the path.

If you prefer to check from a terminal on your computer, the same round trip is:

Terminal window
curl -fsS -X POST -H 'Content-Type: application/json' -d '{"text":"Hello from Go"}' https://app.example.com/api/notes
curl -fsS https://app.example.com/api/notes | grep -F '"Hello from Go"'
curl -fsS https://app.example.com/notes/1 | grep -F '<div id="root">'

In the panel, the account’s Applications list shows api as Running, with the socket it serves on and the release that is active. Open it for the live systemd status (it refreshes itself), the releases you can roll back to, and Logs, the journal that carries everything the program writes to stdout and stderr.

The Applications section of an account: one running application and one failed, with their state, publication target, socket and active release

Or, from the command line, as root on the server:

Terminal window
corepanel app list example

Optional: deploy the API on every push

When the project lives in a Git repository, the API can deploy itself. On the application’s page, in the Deploy webhook card, press Enable. You get a token, shown masked until you reveal it, and an upload URL. Store the token as a secret in your CI, COREPANEL_DEPLOY_TOKEN, and add a job like this one to GitHub Actions:

.github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
jobs:
api:
runs-on: ubuntu-latest
defaults:
run:
working-directory: api
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: api/go.mod
- run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o server .
- run: tar czf api.tar.gz server
- run: >
curl -fsS -X POST https://panel.example.com/api/apps/upload
-H "Authorization: Bearer ${{ secrets.COREPANEL_DEPLOY_TOKEN }}"
--data-binary @api.tar.gz

Copy the exact upload URL from the card: it uses your panel’s hostname. The call answers 202 as soon as the deploy starts, and the same health check and automatic rollback apply. The result shows up on the application’s page. The token can deploy that one application and nothing else, and Rotate token replaces it.

The webhook covers the API. The frontend has no deploy endpoint: from CI it goes up over FTPS, for example with lftp -e "mirror -R dist domains/app.example.com".

When something does not work

What you seeWhy
Reloading /notes/1 gives 404The subdomain is not in SPA mode. Change it in the subdomain’s settings.
/api/... gives 404 from the Go programThe routes were written with /api in them. The prefix is stripped: route /notes, not /api/notes.
The deploy dialog says the release was rolled back/healthz did not answer 200 in time. The log at the bottom of the dialog says why, and it stays on the application as Last failure.
The log says connection refused to the databaseThe program connected over TCP. Use DATABASE_URL as it is, or CP_DB_SOCKET.
path must be in canonical form when saving the formThe start command begins with ./. Write server.
fork/exec … permission denied in the logThe archive holds a script, not a compiled Go binary. Only Go binaries run today.
Add application is disabledThe account’s Applications limit is still 0.

Your email lives in the same panel

A web application rarely comes alone: the project also needs info@example.com, a support@ address, password-reset emails. On CorePanel that is the same panel and the same account, not a second provider to pay for and keep in sync:

  • Mailboxes and webmail for example.com are created under the account’s Mail section, with their quotas, aliases and forwarders. The WordPress tutorial walks through it.
  • SPF, DKIM and DMARC are published for the domain when CorePanel runs its DNS, and incoming mail goes through the server’s spam filter.
  • The application can send mail with no SMTP setup. It gets /usr/sbin/sendmail, as PHP sites do, so a password-reset email from the Go program leaves the server as the account. No credentials to store, and the server’s log says which application sent it. See Sending mail.

One login covers the program, its database, its domain, its certificates and every email account behind it.

What you did not have to do

You did not write a systemd unit or an nginx block, open a port, configure TLS, create a MySQL user, or write a rollback script. CorePanel did all of it for the account, and the program runs as example, in a namespace of its own, under memory and CPU limits it cannot exceed.

The Applications documentation covers the rest: persistent data in $CP_APP_DATA, secrets in the environment, resource limits, and the other ways to publish an application, such as one that owns a whole hostname.