BASECOVEpreview

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

basecoveabout 60 MB of RAM (78 MB at its highest on our server) and about 70 MB of disk: a 52.5 MB binary plus its own database and logs. Near-zero CPU when idle.
upgradeseach upgrade keeps a copy of the old binary and database, the last 10 of them, about 600 MB in all
serverLinux, x86-64 or arm64, with systemd. Tested on Ubuntu 24.04.
sizewhatever your apps need. Basecove's share is the two lines above.
accessroot over SSH
ports80 and 443 open to the internet
dnsan A record for every hostname you'll use, plus one for the dashboard
dockerfor apps with a Dockerfile or a package.json, and for provisioned databases. A static site with nothing to build doesn't need it.
nodenot needed: Node apps build and run in a container. Only on a server without Docker, where they run on the server itself.

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.

server · root
$ 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

server · root
$ 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

server · root
$ 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 basecove system user, and adds it to the docker group 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 basecove systemd 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.

server · root
$ 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:

server · root
$ 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

  1. Put the code on the server under /srv/apps/<name>, using rsync, scp or git clone. Or skip this step and deploy from a repository. Keep sources out of /var/lib/basecove, which is where releases are built.
  2. Add a DNS record for the app's hostname, pointing at the server.
  3. 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.
  4. 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

Dockerfile presentBuilt as an image and run as a container. Needs Docker on the server.
package.json, runnableInstalled, built if it has a 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.
built front-end or HTMLServed as static files. The served directory is detected from the release root, 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, and PORT is set to that same number. With no EXPOSE, 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 any PORT you 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 own HEALTHCHECK is 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's storage/, are gone after the next deploy. Declare the directory in the Dockerfile, for example VOLUME /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's sqlite provider, 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 (/data in a container, with or without a Dockerfile), and is backed up daily. The app gets its path as DATABASE_PATH; point your framework at it from Environment, for Laravel DB_CONNECTION=sqlite and DB_DATABASE=${{main.PATH}}, for Prisma DATABASE_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 in docker ps) and into /data/main.sqlite of 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's database/: 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.1 or localhost is reachable only from inside its own container. Bind 0.0.0.0: for example --host 0.0.0.0, HOST=0.0.0.0, or app.listen(port, '0.0.0.0'). A failed deploy's report says when this is the cause.
Say which port you serve on
EXPOSE the port in the Dockerfile, or list it under the service's expose or ports in the Compose file. With neither, the app must listen on $PORT. The host side of a Compose ports mapping is ignored: Basecove routes traffic itself.
Health checks

Every deploy waits for a health check to pass before it receives traffic. Which check runs:

  1. A health path you set in Settings → Health check, including /. It always wins.
  2. Otherwise, automatic: the Compose service's healthcheck, else the image's HEALTHCHECK. The port traffic goes to must also answer.
  3. With neither, GET / must answer 2xx. A redirect to a page that answers 200 passes.

Base images bring their own checks. A HEALTHCHECK is inherited from the image you build FROM, 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 call curl in a slim image that doesn't have it. When the check can't work in the container like this, Basecove checks GET / 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 NONE in the Dockerfile, or healthcheck: {disable: true} on the Compose service. Or set a health path. Use a tool the final image has: wget on Alpine, or the language itself, such as python -c or node -e. Workers and schedulers that run the web service's image have its check switched off for them.

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 VOLUME in 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 .env out 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 secrets are mounted read-only at /run/secrets/<name>, for the app and its sidecars alike, so POSTGRES_PASSWORD_FILE=/run/secrets/db_password works as it does locally. A secret's file: 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 its environment: names, then the variable named after the secret in Settings → Environment, db_password from DB_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, localhost is 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_successfully runs 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.yaml or docker-compose.yml, the files docker compose uses without -f. A docker-compose.prod.yml isn'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 writes http:// 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's STOPSIGNAL), then a grace period. Set stop_grace_period on a Compose service whose jobs need longer. A shell-form CMD can swallow the signal; the exec form, CMD ["…"], passes it to your app.

Deploys, the gate, rollback

◍ building→ ◍ starting→ ◍ healthy→ ● live→ ◌ draining→ ○ stopped

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.

from a script · see the API
$ 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:

server · root
$ 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.

  1. Open the dashboard at its admin host, and choose GitHub from the account menu.
  2. Choose the account or organization that owns your repositories, then Create on GitHub. GitHub shows the App with everything filled in. Confirm it.
  3. 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.

  1. On GitHub, go to Settings → Developer settings → GitHub Apps → New GitHub App. These are your account settings, not a repository's.
  2. Give it a unique name, such as acme-basecove. Its URL slug is the -github-app-slug value.
  3. Set the Homepage URL to https://admin.example.com and the Callback URL to https://admin.example.com/api/github/callback.
  4. For deploy on push, leave Webhook active. Set the URL to https://admin.example.com/api/github/webhook and the secret to the output of openssl rand -hex 32, and subscribe to Push and Workflow run events. If you only want private clones, uncheck Active.
  5. 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.
  6. 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:

server · root
$ 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:

/etc/basecove/basecove.env
-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.

injected into the app
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 psql or mysql over 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 are URL, HOST, PORT, NAME, USER and PASSWORD (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_URL from a pasted .env.
  • An attachment named main gets DATABASE_URL (or REDIS_URL). Any other name is namespaced: reporting becomes REPORTING_URL. You can choose the prefix when attaching, and the dialog shows every name it gives: DB gives DB_URL, DB_HOST, DB_PORT and so on.
  • You can change the prefix later. A database attached before 2026-10-08 under a variable like DB_USERNAME keeps DB_USERNAME_HOST and 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.

in the Commands tab, or on the server
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; with basecove 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 --force or --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 run reconnects 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.

server · root
$ 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:

server · root
$ 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:

  1. checks the checksum
  2. runs the new binary to prove it works on this machine
  3. backs up the current binary and the state database
  4. swaps in the new binary and restarts
  5. 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

/var/lib/basecove/state: the SQLite database, releases, certificates. Contains database credentials in plain text.
/etc/basecove/the daemon's flags, and the GitHub App key if you use one
/srv/apps/your app sources, unless they come from git
docker volumesthe data in provisioned databases (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:

the old server
$ 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

server · root
$ 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:

server · root
$ 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

server · root
$ 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=stderr in Settings → Environment. Laravel's default writes to storage/logs/laravel.log inside the container, where you can't see it. If you run php-fpm with your own pool config, keep catch_workers_output = yes, or PHP's output is dropped before it leaves the container. The official php images 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=true or Django's DEBUG=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.

Testing a host without DNS
Over plain HTTP, curl -H 'Host: app.example.com' http://SERVER/ works. Over HTTPS a Host header doesn't set SNI, so use curl --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/basecove resets 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 (or docker-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 with service_completed_successfully, run once before each deploy) and its volumes: all apply. replicas isn'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 one depends_on, such as the API behind an nginx front end, starts before it instead, as docker compose up would, 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 Dockerfile step with RUN --mount=type=secret,id=npm_token gets the file uploaded in Settings → Secret files as npm_token, or else npm_token or NPM_TOKEN from Settings → Environment. Only that step can read it, and it isn't stored in the image. As with docker 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 its build.secrets lists, from the same places as its other secrets. A secret with no value is left out, as docker build does, unless the mount says required=true: then the pre-deploy check stops the deploy before anything builds. --mount=type=ssh is still stopped there: fetch the code with a token passed as a build secret instead. Secrets and RUN --mount=type=cache need Docker to build with BuildKit, the default since Docker 23.
  • Database major versions: Postgres only. Changing a Compose postgres service from postgres:15 to postgres:16 stops 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 .basecove directory by hand leaves its Docker images on disk.