How to Deploy Prometheus on a VPS
Updated Sep 2026
verified on Ubuntu 26.04 · Sep 2026Self-host Prometheus with node_exporter on a VPS using Docker Compose — loopback-only listeners, basic auth, a retention limit you chose on purpose, and HTTPS through Caddy.
- A VPS with 2 GB RAM or more — more if you scrape many targets
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain you can point at the server
- Docker Engine + Compose installed (see the base guide below)
- Comfort editing YAML — scrape targets live in prometheus.yml
What Prometheus is
Prometheus is a metrics database with its own collector. On a fixed interval it pulls (scrapes) plain-text metrics from HTTP endpoints you list, stores them as time series in a local database (the TSDB), and lets you query them with PromQL. It can evaluate alerting rules against those series. Delivering the alerts (email, Slack, PagerDuty) is the job of a separate component, Alertmanager.
Two things it is not. It is not a dashboard tool: the built-in UI is for running queries and checking targets, and most people put Grafana in front of it for graphs. The Prometheus vs Grafana comparison explains how the two divide the work. It is also not a log or trace store. If you want metrics, logs and traces in one product, look at SigNoz instead.
This guide deploys Prometheus next to node_exporter, the official exporter for host metrics (CPU, memory, disk, network), so you end up with a working target and real data rather than an empty database.
Server sizing
Prometheus's memory and disk use grow with the number of time series you scrape, not with the number of servers. One node_exporter produces around a thousand series. A busy application exporter can produce many more.
- RAM: our catalog entry lists 2 GB as the minimum for a real deployment. In our install check (GCP e2-standard-2, Ubuntu 26.04, September 2026), Prometheus scraping only itself sat at about 37 MB idle with about 364 MB of Docker disk. That figure is a floor. Every target you add raises it.
- Disk: the Prometheus docs estimate 1–2 bytes per sample and give the
formula
retention_time_seconds × ingested_samples_per_second × bytes_per_sample. For example, 10,000 series scraped every 15 seconds is about 667 samples per second. Over 30 days at 2 bytes per sample, that comes to about 3.5 GB. Leave room for the write-ahead log on top. - CPU: one vCPU is enough for a single-host setup like this one. Query-heavy dashboards are what use more CPU.
The placement rule for monitoring applies here too: a Prometheus server that lives on the machine it watches goes down with that machine. For anything beyond a personal box, give it its own small VPS. The Best VPS for Monitoring page ranks hosts for this.
Prepare the server
This guide assumes Docker Engine and the Compose plugin are installed. If not, work through Docker & Compose on Ubuntu first. Open SSH and the reverse proxy ports only:
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw --force enable
sudo ufw status verbose
Prometheus's port 9090 and node_exporter's port 9100 will listen on
127.0.0.1 only, so neither needs a firewall rule. Neither serves anything
you want the internet to read.
Paid link — we earn a commission if you shop through it.
Write the configuration
Prometheus reads prometheus.yml at startup. Create the project directory and
a config that scrapes Prometheus itself and the node_exporter you're about to
run:
mkdir -p ~/prometheus && cd ~/prometheus
cat > prometheus.yml <<'YAML'
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: prometheus
basic_auth:
username: admin
password_file: /etc/prometheus/admin-password
static_configs:
- targets: ["127.0.0.1:9090"]
- job_name: node
static_configs:
- targets: ["127.0.0.1:9100"]
YAML
The basic_auth block on the prometheus job is there because the next step
turns on authentication for Prometheus's own web server. Its self-scrape has to
log in like any other client.
Generate the admin password and its bcrypt hash. Prometheus only accepts bcrypt hashes in its web config, never plaintext:
cd ~/prometheus
sudo apt-get install -y apache2-utils
[ -f admin-password ] || openssl rand -hex 16 > admin-password
chmod 644 admin-password
chmod 700 ~/prometheus
HASH=$(htpasswd -nbBC 10 "" "$(cat admin-password)" | tr -d ':\n')
printf 'basic_auth_users:\n admin: %s\n' "$HASH" > web.yml
cat web.yml
The password is in ~/prometheus/admin-password. Read it with cat when you
first log in. It is readable inside the container because Prometheus runs as
the unprivileged nobody user and needs it for the self-scrape. The
chmod 700 on the directory keeps other accounts on the server away from it.
Install Prometheus and node_exporter (Docker Compose)
Both containers use the host network. node_exporter requires it, since it
has to see the host's real network interfaces and processes, as its README
explains. Running Prometheus the same way lets it reach node_exporter on
127.0.0.1 with no published ports:
cd ~/prometheus
cat > docker-compose.yml <<'YAML'
services:
prometheus:
image: prom/prometheus:v3.15.0
container_name: prometheus
restart: unless-stopped
network_mode: host
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=30d
- --web.listen-address=127.0.0.1:9090
- --web.config.file=/etc/prometheus/web.yml
- --web.enable-lifecycle
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./web.yml:/etc/prometheus/web.yml:ro
- ./admin-password:/etc/prometheus/admin-password:ro
- prometheus-data:/prometheus
node-exporter:
image: quay.io/prometheus/node-exporter:v1.12.1
container_name: node-exporter
restart: unless-stopped
network_mode: host
pid: host
command:
- --path.rootfs=/host
- --web.listen-address=127.0.0.1:9100
volumes:
- /:/host:ro,rslave
volumes:
prometheus-data:
YAML
The flags are worth reading once:
command:replaces the image's defaults entirely. The image's default command is--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus, so both are repeated here. Leave out--storage.tsdb.pathand the data ends up outside the volume, and a container recreate wipes it.--storage.tsdb.retention.time=30dsets how long data is kept. Without it (and without--storage.tsdb.retention.size) the default is 15 days. Choose the number on purpose.--web.enable-lifecyclelets you reload the config with an HTTP POST instead of a restart.--path.rootfs=/hosttells node_exporter that the host's filesystem is mounted at/host, so disk metrics describe the server and not the container.
Check the config with promtool (shipped in the same image) before you start
anything. A typo here otherwise shows up as a crash loop. The --user flag
runs the check as you, because the directory is now private to your account:
cd ~/prometheus
docker run --rm --user "$(id -u):$(id -g)" --entrypoint promtool \
-v "$PWD":/etc/prometheus:ro prom/prometheus:v3.15.0 \
check config /etc/prometheus/prometheus.yml
Start the stack and wait for Prometheus to report ready:
cd ~/prometheus
docker compose up -d
for i in $(seq 1 30); do
curl -fs -u "admin:$(cat admin-password)" http://127.0.0.1:9090/-/ready && break
sleep 2
done
docker compose ps
Confirm authentication is enforced and both targets are being scraped. The
first curl has no credentials and should print 401:
cd ~/prometheus
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9090/api/v1/targets
sleep 20
curl -s -u "admin:$(cat admin-password)" http://127.0.0.1:9090/api/v1/targets \
| grep -o '"health":"[a-z]*"'
Two "health":"up" lines mean both jobs are healthy. If node shows down,
node_exporter isn't running. Check docker compose logs node-exporter.
HTTPS + domain
Point an A record for metrics.example.com at the server, then put
Caddy in front of the loopback listener:
metrics.example.com {
reverse_proxy 127.0.0.1:9090
}
Caddy installed on the host reaches 127.0.0.1:9090 directly. If you run
Caddy as a container, 127.0.0.1 is the container's own loopback, so give
that container network_mode: host as well.
The browser now asks for the admin user and the password from
~/prometheus/admin-password. Basic auth sits in Prometheus itself, not in
Caddy, so it protects the API even for something on the box that bypasses the
proxy.
Add a target and reload
Adding a target means editing prometheus.yml. For example, a second server
running its own node_exporter:
- job_name: node-web1
static_configs:
- targets: ["10.0.0.12:9100"]
Exporters on other machines have to be reachable from this box and should not be reachable from anywhere else. Use a private network or a WireGuard tunnel, or firewall port 9100 to this server's IP only. Then validate and reload without a restart:
cd ~/prometheus
docker run --rm --user "$(id -u):$(id -g)" --entrypoint promtool \
-v "$PWD":/etc/prometheus:ro prom/prometheus:v3.15.0 \
check config /etc/prometheus/prometheus.yml
curl -s -X POST -u "admin:$(cat admin-password)" http://127.0.0.1:9090/-/reload
Backups
The config files are small and precious. The TSDB is large and replaceable, since losing it costs you history and not the setup. Back up both, and stop the container for a consistent copy of the data:
cd ~/prometheus
tar czf prometheus-config-$(date +%F).tar.gz prometheus.yml web.yml docker-compose.yml
docker compose stop prometheus
docker run --rm -v prometheus_prometheus-data:/data -v "$PWD":/backup alpine \
tar czf /backup/prometheus-data-$(date +%F).tar.gz -C /data .
docker compose start prometheus
ls -lh ~/prometheus/*.tar.gz
The volume name is prefixed with the compose project directory (prometheus_).
Confirm it with docker volume ls. Copy the archives off the box. The
password file is left out on purpose. Store it in your password manager instead.
Upgrades
Change the pinned tags in docker-compose.yml to the new releases, then:
cd ~/prometheus
docker compose pull
docker compose up -d
Read the release notes before a major version jump. Prometheus 3 changed the UI and some flag defaults compared with 2.x. The TSDB format is carried forward, but take a backup first.
Troubleshooting
The container restarts in a loop. Run docker compose logs prometheus.
The usual causes are a YAML error (run promtool check config), a
prometheus.yml that Docker created as a directory because the file didn't
exist at first start (delete it, write the file, recreate the container), or a
malformed web.yml.
Every request returns 401, even with the right password. The hash in
web.yml must be bcrypt. Regenerate it with htpasswd -nbBC 10 as above.
Plaintext and MD5 hashes are rejected.
The prometheus target is down with a 401. The self-scrape needs the
basic_auth block in prometheus.yml. The password file must be mounted and
readable by the nobody user inside the container.
Disk keeps growing. Check the retention flags and the series count
(prometheus_tsdb_head_series in the query box). A label with unbounded values,
such as user IDs or full URLs, is the classic cause.
Verification + next steps
You're done when https://metrics.example.com prompts for a password, the
target health page (under Status) shows both jobs up, and the query
node_memory_MemAvailable_bytes returns a value. Then restart the stack and
confirm the history is still there.
Next: add Grafana and point a Prometheus
data source at http://127.0.0.1:9090 with the same basic-auth credentials.
For a lighter all-in-one host monitor, see
Netdata. If you want logs and traces in the
same place as your metrics, see SigNoz.