Docs · preview build
From a fresh server to a live, watched app.
This guide takes about fifteen minutes on a fresh server. By the end you'll have the dashboard on its own HTTPS hostname and one app deployed through the health gate.
Requirements
Dockerfile or a package.json, and for provisioned databases. A static site with nothing to build doesn't need it.Those figures are measured on our own server, not estimated. What sizes a server is what you deploy on it. A static site adds almost nothing. A Node app adds its own memory, and npm's cache, about 70 MB on our server. A provisioned MySQL uses about 450 MB of RAM while idle, and Docker builds need disk for their images.
A static site needs neither Node nor Docker, so it's the quickest
thing to try first. If your provider's image ships with
ufw enabled, open the ports now. A closed port 80 looks
like a healthy daemon that nothing can reach.
$ ufw allow 80/tcp && ufw allow 443/tcp
Install
Point two DNS records at the server first: one for your app
(example.com below) and one for the dashboard
(admin.example.com). Certificates are issued on first
start, and Let's Encrypt has to reach the server by those names.
1 Download and verify
$ curl -fLO https://basecove.name.ng/download/basecove-linux-amd64.tar.gz $ curl -fLO https://basecove.name.ng/download/SHA256SUMS $ sha256sum -c --ignore-missing SHA256SUMS
On an ARM server, replace amd64 with arm64.
Don't skip the checksum. A silently truncated download is a real
failure we have seen, and it looks like a crash loop.
2 Run the installer
$ tar xzf basecove-linux-amd64.tar.gz && cd basecove $ ./install.sh --binary ./basecove \ --host example.com --admin-host admin.example.com
The installer is idempotent. It:
- creates a
basecovesystem user, and adds it to thedockergroup if Docker is installed - installs the binary to
/usr/local/bin/basecove - writes the daemon's flags to
/etc/basecove/basecove.env, and leaves the file alone if it already exists - installs and starts the
basecovesystemd service, plus the units that make upgrades work
No domain yet?
Run ./install.sh --binary ./basecove --host YOUR_SERVER_IP
without --admin-host, then edit
/etc/basecove/basecove.env to use the commented
IP-only line. Apps are served over plain HTTP, and you reach the
dashboard through an SSH tunnel:
ssh -L 8081:127.0.0.1:8081 root@YOUR_SERVER_IP, then
open http://127.0.0.1:8081. The dashboard is never
served over plain HTTP on a public port, on purpose.
Sign in
The first admin is created on the server, so whoever has shell access becomes the administrator. You'll be asked for the password twice. It needs at least 12 characters, with no other composition rules.
$ sudo -u basecove basecove user add you -role admin -data /var/lib/basecove
Open https://admin.example.com and sign in. The first
certificate can take a minute: Let's Encrypt sometimes fails one
challenge type and succeeds on the automatic retry. If the browser
reports a TLS error right after install, wait sixty seconds before
you start debugging.
You can add more people from the dashboard's Users page later.
An admin can change things. A viewer can see
everything except secrets.
Your first app
The default configuration already registers one app, hello.
It's served on the hostname you passed as --host and built
from /srv/apps/hello. That directory doesn't exist yet, so
the first deploy failed. That's expected. Give it something to serve:
$ mkdir -p /srv/apps/hello $ echo '<h1>hello from basecove</h1>' > /srv/apps/hello/index.html $ systemctl restart basecove
A directory with an index.html and no
package.json or Dockerfile deploys as a
static site, served by the daemon itself. Open
https://example.com. In the dashboard, hello
shows as live with a green ●.
Don't want the seeded app?
The -src flag in /etc/basecove/basecove.env
re-seeds hello on every start. Change it to
-src= (with the equals sign and nothing after it) to
register apps only from the dashboard. Don't write
-src "": systemd passes the quotes through as the value.
Deploy your own app
- Put the code on the server under
/srv/apps/<name>, usingrsync,scporgit clone. Or skip this step and deploy from a repository. Keep sources out of/var/lib/basecove, which is where releases are built. - Add a DNS record for the app's hostname, pointing at the server.
- In the dashboard, choose Register a new app. Give it a name and a host. The source directory is suggested, and sits under Directories if you want to change it. The dialog checks the directory and tells you if the daemon can't use it.
- Choose Deploy new version and give it a label such as
v1. Leave Hold before routing on to watch the gate the first time.
An app in a subdirectory
When the Dockerfile, docker-compose.yml or package.json is in a folder such as backend/ rather than at the top, set the app's root directory to that folder, under Directories when registering or in the app's settings. Detection, the Docker build context, Compose and static_dir all start there. The whole source is still copied for each release, but nothing outside the root is part of the build.
The root is relative to the source (to the repository, for a git app) and must stay inside it. Changing it applies to the next deploy. A restart or a rollback keeps using the root that version was built from. If the folder isn't there, the check lists the folders that are, and when the top of the source has nothing to run it names the folders that look deployable.
What your app must do
build script, then started with its start script (or main / index.js), all in a container Basecove generates, so your packages' install scripts and code never run on the server itself. The Node version is your .nvmrc, .node-version or engines.node when it names one major version, else the server's own Node, else 24. The app runs as an unprivileged user and must listen on every address (HOST is set to 0.0.0.0 for frameworks that read it). Its settings reach the build without being stored in the image.dist, build, out or public, or from angular.json.- Serve HTTP where you say you do. A container is reached on the port its Dockerfile
EXPOSEs, andPORTis set to that same number. With noEXPOSE, it must listen on$PORT, which the daemon assigns. If it exposes several ports, the deploy uses whichever one answers HTTP. If more than one does, set Container port in the app's settings. Node apps always listen on$PORT. The daemon overrides anyPORTyou set yourself. - Answer the health path with a 2xx. The default path is
/. A redirect is followed, so a home page that redirects to a sign-in page that answers 200 passes. If your app has a dedicated health route, such as/health, set it under the app's Settings → Health check. An image with its ownHEALTHCHECKis checked with that instead, unless you set a health path,/included; see health checks. If the check doesn't pass, the deploy fails at the health check, correctly, your old version keeps serving, and the report says what the app answered and which port it listens on. - Keep files in a
VOLUME. Every deploy starts a new container, so files the app writes anywhere else, such as uploads or Laravel'sstorage/, are gone after the next deploy. Declare the directory in the Dockerfile, for exampleVOLUME /app/storage, and Basecove keeps it in a volume of its own,basecove-<app>.app.storage, through deploys, rollbacks and restarts. What the image has in that directory is copied in only the first time; later deploys don't replace it. During a deploy's switch-over the old and new versions both have it mounted for a few seconds, so a SQLite file there sees two writers briefly. - Keep a SQLite database where a deploy can't reset it. Laravel 11, Rails 8 and Django start on SQLite, and the file lives beside the code by default, which each deploy replaces: the app comes back healthy and empty. Before a deploy, Basecove warns when your code shows it uses SQLite (Laravel's
DB_CONNECTION, a Node SQLite driver, Prisma'ssqliteprovider, Rails' or Django's settings) with nowhere to keep it. The fix: under Settings → Databases, add a SQLite file. It lives in the app's data directory, which every deploy, rollback and restart keeps (/datain a container, with or without a Dockerfile), and is backed up daily. The app gets its path asDATABASE_PATH; point your framework at it from Environment, for LaravelDB_CONNECTION=sqliteandDB_DATABASE=${{main.PATH}}, for PrismaDATABASE_URL=file:${{main.PATH}}. Data the app has written since its last deploy exists only in the running version, which the next deploy replaces; if you need it, copy the file out on the server first (docker cp basecove-APP-N:/app/database/database.sqlite ., the container's name is indocker ps) and into/data/main.sqliteof the new one after. With your own Dockerfile you can also declare a volume yourself, but not over a directory the image also fills, such as Laravel'sdatabase/: a new volume gets the image's files once, so later migrations would never reach it. - Log to standard output or standard error. The app's log dock shows what the app prints, and nothing else. Errors written to a file inside the app, such as Laravel's default
storage/logs/laravel.log, never reach the dashboard; see the health check gets a 500. - A client-side router survives a refresh on a deep link: unknown paths on a static site fall back to
index.html.
Containers: the usual traps
An image that runs with docker run or docker compose up
should deploy as it is. These are the things that most often work on a
laptop and not on a server, whatever the language. Most are caught before
or during a deploy, and the report names the fix.
- Listen on every address, not on localhost
- A server bound to
127.0.0.1orlocalhostis reachable only from inside its own container. Bind0.0.0.0: for example--host 0.0.0.0,HOST=0.0.0.0, orapp.listen(port, '0.0.0.0'). A failed deploy's report says when this is the cause. - Say which port you serve on
EXPOSEthe port in the Dockerfile, or list it under the service'sexposeorportsin the Compose file. With neither, the app must listen on$PORT. The host side of a Composeportsmapping is ignored: Basecove routes traffic itself.- Health checks
-
Every deploy waits for a health check to pass before it receives traffic. Which check runs:
- A health path you set in Settings → Health check, including
/. It always wins. - Otherwise, automatic: the Compose service's
healthcheck, else the image'sHEALTHCHECK. The port traffic goes to must also answer. - With neither,
GET /must answer 2xx. A redirect to a page that answers 200 passes.
Base images bring their own checks. A
HEALTHCHECKis inherited from the image you buildFROM, and it was written for how that image runs by default. Change the command, the port or the tools in the final image, and the inherited check may never pass. It might probe an admin port your command switches off, or callcurlin a slim image that doesn't have it. When the check can't work in the container like this, Basecove checksGET /instead and the report warns you. A check that runs and fails is taken at its word, and the deploy doesn't go live.To fix the warning, give the image a check that matches how it runs, or switch the inherited one off:
HEALTHCHECK NONEin the Dockerfile, orhealthcheck: {disable: true}on the Compose service. Or set a health path. Use a tool the final image has:wgeton Alpine, or the language itself, such aspython -cornode -e. Workers and schedulers that run the web service's image have its check switched off for them. - A health path you set in Settings → Health check, including
- Keep files that must survive a deploy in a volume
- Each deploy starts a fresh container from the new image, so anything written inside it is gone. Uploads, SQLite files and caches you want kept go in a
VOLUMEin the Dockerfile or a named volume in the Compose file. A new volume gets the image's files once; later deploys don't replace them. - Log to standard output
- The log dock shows what the container prints. A framework that logs to a file inside the container shows nothing there. Point its logger at stdout or stderr; see the health check gets a 500.
- Configure through the environment, not a baked-in file
- Set variables in Settings → Environment. They reach the running container, not the build, so don't make the build depend on secrets. Keep
.envout of the image with.dockerignore: otherwise its secrets ship inside the image, and any value in it you didn't set in Basecove still applies, such as a development database. A changed variable applies on the next deploy. - Upload Compose secrets that can't be committed
- A Compose file's
secretsare mounted read-only at/run/secrets/<name>, for the app and its sidecars alike, soPOSTGRES_PASSWORD_FILE=/run/secrets/db_passwordworks as it does locally. A secret'sfile:is usually gitignored and so isn't on the server. A deploy takes each secret's value from the first of these that exists: a file uploaded in Settings → Secret files, the file committed to the repo, the variable itsenvironment:names, then the variable named after the secret in Settings → Environment,db_passwordfromDB_PASSWORD. The report before a deploy lists every secret with no value. A changed value applies on the next deploy, but a database that already has data keeps the password it was created with. - Secret files: a Firebase JSON, a .p12, a raw key
- For a file rather than a short value, admins use Settings → Secret files. It lists the Compose file's secrets, the ones with no value first, with the services that mount each one and where its value currently comes from. Choose a file, drop one, or paste the contents, with an option to decode base64 first. Files are sent byte for byte, up to 1 MB each, 10 MB and 64 files per app. Afterwards the page shows the file's size and the start of its SHA-256; compare it with
shasum -a 256 <file>on your machine. Nothing shows or downloads the contents again. The file is kept on the server outside the build, so it never ends up in the image, and is included in Basecove's state backup. The section appears only for apps with a Compose file. An app without one can't mount a file yet: put a short key in Environment instead, or a whole file as a base64 variable your app decodes at startup. - Reach databases by name, not localhost
- Inside a container,
localhostis that container. Point the app at an attached database with references such as${{main.HOST}}, or at a Compose service by its service name. See Databases. - Run migrations as a release step
- A Compose service that the web service waits on with
condition: service_completed_successfullyruns once, before each deploy. If it fails, nothing new starts and the old version keeps serving. Migrating in the web container's start command also works, but it runs again on every restart. A seed or migration service that nothing waits on is a background service instead: it's kept running, so it's started again each time it ends. The deploy report warns about one, and one that keeps finishing within 10 seconds of starting is restarted less and less often and reported as an incident. - Name the Compose file compose.yaml
- Basecove reads
compose.yamlordocker-compose.yml, the filesdocker composeuses without-f. Adocker-compose.prod.ymlisn't read, and an override file isn't merged in. - Trust the proxy's headers
- HTTPS ends at Basecove, and the app receives plain HTTP with
X-Forwarded-Proto: https. A framework that builds links from the request needs to trust those headers, or it writeshttp://links and the browser blocks them as mixed content. - Stop cleanly on SIGTERM
- An old version is stopped once it has drained. It gets
SIGTERM(or the image'sSTOPSIGNAL), then a grace period. Setstop_grace_periodon a Compose service whose jobs need longer. A shell-formCMDcan swallow the signal; the exec form,CMD ["…"], passes it to your app.
Deploys, the gate, rollback
Every deploy builds a new release next to the running one, starts it, and probes its health path. Traffic moves only once the probe passes. The old version then drains: it finishes the requests it already took before it stops. A failure at any stage before the switch leaves the old version untouched.
healthy and live are different states, shown in different colours. A healthy version has passed its health check but has no traffic. With Hold before routing on, the deploy stops there and waits for you to Route or Discard it. If you never decide, nothing changes. A push from GitHub can instead be decided by its tests: see Route when tests pass.
Roll back from the app's header picks an earlier release and redeploys it through the same health check. A rollback can't route traffic to a version that doesn't come up healthy.
Environment variables
Set them under the app's Settings → Environment. Saving doesn't deploy. New values apply on the next deploy, and the running version keeps the environment it started with. That stops a config edit from replacing production behind the gate's back.
Settings shows the first four variables. Show all lists the rest, and open in its own page ↗ opens the whole editor in its own tab.
To bring over a whole .env file, press Paste .env
and paste it in, or paste it straight into a name field. Comments,
blank lines, export and quotes are handled the way
dotenv handles them. A name already set takes the pasted value. Any
line that can't be used is listed with its line number before
anything changes. ${OTHER} is kept as written, not
expanded.
Your variables override the injected database credentials, so setting
DATABASE_URL yourself points the app at a database
Basecove doesn't manage. PORT is the one variable you
can't set.
Domains and extra hostnames
An app's domain and its extra hostnames are under Settings → domain. Unlike the rest of Settings, these apply at once, without a deploy. Point each name's DNS at the server first: the dialog warns when it doesn't, and Let's Encrypt limits failed certificate attempts.
change… moves the app to another domain. The live version answers there as soon as you confirm, and the old domain answers 404. Anything outside the server that names the old one, such as OAuth callback URLs and webhooks, needs changing there.
add a hostname… makes the app answer on another name too, up to
10. Each one either serves the app, which then sees the request
with that hostname (an admin panel on admin.example.com),
or redirects to the same page on the app's domain (www
to the bare domain). Switching between the two never answers 404 in
between. Every deploy, rollback, pause and maintenance window covers
all of them together, so no hostname is ever left on an older
version. A new hostname gives a connection error for a few seconds
while its certificate is issued. A clone gets none of the source's
extra hostnames: a hostname belongs to one app.
$ curl -X POST -H "Authorization: Bearer $BC_TOKEN" \
'https://admin.example.com/api/apps/shop/hosts?host=www.shop.example.com&redirect=1'
From a git repository
In Register a new app, set Source to Git URL for
a public repository, or to GitHub for a private one (see
below). The source directory becomes the location of the checkout,
so it must be empty or missing, and writable by the
basecove user:
$ install -d -o basecove -g basecove /srv/apps/blog
Each deploy fetches, resets the checkout to the branch or commit you choose, and builds. A Git URL is cloned without credentials, so it only works for a public HTTPS clone URL.
Private repositories and deploy on push
Private repositories go through a GitHub App that Basecove creates
on your GitHub account. Basecove never stores a personal access
token. The App is free and gets read-only access to the repositories
you pick. It needs --admin-host, because GitHub has to
reach the daemon over HTTPS.
- Open the dashboard at its admin host, and choose GitHub from the account menu.
- Choose the account or organization that owns your repositories, then Create on GitHub. GitHub shows the App with everything filled in. Confirm it.
- GitHub asks which repositories the App may read. Pick them, and you land back in the dashboard with Register a new app open on those repositories.
Nothing is copied to the server and nothing restarts. Check connection on the same page tests the App and the webhook, and names the fix for anything wrong.
Setting up the App by hand
If you'd rather create the App yourself, or manage it in
basecove.env, use flags. When they are set they win
over an App set up in the dashboard.
- On GitHub, go to Settings → Developer settings → GitHub Apps → New GitHub App. These are your account settings, not a repository's.
- Give it a unique name, such as
acme-basecove. Its URL slug is the-github-app-slugvalue. - Set the Homepage URL to
https://admin.example.comand the Callback URL tohttps://admin.example.com/api/github/callback. - For deploy on push, leave Webhook active. Set the URL to
https://admin.example.com/api/github/webhookand the secret to the output ofopenssl rand -hex 32, and subscribe to Push and Workflow run events. If you only want private clones, uncheck Active. - Under Repository permissions, set Contents to Read-only, and Actions to Read-only so a push can route when its tests pass. Leave everything else at its default.
- Create the App, note its App ID, and click Generate a private key.
Copy the key to the server so that only the daemon can read it:
$ install -m 600 -o basecove -g basecove app.private-key.pem /etc/basecove/github-app.pem
Add these flags to the end of the BASECOVE_ARGS line in
/etc/basecove/basecove.env, keeping them on that one
line, then run systemctl restart basecove:
-github-app-id 123456 -github-app-key /etc/basecove/github-app.pem -github-app-slug acme-basecove -github-webhook-secret YOUR_SECRET
The first three flags go together, and the daemon won't start with
only some of them. Leave out -github-webhook-secret if
you turned the webhook off.
Then, in Register a new app, choose GitHub and then Connect GitHub. GitHub asks which repositories the App can see, then sends you back to the dialog to pick one.
Either way, an existing app can switch to a GitHub repository under Settings → Source.
With the webhook on, a push to an app's branch starts a deploy. By
default the branch is the repository's default branch; change it under
Settings → Deploy on push → branch, say from dev to
main. Changing it deploys nothing until the next push to
that branch. Pushed deploys
are built and health-checked, then stop at the gate until someone
routes them, unless the app routes them when their tests pass.
Route when tests pass
If your tests run in GitHub Actions, Basecove can route a push for you once they pass. Your workflow doesn't need a deploy step, an API token or any repository secrets. Turn it on per app under Settings → Deploy on push → Route when GitHub Actions pass. The app has to be connected through the GitHub App (Settings → Source, then GitHub).
The push is built and health-checked while your tests run, so nothing waits twice. Then:
- Every workflow on the commit passes (success, neutral or skipped): it is routed, exactly as if you had clicked Route.
- One fails or times out: it is discarded at once, and the live version keeps serving. The app's history says which workflow failed.
- No answer: no workflow ran for the commit, one was cancelled or is waiting for approval, or they haven't finished after two hours. It waits at the gate for you, and the app's page says why.
- A newer push arrives while it waits: it is discarded, and the newer one takes its place.
While it waits, the app's page shows each workflow with a link to its run on GitHub. Route and Discard still work and decide at once. Only GitHub Actions count. Checks from other apps, such as a coverage bot, are ignored, so one that never reports can't hold a deploy. If a workflow fails, fixing it and pushing again is the way through: re-running the failed workflow doesn't bring back a push that was already discarded.
An App set up in the dashboard since this feature shipped already has what this needs. An older App, or one you created by hand, needs two changes on GitHub: in the App's settings → Permissions & events, set Repository permissions → Actions to Read-only and tick Subscribe to events → Workflow run, then save. GitHub then asks the owner of each account the App is installed on to accept the new permission, by email and under Settings → Applications → Installed GitHub Apps. Check connection on the dashboard's GitHub page says which of these is still missing.
Databases
Under the app's Settings → Databases, either provision Postgres, MySQL or Redis as a container with generated credentials (this needs Docker), or connect an existing managed database by its connection URL.
DATABASE_URL postgres://blog:…@127.0.0.1:52958/blog DATABASE_HOST 127.0.0.1 DATABASE_PORT 52958 DATABASE_NAME blog DATABASE_USER blog DATABASE_PASSWORD …
- Credentials reach the app on its next deploy, not when you attach. Each database in Settings says whether the live version has its variables yet, with a Deploy to apply button when it doesn't.
- Admins can read the values, password included, with show values, plus a URL for
psqlormysqlover SSH. A provisioned database listens on the server's loopback only. - Your app reads other names? Point them at the database. In Environment, set a variable to a reference:
DB_PASSWORD=${{main.PASSWORD}}. The details areURL,HOST,PORT,NAME,USERandPASSWORD(Redis: the first three; a database connected by URL:URL). Each deploy fills them in, so the password is never copied anywhere. Settings → Databases lists every reference with use as…, which adds one for you. A reference to a database or detail that doesn't exist stops the deploy and says which. - Each database's name is unique within the app, since a reference picks one by name. Attaching a second one under a name already in use is refused.
- A variable you set yourself wins over the attached one, and Settings names any that do, such as a
DATABASE_URLfrom a pasted.env. - An attachment named
maingetsDATABASE_URL(orREDIS_URL). Any other name is namespaced:reportingbecomesREPORTING_URL. You can choose the prefix when attaching, and the dialog shows every name it gives:DBgivesDB_URL,DB_HOST,DB_PORTand so on. - You can change the prefix later. A database attached before 2026-10-08 under a variable like
DB_USERNAMEkeepsDB_USERNAME_HOSTand the rest. To rename them, open Settings, then databases, and choose change prefix on it; the dialog lists the new names. The data and credentials stay, and the new names reach the app on its next deploy, when the old ones stop, so code that reads them must change too. References like${{main.HOST}}use the database's name, so they keep working. - Detaching keeps the data unless you explicitly choose to drop it. Re-attaching the same name picks the data back up.
- Admins get a Data tab on the app for browsing tables and running queries in a read-only transaction. It is not a sandbox. See known limits.
- On Postgres the Data tab can also change rows. Switch the query box to write: a statement is dry-run first, inside a transaction that is rolled back, and shows how many rows it would change. Commit sends that count back, and the commit is refused if the statement would now change a different number. On a provisioned Postgres each person runs as their own database role, which can read and change rows but cannot alter or drop tables. MySQL stays read-only, because it commits table changes immediately even inside a transaction.
- Every Data tab call is in the access log, the tab's access log view: who, which database, the statement, and how many rows it changed. A commit is recorded before it runs. Entries are chained and signed, so one changed through the database shows up when the chain is checked, but root on the server could still rewrite the log.
Running a command
Creating the first admin, running a migration by hand, clearing a cache: these are one-off commands in your app's own environment. For an app that runs in a container, admins get a Commands tab. Type the command, press Enter, and the output appears as it runs.
php artisan app:create-admin "Ted" ted@example.com basecove run myapp -- php artisan app:create-admin "Ted" ted@example.com
On the server, basecove run prints the same output and
exits with the command's own exit code, so a script can tell whether
it worked. Ctrl-C stops the command.
- Each command gets a new container, started from the live version's image with the same environment, database access and volumes. The container is removed when the command ends, and the running site is never touched. Files the command writes outside a volume are gone with the container.
- It is a shell command. Pipes and
&&work; withbasecove run, quote the whole command ('php artisan migrate --force && php artisan optimize') so your own shell leaves them alone. The image's own entrypoint is skipped, so what runs is what you typed. An image with no shell can't run commands. - There is no input. A command that asks a question fails. Add
--forceor--no-interaction, whichever your tool takes. - Stop and the time limit. Stop ends a command that hangs. Every command is stopped after 1 hour, long enough for a migration on a large table. At most 3 run at once per app.
- A restart doesn't kill it. If Basecove restarts or is upgraded while a command runs, the command carries on, and
basecove runreconnects without repeating or losing lines. - History. The tab lists the last 50 commands with who ran them and how they ended. Use again puts one back in the box without running it, so running a migration a second time takes a deliberate Enter.
- Output stays out of the app's log, which other users can read, since a command can print anything your app can read. Only admins can run commands or see their output. Pausing or deleting the app stops its commands.
- Apps that run directly on the server (static sites, and Node apps deployed before they ran in containers or on a server without Docker) can't run commands from Basecove. The tab points to the release folder to use over ssh. A Node app's next deploy moves it into a container, where commands work.
Monitoring & alerts
You don't register anything. Every deployed app is probed every 30 seconds through the proxy, on its own hostname, so a probe tests your routing as well as your process. Basecove also reads per-route error rates from real traffic, and reports certificate and domain expiry. Incidents are flap-suppressed.
The app page's Containers panel, and basecove ps
<app> on the server, list every container an app runs:
its release, its Compose sidecars and its databases, with uptime,
restarts and how each last exited. A sidecar or database that keeps
crashing, stops or turns unhealthy opens an incident of its own, even
while the app still answers. A release that exits on its own is
started again, waiting longer between tries, up to a minute; if it
exits 5 times in 5 minutes, that opens an incident saying how it
last exited, which closes after 10 minutes without one.
To be told when something breaks, open the account menu, choose Alerts and add a webhook: Slack, Discord, Teams, or a generic JSON POST. Alerts fire when an incident opens and when it closes.
CI and the API
Everything in the dashboard is also an HTTP API. For scripts and CI there is one machine token, generated on first start. It can do anything an admin can except manage users.
$ export BC_TOKEN=$(cat /var/lib/basecove/api-token) $ curl -X POST -H "Authorization: Bearer $BC_TOKEN" \ 'https://admin.example.com/api/apps/blog/deploy?version=v2'
The API requires the token from every caller, including localhost.
Add &gate=1 to hold a deploy for review. The
dashboard's Users page shows when the token was last used.
If your tests run in GitHub Actions and you only want to deploy once they pass, you don't need the token in CI at all. Connect the repository through the GitHub App and turn on Route when tests pass. Basecove then builds each push while the tests run and routes it when they pass.
Upgrading
Download and verify the new build as in step 1, then:
$ tar xzf basecove-linux-amd64.tar.gz $ basecove upgrade ./basecove/basecove -dry-run \ -sha256 $(sha256sum basecove/basecove | cut -d' ' -f1) $ basecove upgrade ./basecove/basecove \ -sha256 $(sha256sum basecove/basecove | cut -d' ' -f1)
basecove upgrade:
- checks the checksum
- runs the new binary to prove it works on this machine
- backs up the current binary and the state database
- swaps in the new binary and restarts
- waits for the daemon to report the new version
If the daemon never reports the new version, the old binary goes back automatically. Your apps keep running through the restart. An admin can also stage an upgrade from the dashboard.
What to back up
basecove-db-…), and the files your apps keep in a VOLUME (basecove-<app>.…)
Basecove backs up its own database, certificates and API token every
day and keeps the newest 7 in
/var/lib/basecove/backups/_state/basecove/. They are on
the same disk as the originals, so copy that folder off the server
too. basecove upgrade also keeps the most recent backups
in /root/basecove-backups/.
Your apps' files
Basecove backs up each volume an app keeps its files in every day at
03:00 server time, and keeps the newest 7 in
/var/lib/basecove/backups/<app>/. Files are copied
while the app keeps serving. A file being written at that moment may
be caught half-way, and is whole in the next day's backup. SQLite
databases are copied through SQLite's own backup, so they are always
consistent. A backup that would leave less than 10% of the disk free
is skipped, and the app's Settings tab says so.
If an app keeps a different database in a volume, such as Postgres files, choose Pause the app while copying under Settings → backups. Its containers are then frozen for the whole copy, so requests wait for as long as it takes, and Settings shows how long that was.
In Settings → backups you can take a backup now, download one,
restore one, or delete one to free space; the section shows how much
the backups take on the server. A restore stops the app, replaces every file in the
volume, and starts the same version again. It backs up what the volume
held first, so you can undo it. These backups are on the same server
as the app, so download them to keep a copy anywhere else. A Compose
sidecar's volumes, such as a postgres service's, aren't
included yet. Back those up yourself.
Bringing data from another server
To move an app onto Basecove with its data, make a backup on the old
server and upload it with Upload… beside the database or volume
in Settings → backups. It's listed as uploaded, and
nothing changes until you restore it like any other backup. For a
Postgres database, a plain or custom-format pg_dump works;
the command below is the cleanest. For a volume, make a tar from inside
the folder whose files it holds:
$ pg_dump --no-owner --no-acl -d <database> | gzip > dump.sql.gz $ tar czf files.tar.gz -C <folder> .
The file is checked as it arrives and fixed where that's safe: a dump
in pg_dump's custom format is converted, and owners and grants for the
old server's users, or settings a newer pg_dump writes,
are left out. You're told what was changed. If a restore would still
fail on it, for example because it needs a Postgres extension the
database here doesn't have, it isn't kept, and every reason is listed
with the command that fixes it. Restoring
files that won't fit on the disk is refused before anything is removed. A
restore of a database loads the upload beside it first and only swaps
it in once every table has its rows. Only a signed-in admin can upload,
not the API token. Uploads can be up to 20 GB.
If you delete /var/lib/basecove and start again, the
images the old install built are removed the next time Basecove
starts. Its volumes and database containers are not: they hold data.
Images built before October 2026 carry no mark saying which install
built them: list them with docker images 'basecove/*'
and remove the old ones with docker rmi.
Uninstalling
Delete your apps first, while Basecove is still running. Stopping Basecove doesn't stop your databases. They are set to restart on their own, so they keep running and keep their data with nothing managing them.
1 Delete each app
In the dashboard, open the app's Settings tab and use Delete under danger zone. You confirm by typing the app's name. Tick Also destroy the data to remove its databases' data, its volumes and their backups as well. This removes the app's versions, images and database containers. It can't be undone, so back up any database you want to keep before you start.
2 Remove the service
$ systemctl disable --now basecove-upgrade.path basecove.service $ rm -f /etc/systemd/system/basecove.service \ /etc/systemd/system/basecove-upgrade.path \ /etc/systemd/system/basecove-upgrade.service $ systemctl daemon-reload
3 Clear what's left in Docker
If you deleted every app in step 1, these commands find nothing. Otherwise they remove Basecove's leftover containers, images and network:
$ docker ps -aq --filter 'name=^basecove-' | xargs -r docker rm -f $ docker images 'basecove/*' -q | xargs -r docker rmi -f $ docker network rm basecove
Database data lives in volumes named basecove-db-…, and
apps' own files in volumes named basecove-<app>.….
List both with docker volume ls --filter name=basecove-.
Remove them with docker volume rm only when you're sure,
because that is the step that deletes the data.
4 Remove the files
$ rm -f /usr/local/bin/basecove $ rm -rf /var/lib/basecove /etc/basecove /root/basecove-backups $ userdel basecove
These directories hold database credentials in plain text, TLS keys and
the API token. Delete them, or keep them only somewhere as protected as
the server. Your app sources in /srv/apps/ are left alone.
Close the firewall ports you opened at install, and if you connected a
GitHub App, uninstall it on GitHub too. Removing the server doesn't
revoke its access to your repositories.
Troubleshooting
- The daemon's logs
journalctl -u basecove -f. Each deploy's build output is also in the app's log dock in the dashboard.- A hostname gives a TLS error, or curl reports
000 - On an HTTPS server, a hostname no app is registered for fails the TLS handshake instead of returning a 404, because certificates are only held for hosts that are routed. Check the app's host spelling and its DNS record, and allow a minute for the first certificate.
- The deploy fails at health
- The app started, but its health path never returned a 2xx. The previous version kept serving the whole time. The failed deploy shows what the path answered, and the answer tells you where to look. 404: the path doesn't exist in your app, so change it in Settings → Health check. No answer: the app isn't listening where Basecove routes to, and the report names the port it does listen on. 500: the app is reachable but erroring; see the next entry.
- The health check gets a 500
-
A 500 means your app caught an error and rendered an error page. Basecove keeps that page, and the failed deploy shows it under What the app answered: the page's title, which on most frameworks' error pages is the exception message, then the page as text, with the raw response one click away. Only admins see it, because a debug page can print the app's environment, passwords included. Nothing in it is run; the page is shown as text.
If the page only says something like "Server Error", the details are in the app's log. The log dock shows what the app prints and nothing else, so an app that logs to a file shows nothing there. Point its logs at standard output or standard error:
- Laravel: set
LOG_CHANNEL=stderrin Settings → Environment. Laravel's default writes tostorage/logs/laravel.loginside the container, where you can't see it. If you run php-fpm with your own pool config, keepcatch_workers_output = yes, or PHP's output is dropped before it leaves the container. The officialphpimages already set it. - Other stacks: point the framework's logger at the console. Most Node, Python, Ruby and Go servers already print to the console unless told otherwise.
A framework's debug mode, such as
APP_DEBUG=trueor Django'sDEBUG=True, puts the full error on that page. A version that fails its health check never receives traffic, so its debug page is seen only here. But a version that passes goes live with debug still on, showing your environment to anyone who visits. If you turn it on to diagnose, deploy with Hold before routing on. Once the fix passes, Discard it, turn debug off, and deploy again.Changing an environment variable doesn't deploy; the next deploy picks it up.
- Laravel: set
- Testing a host without DNS
- Over plain HTTP,
curl -H 'Host: app.example.com' http://SERVER/works. Over HTTPS aHostheader doesn't set SNI, so usecurl --resolve app.example.com:443:SERVER https://app.example.com/instead. - Don't check readiness with
curl -sf - A host with nothing deployed yet answers with a 404 page that is easy to mistake for your app. Check the response body for something your app actually returns.
- Locked out
sudo -u basecove basecove user passwd you -data /var/lib/basecoveresets a password from the server. The daemon doesn't need a restart.
Known limits
This is a preview. Run it on a server you could rebuild, with apps you could redeploy elsewhere. These are the known edges, stated plainly.
- A provisioned Postgres app user is a superuser. Code with that database's credentials can do anything to that database. The Data tab doesn't use it for queries: each person runs as their own role, which is not a superuser. On an external Postgres the Data tab uses the URL you pasted, with whatever rights that user has.
- Database credentials are stored in plain text in
/var/lib/basecove/basecove.db. Protect that file and its backups as you would the credentials. - Low-traffic routes can fail without an alert. Per-route alerts need at least 20 requests in a 5-minute window before they judge a route. On most small sites that means per-route alerts rarely fire. The 30-second health probe still covers each app as a whole.
- Certificate renewal hasn't been observed in production yet. It's armed through Let's Encrypt's renewal info, and the first renewal window opens in November 2026. Watch for the expiry reported in the dashboard.
- One server. There's no fleet or multi-server management yet.
- Two global roles. No per-app permissions. Every admin can change every app.
- Stacks. Without a Dockerfile, Basecove knows Node (run in a container it generates) and static sites. Anything else, including PHP, Python and Go, deploys through a
Dockerfile. - A Node app with a data directory stays on the server. A Node app deployed before Basecove ran them in containers, with a SQLite file under Settings → Databases, keeps running on the server itself, with a warning on each deploy: moving its data into a container without losing writes needs the app stopped for the copy, which Basecove doesn't do yet.
- Monitoring is HTTP only. No TCP checks, cron or heartbeat monitoring, multi-region probes or public status pages yet.
- Mid-drain restarts. If the daemon restarts while an old version is draining, that version is stopped immediately rather than allowed to finish draining.
- Compose: one copy of each service. A
compose.yaml(ordocker-compose.yml) is read: its public service, its workers and schedulers, its stock-image services such as postgres or redis, its release steps (a service another waits on withservice_completed_successfully, run once before each deploy) and itsvolumes:all apply.replicasisn't: each service runs once. Workers switch to a new version only once it serves, and stop before the new ones start. A background service the public onedepends_on, such as the API behind an nginx front end, starts before it instead, asdocker compose upwould, and each version's public service reaches its own copy by that name. Native Node apps have no persistent directory at all. - No file browser. You can't open an app's files, or a failed version's, from the dashboard. Errors reach you through what the app answered and what it printed.
- Commands have no terminal. Running a command shows its output but takes no input, so a shell, an editor or a prompt can't be used. Static sites, and Node apps still running on the server itself, can't run commands from Basecove at all.
- Build secrets work. SSH in a build doesn't. A
Dockerfilestep withRUN --mount=type=secret,id=npm_tokengets the file uploaded in Settings → Secret files asnpm_token, or elsenpm_tokenorNPM_TOKENfrom Settings → Environment. Only that step can read it, and it isn't stored in the image. As withdocker build, a changed value doesn't make a cached step run again: deploy with Build from scratch when it should. With Compose, the build gets the secrets itsbuild.secretslists, from the same places as its other secrets. A secret with no value is left out, asdocker builddoes, unless the mount saysrequired=true: then the pre-deploy check stops the deploy before anything builds.--mount=type=sshis still stopped there: fetch the code with a token passed as a build secret instead. Secrets andRUN --mount=type=cacheneed Docker to build with BuildKit, the default since Docker 23. - Database major versions: Postgres only. Changing a Compose
postgresservice frompostgres:15topostgres:16stops the deploy until you approve the upgrade with Upgrade and deploy. Basecove then dumps the data, restores it into the new version and checks every table's row count. Your app answers 503 until the new version is healthy. If anything fails, the old data and version come back. MySQL, MariaDB and Mongo moving to a new major version are still refused, with the steps to do it by hand. A move to an older major version is always refused. - Images left behind. Deleting an app's
.basecovedirectory by hand leaves its Docker images on disk.