CapRover turns one Linux server into a small Heroku. You push code or name a Docker image, and it builds, deploys and routes the app to its own subdomain behind Nginx. Under it sits a single-node Docker Swarm, so scaling an app to three replicas is one field in the dashboard, and the one-click catalog covers databases and dozens of self-hosted apps.
The CapRover docker install itself is one docker run, but two things trip people up. The installer refuses to start on a machine behind NAT, and the setup wizard only changes the default password at its very end. This guide covers both, plus deploying from an image and from a Dockerfile, environment variables, scaling, a one-click app and backups. We ran CapRover 1.15.4 with Docker 29.9 on Ubuntu 26.04 and repeated the install and a deployment on Ubuntu 24.04 in October 2026.
How CapRover fits together
CapRover runs as three Swarm services of its own, and every app you deploy becomes one more service behind them. Our test box ended up with this layout:

Each of the three services has one job:
captain-nginx: ports 80 and 443, routes each subdomaincaptain-captain: dashboard, API and image builds, port 3000captain-certbot: Let’s Encrypt certificates once HTTPS is on
That routing model is the reason CapRover needs a wildcard DNS name. Every app gets <app>.<root domain>, so all of those names must reach the same server, and Nginx then picks the service by the host name in the request. It also means a broken DNS record breaks every app at once, which is the first thing to check when nothing loads.
Prerequisites
CapRover takes over ports 80 and 443 and runs Docker Swarm, so give it a server of its own rather than one that already hosts a web server:
- Ubuntu 26.04 or 24.04, fresh install
- 1 GB RAM minimum; 2 GB or more if you build images on the server
- a domain where you can add a wildcard A record
- a public IP for HTTPS (a LAN box works over HTTP only)
The 1 GB figure comes from the CapRover getting-started docs, which warn that 512 MB may not be enough for builds. Our Dockerfile build of a small Python app finished fine on 4 GB, and the 24.04 test ran on 2 GB. Sizing grows with what you deploy, since each app and database is its own container on the same box.
Install Docker Engine
CapRover needs Docker 25 or newer from Docker’s own repository. Do not use the snap package, which the CapRover docs call buggy for this. Add the repository; the codename lookup makes the same commands work on 26.04 and 24.04:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
printf "Types: deb\nURIs: https://download.docker.com/linux/ubuntu\nSuites: %s\nComponents: stable\nSigned-By: /etc/apt/keyrings/docker.asc\n" "$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")" | sudo tee /etc/apt/sources.list.d/docker.sources
Install the engine and its plugins. CapRover only needs the engine, but the Compose and Buildx plugins cost nothing and you will want Docker Compose for anything you run outside CapRover:
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Docker’s repository already publishes builds for the 26.04 codename, so there is no need to fall back to the 24.04 packages. Confirm the version before going further:
docker --version
The CapRover docs ask for Docker 25 or newer, and our 26.04 server reported a current build:
Docker version 29.9.1, build dfc520d
The Docker CE guide for Ubuntu 26.04 explains each of these steps if you want more than the commands.
Open the firewall
Allow SSH first so enabling UFW cannot lock you out, then the ports CapRover uses on a single node:
| Port | Used for |
|---|---|
| 80/tcp | Apps over HTTP, Let’s Encrypt validation |
| 443/tcp and 443/udp | Apps over HTTPS (both listed in the CapRover docs) |
| 3000/tcp | Dashboard by IP, needed for the first setup |
Add the rules and turn the firewall on:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 3000/tcp
sudo ufw --force enable
Open the same ports in the security group of your cloud provider too, and treat that layer as the real firewall. Docker publishes container ports through its own iptables rules, so UFW does not filter ports 80, 443 and 3000 once CapRover is running. Port 3000 is only needed for the initial setup; the CapRover firewall docs say you can close it once CapRover is attached to a domain, so close it in the provider firewall after the setup below. Worker nodes need 2377/tcp, 7946/tcp+udp and 4789/udp between the nodes only, never open to the internet.
Point a wildcard DNS record at the server
Create one A record whose host is a wildcard under the name you want as CapRover’s root. For a root of apps.example.com, the record is:
Type: A Host: *.apps Value: 203.0.113.10
Point it straight at the server. The CapRover docs say proxied DNS such as the Cloudflare orange cloud may cause problems and is not supported, so leave the record DNS-only. DNS changes can take a while to spread, so check that a name under it resolves before you continue:
getent hosts captain.apps.example.com
For a homelab test without a domain, sslip.io gives you a wildcard name for any IP with no setup at all, which is what we used: anything.192-168-1-186.sslip.io resolves to 192.168.1.186. It is fine for trying CapRover on a LAN, but Let’s Encrypt cannot reach a private address, so that setup stays on HTTP. Use a real domain for anything you plan to keep.
Install CapRover
The installer is a one-shot container that checks the system, starts Swarm and creates the three CapRover services, then exits. On a server with a public IP, run it as the docs give it:
sudo docker run -p 80:80 -p 443:443 -p 3000:3000 -e ACCEPTED_TERMS=true -v /var/run/docker.sock:/var/run/docker.sock -v /captain:/captain caprover/caprover
On our 24.04 cloud server that worked as written once the security group allowed 80, 443 and 3000, and Swarm came up without any extra options. Our 26.04 server sat on a LAN behind NAT, and the same command failed after a few seconds, because the check that follows assumes the server owns a public address.
Installation failed. Error: Port seems to be closed: 80
The installer looks up the public IP of the server and connects back to it on ports 80, 443 and 3000, in that order, to prove apps will be reachable. Behind NAT, that address belongs to your router, so the check fails, and on a cloud server it fails when the firewall of the provider still blocks one of those ports. The two causes need different fixes, so read the container log, which names both:
Are you trying to run CapRover on a local machine or a machine without a public IP?
In that case, you need to add this to your installation command:
-e MAIN_NODE_IP_ADDRESS='127.0.0.1'
...
Installation failed.
Error: Port seems to be closed: 80
On a cloud server, open 80, 443 and 3000 in the security group and run the installer again. On a LAN, remove the failed container and pass the LAN address of the server, which is the address your wildcard name resolves to (the run-locally docs cover the same option):
sudo docker rm $(sudo docker ps -aq --filter ancestor=caprover/caprover)
sudo docker run -d -p 80:80 -p 443:443 -p 3000:3000 -e ACCEPTED_TERMS=true -e MAIN_NODE_IP_ADDRESS=192.168.1.186 -v /var/run/docker.sock:/var/run/docker.sock -v /captain:/captain caprover/caprover
The installer runs detached this time, so follow its log straight away, before Swarm starts other containers:
sudo docker logs -f $(sudo docker ps -lq)
On our LAN box it took 26 seconds from the system check to a running Swarm, most of it spent pulling three images:
>>> Checking System Compatibility <<<
Docker Version passed.
Total RAM 4101 MB
Pulling: nginx:1.31
Pulling: caprover/caprover-placeholder-app:latest
Pulling: caprover/certbot-sleeping:v2.11.0
October 17th 2026, 1:06:00.055 pm Fresh installation!
October 17th 2026, 1:06:00.055 pm Starting swarm at 192.168.1.186:2377
Swarm started: u6k84474bz6tlleookoq8ep9i
*** CapRover is initializing ***
Please wait at least 60 seconds before trying to access CapRover.
Take that 60 seconds seriously. Our first API call on the 24.04 server came back with Captain is not ready yet..., and the same call worked a minute later, so a script that drives the API right after install needs a retry loop. Once it is ready, all three CapRover services are running:
sudo docker service ls --format "{{.Name}} {{.Replicas}} {{.Image}}"
All three should show one running replica:
captain-captain 1/1 caprover/caprover:1.15.4
captain-certbot 1/1 caprover/certbot-sleeping:v2.11.0
captain-nginx 1/1 nginx:1.31
A service stuck at 0/1 usually means its image is still pulling, so give it a minute and check sudo docker service ps <name> for the error if it stays there.
Run the setup wizard
The CLI sets the root domain, the password and HTTPS in one guided run, and the docs recommend it over the dashboard because it does the steps in the right order. The current CLI needs Node.js 20 or newer. Ubuntu 26.04 ships Node 22, so we installed it on the server from the Ubuntu repository; Ubuntu 24.04 still ships Node 18, so on 24.04 run the CLI from your workstation or install a newer Node first:
sudo apt install -y nodejs npm
sudo npm install -g caprover
caprover serversetup
The wizard asked these questions, in this order:
? have you already started CapRover container on your server? yes
? IP address of your server: 192.168.1.186
? CapRover server root domain: 192-168-1-186.sslip.io
? new CapRover password (min 8 characters): [input is masked]
? enter new CapRover password again: [input is masked]
? "valid" email address to get certificate and enable HTTPS:
Enter the root domain without the *. part. On a server with public DNS, give a real email, and the wizard enables and forces HTTPS through Let’s Encrypt, changes the password, and finally asks for a machine name under which the CLI stores the login. Our LAN box couldn’t get a certificate, so we stopped at the email prompt, and that exposed the catch: the root domain was saved immediately, but the new password was not. CapRover still accepted only captain42, the default every scanner knows. If you stop the wizard early for any reason, change the password in the dashboard right away, as shown below.
Log in and change the default password
The dashboard lives at captain. plus your root domain, so ours was http://captain.192-168-1-186.sslip.io (with HTTPS enabled it would be https://). Until HTTPS is set up, the dashboard greets you with the initial setup panel:

Go to Settings > Change Password, enter captain42 as the old password and your new one twice. Afterwards the API rejected the old one with {"status":1105,"description":"Invalid credentials"}, which is the quick check that the change stuck. On a public server, finish HTTPS from the same dashboard if you skipped it in the wizard: on the Dashboard page, under CapRover Root Domain Configurations, click Enable HTTPS, then Force HTTPS once the certificate is issued.
Deploy an app from a Docker image
The fastest deploy names an existing image, so nothing is built on the server. If you finished the wizard, the CLI is already logged in under the machine name you gave it. Otherwise log in once and pick a short name; use https:// in the URL once HTTPS is forced, because the login does not follow the redirect:
caprover login -u http://captain.192-168-1-186.sslip.io -p 'your-password' -n paas01
Create the app, then deploy an image to it. The dashboard’s Create A New App button does the first step too; the API call keeps everything in the terminal:
caprover api -n paas01 -t /user/apps/appDefinitions/register -m POST -d '{"appName":"hello","hasPersistentData":false}'
caprover deploy -n paas01 -a hello -i nginxdemos/hello:latest
Because an image name was given, CapRover skips the build and only pulls it:
Build started for hello
An explicit image name was provided (nginxdemos/hello:latest). Therefore, no build process is needed.
Pulling this image: nginxdemos/hello:latest This process might take a few minutes.
Build has finished successfully!
Deployed successfully hello
App is available at http://hello.192-168-1-186.sslip.io
The app answers on its own subdomain straight away, and it shows up in Swarm under its plain name, hello. Image deploys suit anything that already ships a container, because nothing is compiled on your server and a redeploy is just a pull.
Deploy your own code with a Dockerfile
For your own code, CapRover builds the image on the server from a Dockerfile, and a small captain-definition file tells it where that Dockerfile is. We used a tiny Python server that prints its container name and an environment variable, which makes the next section easy to check:
mkdir ~/api && cd ~/api
vim app.py
Add the app. It reads GREETING from the environment so you can see a config change take effect without a rebuild:
from http.server import BaseHTTPRequestHandler, HTTPServer
import os, socket
class H(BaseHTTPRequestHandler):
def do_GET(self):
body = f"api on {socket.gethostname()} greeting={os.environ.get('GREETING', 'unset')}\n".encode()
self.send_response(200)
self.end_headers()
self.wfile.write(body)
HTTPServer(("0.0.0.0", 80), H).serve_forever()
Create the Dockerfile next to it, in the same folder that will become the Git repository:
vim Dockerfile
Our app listens on port 80 inside the container, which is the port CapRover routes to by default:
FROM python:3.13-slim
WORKDIR /app
COPY app.py .
EXPOSE 80
CMD ["python", "app.py"]
Then add the definition file in the project root. Without it the CLI warns and falls back to ./Dockerfile, but an explicit file keeps the build predictable once the project grows:
vim captain-definition
Schema version 2 with a Dockerfile path is all it needs:
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
The CLI uploads what’s committed in Git, not what’s on disk, so commit before deploying. A fresh server has no Git identity, and the commit fails with unable to auto-detect email address until you set one. Naming the branch at git init keeps the deploy command right whatever your Git default is:
git config --global user.name "Your Name"
git config --global user.email [email protected]
git init -b master && git add . && git commit -m init
caprover api -n paas01 -t /user/apps/appDefinitions/register -m POST -d '{"appName":"api","hasPersistentData":false}'
caprover deploy -n paas01 -a api -b master
The build log streams back to your terminal, so a failing RUN step shows up right there, and a good build ends with the URL:
[internal] load metadata for docker.io/library/python:3.13-slim
[3/3] COPY app.py .
[2/3] WORKDIR /app
[1/3] FROM docker.io/library/python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f
exporting to image
Build has finished successfully!
Deployed successfully api
App is available at http://api.192-168-1-186.sslip.io
A request returns api on 5a9222f1b28b greeting=unset, since no environment variable is set yet. The build ran on the server, so the first deploy of a project with heavy dependencies is where a 1 GB box runs out of memory. If you want a deploy on every push instead of running the CLI, the Deployment tab of the app takes a repository URL, branch and credentials, and after you save them it shows a webhook URL to add to GitHub or GitLab. We did not set that part up in this lab.
Set environment variables and scale out
Both live on the App Configs tab of the app. Add a key and value, set the instance count, then click Save & Restart, and CapRover rolls the containers so the new settings apply without a rebuild. We set GREETING and three instances:

The API can make the same change, but read this first: appDefinitions/update replaces the whole app definition, and every field you leave out is reset to its default. Volumes, port mappings, the container HTTP port and WebSocket support all go back to defaults, so on an app like Uptime Kuma the call would detach its data and break routing. We ran it on the api app, which had nothing else configured:
caprover api -n paas01 -t /user/apps/appDefinitions/update -m POST -d '{"appName":"api","instanceCount":3,"envVars":[{"key":"GREETING","value":"from-caprover"}]}'
For any other app, fetch the current definition from /user/apps/appDefinitions, change that object and post the whole thing back, or stay with the dashboard. About fifteen seconds later the service showed api 3/3, and repeated requests rotated through three containers, each with the new variable:
api on 28cb33acfd2c greeting=from-caprover
api on 0d4fc03d1a14 greeting=from-caprover
api on 5eedd9ef53c0 greeting=from-caprover
api on 28cb33acfd2c greeting=from-caprover
Only scale apps that keep no state in the container. An app with persistent data stores it in a volume on this one node, and several replicas writing to the same files is how databases get corrupted. Keep databases at one instance and scale the stateless app in front of them instead, so extra replicas add capacity without adding risk. Replicas on one node also share its CPU and memory, so past a point the next step is a worker node, not a bigger instance count. Those nodes join the same Swarm, which the Docker Swarm on Ubuntu guide explains from the ground up.
Deploy a one-click app
The one-click catalog installs packaged apps and databases with their volumes and settings filled in. Open Apps > Create A New App > One-Click Apps/Databases and search for what you need; we picked Uptime Kuma:

The form asks for an app name and offers the version tag (2.3.2 when we ran it). We named the app status and clicked Deploy, and the progress screen walked through registering, configuring volumes and deploying in under a minute:

The template created a named Docker volume, status-data, for the app’s database, so redeploys and restarts keep your monitors. The apps list now shows all three apps, with the persistent-data tick on status:

One-click templates are a starting point, not a managed service. Check the image tag the form offers before deploying, and plan how you’ll update it, because CapRover does not upgrade a one-click app for you. Note which version each app runs, so you know what you are moving from when you change the tag later. For a standalone setup outside CapRover, the Uptime Kuma guide covers the same app directly.
Read app logs
Each app’s Logs tab streams its output in the dashboard. On the server, the Swarm service has the same name as the app, so docker service logs works directly:
sudo docker service logs --tail 5 status
A fresh Uptime Kuma waits for its first-run setup, which is what you want to see before you open its URL:
status.1.rhkazpcl4e60@paas01 | 2026-10-17T13:17:55Z [SETUP-DATABASE] INFO: Listening on:
status.1.rhkazpcl4e60@paas01 | 2026-10-17T13:17:55Z [SETUP-DATABASE] INFO: - http://localhost:3001
status.1.rhkazpcl4e60@paas01 | 2026-10-17T13:17:55Z [SETUP-DATABASE] INFO: Waiting for user action...
Older guides tell you to look for srv-captain-- service names. Apps created on current CapRover versions use plain names (installs upgraded from older versions keep the prefix for their existing apps), so sudo docker service ls is the quickest way to see the real names before you copy a command from one of them. The dashboard tab and the command show the same stream, so use whichever is closer when an app refuses to start.
Back up CapRover and app data
The built-in backup covers the server configuration and nothing else, so plan for three kinds:
- configuration: root domain, app definitions, Nginx settings, certificates
- app data: the Docker volume of every app with persistent data
- databases: a dump from the database’s own tool
Maintenance > Create Backup covers the first item. It downloads a tar named after the date, IP and hostname, and ours was only 14 KB:
caprover-backup-2026_10_17-13_21_32-1792243292652-ip-192_168_1_186-host-paas01.tar
List the archive to see its layout:
tar -tf caprover-backup-*.tar | grep config-captain
Ours printed the nested path:
data/data/config-captain.json
Check what is inside before you rely on it. Our backup nested the data one level too deep (data/data/config-captain.json), which upstream fixed in a change merged after 1.15.4 because such backups would not restore. The restore procedure is still marked experimental and leaves out images, so apps built from a Dockerfile need a fresh deploy afterwards. Until the next release ships, a restore from a 1.15 backup means repacking the tar so the files sit directly under data/, or restoring with the fixed build. App data lives in Docker volumes, which the backup skips entirely. Archive each volume with a throwaway container that mounts it read-only:
mkdir -p ~/backups
sudo docker run --rm -v status-data:/data:ro -v ~/backups:/backup alpine tar czf /backup/status-data.tgz -C /data .
Copy both files off the server on a schedule. Use the dump tool for database apps, because a tar of live database files can catch them mid-write and restore to a broken state. For a stack that outgrows one box, CapRover’s Cluster page adds worker nodes to the same Swarm (the extra ports are listed in the firewall section), and Coolify is the closest self-hosted alternative if you want to compare before you commit.