# Can I self-host Axiom?

**YES** — it's called VictoriaLogs. ONE COMMAND setup · ~10 minutes to running · 1 GB RAM minimum · $25/mo you stop paying ($300/yr on the Axiom Cloud plan) — a metered rate, not a whole bill.

VictoriaLogs authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/axiom/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install VictoriaLogs 1.52.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.

Say three things first. VictoriaLogs has no accounts and no sign-in form, so a public hostname
with nothing in front hands every log line this box keeps to whoever loads the URL. Step 7 does not
finish until real container output has been shipped in. And this is logs only: no metrics, no
traces, no performance monitoring, no alert rules.

VictoriaLogs needs 1024 MB of RAM available and 20 GB free on /srv. The image publishes amd64,
arm64, arm/v7 and ppc64le. Measure all four, and confirm Caddy is 2.8 or newer:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
caddy version
```

If available RAM is under 1024 MB or free disk is under 20 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop. If Caddy predates 2.8, stop:
this install uses the `basic_auth` spelling that arrived there. The disk floor is high for one
container because a log store fills disk: this one caps data at 5 GiB, backups land beside it.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/victorialogs /srv/victorialogs/backups /srv/victorialogs/data
ls -la /srv/victorialogs
```

Assert: `data` and `backups` exist and are owned by the login user. `data` is the only thing the
container writes: it becomes `/vlogs` inside, and every ingested line lands there in a per-day
partition directory. No config directory, because there is no config file.

## 3. Secrets

One secret: the password Caddy checks before any request reaches VictoriaLogs. Generate it on the
server. Do not print it, repeat it in your summary, or put it in a log line.

```bash
umask 077
openssl rand -hex 24 > /srv/victorialogs/dashboard-password
chmod 600 /srv/victorialogs/dashboard-password
umask 022
ls -la /srv/victorialogs/dashboard-password
```

Assert: the file is mode `-rw-------`. Tell the user the browser login name is `vlogs`, that they
read the password with `sudo cat /srv/victorialogs/dashboard-password`, and that both belong in
their password manager now. Step 5 hashes it for Caddy; the plaintext stays because the restore
needs it. VictoriaLogs ships no admin account and no API token, so there is no second one.

## 4. compose.yml

```bash
cat > /srv/victorialogs/compose.yml <<'EOF'
# VictoriaLogs · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   docker image ... https://docs.victoriametrics.com/victorialogs/quickstart/
#   flags .......... https://docs.victoriametrics.com/victorialogs/
#   log driver ..... https://docs.victoriametrics.com/victorialogs/data-ingestion/splunk/
#
# One service, configured entirely by the `command:` list: there is no
# configuration file and no .env. Upstream's own demo under deployment/docker
# runs seven services; this keeps the one that stores and answers queries. No
# healthcheck: the image is distroless, so every assert runs from the host, and
# no login screen, so Caddy basic_auth is the door. Plain v1.52.0 is the open
# source build; step 9 covers the -enterprise tags. Digest read from Docker Hub
# on 2026-08-14; amd64, arm64, arm/v7 and ppc64le.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  victorialogs:
    image: victoriametrics/victoria-logs:v1.52.0@sha256:47b820890d64c4575a2a0a46415dcd8a4fd59a0f1fcd6a377693d7aea639442e
    container_name: victorialogs
    restart: unless-stopped
    command:
      # Absolute, so the mount below is the only place logs can land.
      - "-storageDataPath=/vlogs"
      # Upstream's default retention is 7 days. Thirty is the choice here.
      - "-retentionPeriod=30d"
      # The ceiling that protects the disk: past this the oldest per-day
      # partitions drop, whatever the retention period says.
      - "-retention.maxDiskSpaceUsageBytes=5GiB"
    volumes:
      # Every ingested line lands here, in per-day partition directories.
      - /srv/victorialogs/data:/vlogs
    ports:
      # Loopback only: Caddy from outside, the Docker log driver from inside.
      - "127.0.0.1:8190:9428"
EOF
cd /srv/victorialogs && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one port, one bind mount.

## 5. Caddy and TLS

The credential Caddy checks is a bcrypt hash of step 3's password, read from the file rather than
from an argument, so it never reaches the process list:

```bash
umask 077
caddy hash-password < /srv/victorialogs/dashboard-password > /srv/victorialogs/auth.hash
printf 'basic_auth {\n\tvlogs %s\n}\n' "$(cat /srv/victorialogs/auth.hash)" > /srv/victorialogs/auth.conf
umask 022
sudo install -m 640 -o root -g caddy /srv/victorialogs/auth.conf /etc/caddy/victorialogs-auth.conf
rm -f /srv/victorialogs/auth.hash /srv/victorialogs/auth.conf
sudo grep -c basic_auth /etc/caddy/victorialogs-auth.conf
```

Assert: that prints `1`. A `0` means `caddy hash-password` wrote nothing and the site block below
would publish an open log store, so stop there.

Then the site block, with `<DOMAIN>` replaced by the hostname from step 1. Copy the Caddyfile
first: a syntax error takes down every other site on this box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-victorialogs
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# VictoriaLogs · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.victoriametrics.com/victorialogs/security-and-lb/
# https://caddyserver.com/docs/automatic-https and
# https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. VictoriaLogs has no accounts and no sign-in form, and
# its one optional Basic Auth flag pair still leaves /health, /ping and
# /robots.txt open, so the door belongs here, where it covers every path.
# Needs Caddy 2.8 or newer.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Credential lives in /etc/caddy/victorialogs-auth.conf (not published here).
	import /etc/caddy/victorialogs-auth.conf

	# 8190 is the loopback port compose publishes. It is not a container port
	# and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8190
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: validate and reload both exit 0. If validate fails, restore
/etc/caddy/Caddyfile.before-victorialogs, reload, and report what it objected to.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8190 stays closed: it is bound to 127.0.0.1 and Caddy reaches it over loopback. Assert:
`Status: active`, rules for 80, 443/tcp and 443/udp, no rule for 8190 or 9428. Say plainly that any
process on this box reaches 127.0.0.1:8190 with no password. That is what lets step 7's log driver
write, and it is the edge of a single-tenant box.

## 7. Start and verify

VictoriaLogs creates its storage on first start. No migration step, no wizard, no first account.

```bash
cd /srv/victorialogs
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8190/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
printf '{"_msg":"caniselfhostit install check","service":"install-check"}\n' | curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/stream+json' --data-binary @- 'http://127.0.0.1:8190/insert/jsonline?_stream_fields=service'
sleep 3
curl -sS http://127.0.0.1:8190/select/logsql/query -d 'query={service="install-check"}' | grep -c 'caniselfhostit install check'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/select/vmui/
curl -sS -o /dev/null -w '%{http_code}\n' -u "vlogs:$(sudo cat /srv/victorialogs/dashboard-password)" https://<DOMAIN>/select/vmui/
```

Assert all five and print what you received. The loop ends on `200`, `/health` answering `OK`. The
ingest call prints `200`. The query prints `1`: a line went in over the JSON stream API and came
back out through LogsQL, which is the whole product. The unauthenticated public call prints `401`,
the security assert in this block; the authenticated one prints `200`. If any of the five misses,
stop, run `docker compose logs --tail 40 victorialogs`, and name the cause. A `502` with a running
container is Caddy. A `200` where `401` was expected means step 5's import line did not load and the
store is public. A `0` after a `200` ingest means the stream field does not match the JSON. A
running container is not success.

STOP: tell the user to open https://<DOMAIN>/select/vmui/ in a private window, sign in as `vlogs` with the password from /srv/victorialogs/dashboard-password, and confirm the query `*` returns the `caniselfhostit install check` line. Do not continue until they confirm.

Now ship real logs in. Docker's built-in `splunk` log driver writes to the Splunk HTTP Event
Collector paths VictoriaLogs answers, so nothing more is installed. Run
`docker ps --format '{{.Names}}'`. If nothing but `victorialogs` is listed, print the block below,
say where it goes later, and go to step 8. Otherwise:

STOP: ask the user which service should ship its output, and wait. Do not continue until they confirm.

Add that block to the service they name, in its own compose file, keep the keys already there, then
`docker compose up -d --force-recreate <service>`:

```yaml
    logging:
      driver: splunk
      options:
        splunk-url: "http://127.0.0.1:8190"
        splunk-token: "PLACEHOLDER"
        splunk-verify-connection: "false"
        tag: "{{.Name}}"
```

The token is required by the driver and ignored by VictoriaLogs, which reads no token there. The
connection check is off on purpose: the driver probes with an OPTIONS request at start-up and
expects `200`, VictoriaLogs answers every OPTIONS request with `204`, and a container whose probe
fails never starts. Never put this block on `victorialogs` itself. Then prove the loop:

```bash
before=$(curl -sS http://127.0.0.1:8190/select/logsql/query -d 'query=_time:10m' | wc -l)
sleep 20
after=$(curl -sS http://127.0.0.1:8190/select/logsql/query -d 'query=_time:10m' | wc -l)
echo "before=$before after=$after"
curl -sS http://127.0.0.1:8190/select/logsql/query -d 'query=_time:2m' | head -2
```

Assert: `after` is greater than `before`, and the last command prints a line of that service's own
output. If the two are equal the driver is not delivering: run
`docker inspect --format '{{.HostConfig.LogConfig}}' <container>` and check the URL.

## 8. First backup and restore

One archive: the log partitions, the compose file, the Caddy password, the live Caddyfile and the
auth conf. The container stops for it: a storage directory copied mid-merge is not a backup.
Downtime is a few seconds.

```bash
cd /srv/victorialogs
docker compose stop
sudo tar -czf /srv/victorialogs/backups/victorialogs-$(date +%F).tar.gz -C /srv/victorialogs data compose.yml dashboard-password -C /etc/caddy Caddyfile victorialogs-auth.conf
docker compose start
ls -lh /srv/victorialogs/backups/
```

Assert: the archive exists and is non-empty. Print its size. The only credential in it is
`dashboard-password`, so it is secret material. A backup on the same disk is not a backup, so run
this from the user's machine:

```bash
mkdir -p ~/backups/victorialogs
scp vps:/srv/victorialogs/backups/*.tar.gz ~/backups/victorialogs/
```

To restore cold on a box that has been through Prompt Zero: `docker compose down`, recreate the
directories as in step 2, untar into /srv/victorialogs, move `Caddyfile` and
`victorialogs-auth.conf` back under /etc/caddy, `sudo systemctl reload caddy`, then
`docker compose up -d`. Re-run step 7's `401` and `200` asserts. `data/` is every line kept and
`dashboard-password` is how the user gets back in, so missing the second is a lockout.

## 9. Updating later

New versions are at https://github.com/VictoriaMetrics/VictoriaLogs/releases, changelog at
https://docs.victoriametrics.com/victorialogs/changelog/. Stay on the plain tag: `-enterprise` and
`-enterprise-fips` in the same Docker Hub repository are a separate commercial build under its own
licence rather than the Apache-2.0 one, and they expect a licence key. Take a backup, then edit the
image line in compose.yml to the new tag and digest:

```bash
cd /srv/victorialogs
docker compose pull
docker compose up -d
docker compose logs --tail 30 victorialogs
```

Re-run step 7's checks first. Two numbers in `command:` are worth revisiting then:
`-retentionPeriod=30d` decides how far back a query reaches, and
`-retention.maxDiskSpaceUsageBytes=5GiB` drops the oldest days when the disk fills first. Raise
either, then watch `du -sh /srv/victorialogs/data`.

## 10. What will probably go wrong

The install will look finished and the product will be empty. I had a green health check, a signed
certificate, a working login box, and a query screen answering every question with zero results, and
I spent twenty minutes assuming my query language was wrong. Nothing was wrong. Nothing was
shipping. A log store is the one service where a correct install and a useless one look identical
from outside, because the useful half is configuration on containers you are not installing today.
The other failure: a container refuses to start after you attach the log driver, saying it failed to
initialize the logging driver. That is the connection probe this prompt turns off. Leave it on, or
aim the driver where VictoriaLogs is not listening, and the container never runs.

## 11. Out of scope

- Do not add Grafana, Vector, vmauth, vmalert or Alertmanager. Upstream's demo runs all five;
  this install is the log store alone.
- Do not set `-httpAuth.username` or `-httpAuth.password` on the container. Caddy is the door; a
  second half-covering one only looks like protection.
- Do not open 8190 in the firewall or rebind it to 0.0.0.0.
- Do not configure alerting rules. They need vmalert plus a notifier, a second install.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing VictoriaLogs 1.52.0 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read these three before step 1, because together they decide whether you want this. VictoriaLogs
has no accounts, no sign-in form and no roles, so a public hostname with nothing in front of it
hands every log line this box keeps to whoever loads the URL; step 3 and step 5 put a password in
Caddy and step 7 asserts it. Step 7 also does not finish until real container output is shipping
in, because an empty log database is not a log store and the shipping half is configuration on
containers this install does not touch. And this is logs only: no metrics, no traces, no
application performance monitoring, no alert rules.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
caddy version
```

You should see: at least `1024` MB available, at least `20` GB free on /srv, `amd64` or `arm64`
(the image also publishes arm/v7 and ppc64le), your server's IP address, and a Caddy version of
2.8 or newer.

If you do not: under-floor RAM or disk means stop rather than install and hope, and the disk floor
is high for one container because a log store is the thing that fills a disk. This install caps its
own data directory at 5 GiB, upstream asks for spare space around it, and the backup archives in
step 8 land on the same disk until you move them off, which is where the other 15 GB goes. An
empty `dig` answer means the A record has not been created or has not propagated; wait a few
minutes and run it again. A Caddy older than 2.8 does not know the `basic_auth` spelling this
install uses, so upgrade Caddy before going on rather than rewriting the site block.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/victorialogs /srv/victorialogs/backups /srv/victorialogs/data
ls -la /srv/victorialogs
```

You should see: `data` and `backups`, both owned by your login user. `data` becomes `/vlogs`
inside the container and holds every ingested line in per-day partition directories.

If you do not: a permission error means you are not in the sudo group, which Prompt Zero set up.
There is no config directory to create, because VictoriaLogs has no configuration file.

## 3. Secrets

One secret: the password Caddy checks before any request reaches VictoriaLogs.

```bash
umask 077
openssl rand -hex 24 > /srv/victorialogs/dashboard-password
chmod 600 /srv/victorialogs/dashboard-password
umask 022
ls -la /srv/victorialogs/dashboard-password
```

You should see: `-rw-------` on that file. Read it with
`sudo cat /srv/victorialogs/dashboard-password`. The login name for the browser box is `vlogs`.
Put both in your password manager now.

If you do not: a mode other than 600 means the `umask` line did not run, so re-run `chmod 600`
before going on.

Do not paste the contents of that file, or any command output containing it, back into this chat
window. Nothing on this path needs the value except your browser and step 7's own `curl`, and a
password pasted into a chat window belongs to whoever runs that chat window.

## 4. compose.yml

```bash
cat > /srv/victorialogs/compose.yml <<'EOF'
# VictoriaLogs · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   docker image ... https://docs.victoriametrics.com/victorialogs/quickstart/
#   flags .......... https://docs.victoriametrics.com/victorialogs/
#   log driver ..... https://docs.victoriametrics.com/victorialogs/data-ingestion/splunk/
#
# One service, configured entirely by the `command:` list: there is no
# configuration file and no .env. Upstream's own demo under deployment/docker
# runs seven services; this keeps the one that stores and answers queries. No
# healthcheck: the image is distroless, so every assert runs from the host, and
# no login screen, so Caddy basic_auth is the door. Plain v1.52.0 is the open
# source build; step 9 covers the -enterprise tags. Digest read from Docker Hub
# on 2026-08-14; amd64, arm64, arm/v7 and ppc64le.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  victorialogs:
    image: victoriametrics/victoria-logs:v1.52.0@sha256:47b820890d64c4575a2a0a46415dcd8a4fd59a0f1fcd6a377693d7aea639442e
    container_name: victorialogs
    restart: unless-stopped
    command:
      # Absolute, so the mount below is the only place logs can land.
      - "-storageDataPath=/vlogs"
      # Upstream's default retention is 7 days. Thirty is the choice here.
      - "-retentionPeriod=30d"
      # The ceiling that protects the disk: past this the oldest per-day
      # partitions drop, whatever the retention period says.
      - "-retention.maxDiskSpaceUsageBytes=5GiB"
    volumes:
      # Every ingested line lands here, in per-day partition directories.
      - /srv/victorialogs/data:/vlogs
    ports:
      # Loopback only: Caddy from outside, the Docker log driver from inside.
      - "127.0.0.1:8190:9428"
EOF
cd /srv/victorialogs && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK`. One service, one published port, one bind mount.

If you do not: `docker compose config` prints the line it objected to. A YAML error is almost
always an indentation change made while pasting, so paste the block again in one piece rather than
editing it line by line. If a later `docker compose pull` complains that the manifest digest does
not match, the tag and the digest in that image line have drifted apart, which means the line was
edited by hand; put both back exactly as written above rather than dropping the `@sha256:` part.

## 5. Caddy and TLS

First the credential Caddy checks, a bcrypt hash of the password from step 3. It is read from the
file rather than typed as an argument, so it never reaches the process list:

```bash
umask 077
caddy hash-password < /srv/victorialogs/dashboard-password > /srv/victorialogs/auth.hash
printf 'basic_auth {\n\tvlogs %s\n}\n' "$(cat /srv/victorialogs/auth.hash)" > /srv/victorialogs/auth.conf
umask 022
sudo install -m 640 -o root -g caddy /srv/victorialogs/auth.conf /etc/caddy/victorialogs-auth.conf
rm -f /srv/victorialogs/auth.hash /srv/victorialogs/auth.conf
sudo grep -c basic_auth /etc/caddy/victorialogs-auth.conf
```

You should see: `1`.

If you do not: a `0` means `caddy hash-password` wrote nothing, and the site block below would
then publish an open log store to the internet. Stop here and re-run this block before going on.

Now the site block. Replace `<DOMAIN>` with your hostname before you paste, and note that the
first command keeps a copy of the working Caddyfile, because a syntax error here takes down every
other site on the box:

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-victorialogs
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# VictoriaLogs · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.victoriametrics.com/victorialogs/security-and-lb/
# https://caddyserver.com/docs/automatic-https and
# https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. VictoriaLogs has no accounts and no sign-in form, and
# its one optional Basic Auth flag pair still leaves /health, /ping and
# /robots.txt open, so the door belongs here, where it covers every path.
# Needs Caddy 2.8 or newer.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Credential lives in /etc/caddy/victorialogs-auth.conf (not published here).
	import /etc/caddy/victorialogs-auth.conf

	# 8190 is the loopback port compose publishes. It is not a container port
	# and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8190
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from the reload.

If you do not: restore the copy with
`sudo cp /etc/caddy/Caddyfile.before-victorialogs /etc/caddy/Caddyfile`, reload, and read what
validate objected to. The usual cause is a `<DOMAIN>` left literal in the pasted block.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for 80/tcp, 443/tcp and 443/udp, and no rule mentioning
8190 or 9428.

If you do not: an inactive firewall means Prompt Zero did not finish; run `sudo ufw enable`. If
8190 appears, remove it with `sudo ufw delete allow 8190`, because Caddy reaches that port over
loopback and nothing else should. One thing to hold on to: every process on this box can reach
127.0.0.1:8190 with no password. That is what lets the log driver in step 7 write, and it is the
boundary of a single-tenant machine. If you ever share this box with someone whose access you
would not extend to your logs, that assumption stops holding and the loopback port becomes the
thing to close, not the hostname.

## 7. Start and verify

VictoriaLogs creates its storage on first start. No migration step, no wizard, no first account.

```bash
cd /srv/victorialogs
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8190/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
printf '{"_msg":"caniselfhostit install check","service":"install-check"}\n' | curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/stream+json' --data-binary @- 'http://127.0.0.1:8190/insert/jsonline?_stream_fields=service'
sleep 3
curl -sS http://127.0.0.1:8190/select/logsql/query -d 'query={service="install-check"}' | grep -c 'caniselfhostit install check'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/select/vmui/
curl -sS -o /dev/null -w '%{http_code}\n' -u "vlogs:$(sudo cat /srv/victorialogs/dashboard-password)" https://<DOMAIN>/select/vmui/
```

You should see, in order: the loop ending on `200`, which is `/health` answering `OK`; `200` from
the ingest call; `1` from the query, which is a line that went in over the JSON stream API coming
back out through LogsQL; `401` from the unauthenticated public call, which is the security check
in this step; and `200` from the authenticated one.

If you do not: a `502` with the container running means Caddy cannot reach 8190, so re-read
step 5. A `200` where you expected `401` means the `import` line did not load and your log store
is public to the internet, so fix that before anything else. A `0` from the query after a `200`
ingest means the insert was accepted but the `_stream_fields` name in the URL does not match the
JSON. For anything else, run `docker compose logs --tail 40 victorialogs`. A running container is
not success.

The browser tab is titled `UI for VictoriaLogs`. The query box is at the top, results underneath,
and the query language is LogsQL rather than SQL: `*` returns everything, a bare word matches the
message text, and `{service="install-check"}` filters on a stream field.

STOP: open https://<DOMAIN>/select/vmui/ in a private window, sign in as `vlogs` with the password from /srv/victorialogs/dashboard-password, and confirm the query `*` returns the `caniselfhostit install check` line. Do not continue until that line is on the screen.

Now ship real logs in. Docker's built-in `splunk` log driver writes to the Splunk HTTP Event
Collector paths VictoriaLogs answers, so nothing more has to be installed. List what is running
with `docker ps --format '{{.Names}}'`. If nothing but `victorialogs` comes back, keep the block
below for when you have a second service and go to step 8. Otherwise pick one of those services,
add this to it in its own compose file keeping the keys already there, and recreate that one
service with `docker compose up -d --force-recreate <service>`:

```yaml
    logging:
      driver: splunk
      options:
        splunk-url: "http://127.0.0.1:8190"
        splunk-token: "PLACEHOLDER"
        splunk-verify-connection: "false"
        tag: "{{.Name}}"
```

The token is required by the driver and ignored by VictoriaLogs, which reads no token on that
path. The connection check is off on purpose: the driver probes the destination with an HTTP
OPTIONS request at container start and expects `200`, VictoriaLogs answers every OPTIONS request
with `204`, and a container whose probe fails does not start at all. Never put this block on the
`victorialogs` service itself. Then prove the loop:

```bash
before=$(curl -sS http://127.0.0.1:8190/select/logsql/query -d 'query=_time:10m' | wc -l)
sleep 20
after=$(curl -sS http://127.0.0.1:8190/select/logsql/query -d 'query=_time:10m' | wc -l)
echo "before=$before after=$after"
curl -sS http://127.0.0.1:8190/select/logsql/query -d 'query=_time:2m' | head -2
```

You should see: `after` larger than `before`, and one or two JSON lines carrying that service's
own output.

If you do not: equal numbers mean the driver is not delivering. Check the URL with
`docker inspect --format '{{.HostConfig.LogConfig}}' <container>`, and confirm the service really
was recreated rather than only restarted, because a logging change lands on recreate.

## 8. First backup and restore

One archive: the log partitions, the compose file, the Caddy password, the live Caddyfile and the
auth conf. The container stops for it, because a storage directory copied while partitions are
being merged is not a backup.

```bash
cd /srv/victorialogs
docker compose stop
sudo tar -czf /srv/victorialogs/backups/victorialogs-$(date +%F).tar.gz -C /srv/victorialogs data compose.yml dashboard-password -C /etc/caddy Caddyfile victorialogs-auth.conf
docker compose start
ls -lh /srv/victorialogs/backups/
```

You should see: one `.tar.gz` with a non-zero size. Downtime is a few seconds.

If you do not: a `Cannot open: No such file` for `victorialogs-auth.conf` means step 5 did not
finish, so go back. A zero-byte archive means `data` was empty, which is possible only if step 7
never ingested anything.

The only credential in that archive is `dashboard-password`, so treat the file as secret material.
A backup on the same disk is not a backup, so run this on your own machine, not the server:

```bash
mkdir -p ~/backups/victorialogs
scp vps:/srv/victorialogs/backups/*.tar.gz ~/backups/victorialogs/
```

To restore cold on a box that has been through Prompt Zero: `docker compose down`, recreate the
directories exactly as in step 2, untar the archive into /srv/victorialogs, move `Caddyfile` and
`victorialogs-auth.conf` back under /etc/caddy, `sudo systemctl reload caddy`, then
`docker compose up -d`, and re-run step 7's `401` and `200` checks before believing any of it.
`data/` is every log line you kept and `dashboard-password` is how you get back in, so a restore
missing the second is a lockout even when the first is intact.

## 9. Updating later

New versions are at https://github.com/VictoriaMetrics/VictoriaLogs/releases, with the changelog
at https://docs.victoriametrics.com/victorialogs/changelog/. Stay on the plain tag: `-enterprise`
and `-enterprise-fips` in the same Docker Hub repository are a separate commercial build under its
own licence rather than the Apache-2.0 one, and they expect a licence key. Take a backup, then
edit the image line in /srv/victorialogs/compose.yml to the new tag and its digest:

```bash
cd /srv/victorialogs
docker compose pull
docker compose up -d
docker compose logs --tail 30 victorialogs
```

You should see: the new version in the start-up log, then `started VictoriaLogs`.

If you do not: a container that exits immediately after an update almost always names a
command-line flag it no longer accepts, in the last line of that log. Roll back by putting the
previous tag and digest into compose.yml and running the same three commands. Re-run step 7's
health, ingest and query checks before calling the update done. Two numbers in `command:` are
worth revisiting at the same time: `-retentionPeriod=30d` decides how far back a query can reach,
and `-retention.maxDiskSpaceUsageBytes=5GiB` drops the oldest days when the disk fills first.
Raise either, then watch `du -sh /srv/victorialogs/data` for a week before trusting the figure.

## 10. What will probably go wrong

The install will look finished and the product will be empty. I had a green health check, a signed
certificate, a working login box, and a query screen answering every question with zero results,
and I spent twenty minutes assuming my query language was wrong. Nothing was wrong. Nothing was
shipping. A log store is the one service where a correct install and a useless one look identical
from outside, because the useful half is configuration on containers you are not installing today.
The other failure: a container refuses to start after you attach the log driver, saying it failed
to initialize the logging driver. That is the connection probe this page turns off. Leave it on,
or aim the driver where VictoriaLogs is not listening, and the container never runs.

## 11. Out of scope

- Do not add Grafana, Vector, vmauth, vmalert or Alertmanager. Upstream's demo runs all five;
  this install is the log store alone.
- Do not set `-httpAuth.username` or `-httpAuth.password` on the container. Caddy is the door; a
  second half-covering one only looks like protection.
- Do not open 8190 in the firewall or rebind it to 0.0.0.0.
- Do not configure alerting rules. They need vmalert plus a notifier, a second install.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install VictoriaLogs 1.52.0 under ~/selfhost/victorialogs, answering at http://localhost:8190.

## 1. Preflight

Say this before step 2 runs, because it decides whether the user wants this install at all. A log
store holds only what is shipped to it, and this one answers at http://localhost:8190, which means
this computer and nothing else. It collects what containers on this machine say. It cannot reach a
VPS, and while the machine sleeps it collects nothing.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. VictoriaLogs needs 1024 MB of RAM available
and 20 GB free on the home disk, and the image publishes amd64, arm64, arm/v7 and ppc64le. If
available RAM is under 1024 MB or free disk is under 20 GB, print both and stop. The floor is high
for one container because a log store fills disk: this one caps data at 5 GiB.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/victorialogs/data ~/selfhost/victorialogs/backups
ls -la ~/selfhost/victorialogs
```

Assert: `data` and `backups` exist. `data` becomes `/vlogs` inside the container and holds every
ingested line in per-day partitions. There is no config directory: the flags in step 5 are the
whole configuration. No ownership fix is needed: the image declares no user, so the
process runs as root and writes world-readable partition directories, which lets step 8 archive
them without sudo.

## 4. Secrets

No secret is generated on this path and there is no `.env` file. The VPS path generates one
password because it puts a public hostname in front of a service with no login of its own; here
there is no hostname and no Caddy, so loopback is the whole door. Say plainly that anything on this
computer can read and write this log store with no credential, and the protection is that nothing
else reaches 8190.

## 5. compose.yml

```bash
cat > ~/selfhost/victorialogs/compose.yml <<'EOF'
# VictoriaLogs · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker image ... https://docs.victoriametrics.com/victorialogs/quickstart/
#   flags .......... https://docs.victoriametrics.com/victorialogs/
#   log driver ..... https://docs.victoriametrics.com/victorialogs/data-ingestion/splunk/
#
# One service on the computer you are sitting at. The data path is relative to
# ~/selfhost/victorialogs/, so one file works on macOS, Linux and Windows, and
# stays a bind mount so the partition directories are visible in Finder or
# Explorer. The `command:` list is the whole configuration: no config file, no
# .env. No healthcheck, because the image is distroless. Plain v1.52.0 is the
# open source build; step 9 covers the -enterprise tags. Digest read from
# Docker Hub on 2026-08-14; amd64, arm64, arm/v7 and ppc64le.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  victorialogs:
    image: victoriametrics/victoria-logs:v1.52.0@sha256:47b820890d64c4575a2a0a46415dcd8a4fd59a0f1fcd6a377693d7aea639442e
    container_name: victorialogs
    restart: unless-stopped
    command:
      # Absolute, so the mount below is the only place logs can land.
      - "-storageDataPath=/vlogs"
      # Upstream's default retention is 7 days. Thirty is the choice here.
      - "-retentionPeriod=30d"
      # The ceiling that protects the disk: past this the oldest per-day
      # partitions drop. A laptop disk fills sooner than a server one.
      - "-retention.maxDiskSpaceUsageBytes=5GiB"
    volumes:
      # Every ingested line lands here, in per-day partition directories.
      - ./data:/vlogs
    ports:
      # Loopback only: no other device on the wifi reaches 8190.
      - "127.0.0.1:8190:9428"
EOF
cd ~/selfhost/victorialogs && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one port, one bind mount.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname to
resolve, a certificate attests a public name and nothing here has one, and nothing is published
beyond loopback. Browsers treat http://localhost as a secure context, so the query page works
without TLS.

8190 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone on the internet. For a log store that is a fair trade, because what it collects
lives here too. Confirm the binding:

```bash
grep -c '"127.0.0.1:' ~/selfhost/victorialogs/compose.yml
```

Assert: that prints `1`. A `0` means the port line was edited and the store may be listening on
every interface with no password in front of it; stop and fix the compose file.

## 7. Start and verify

VictoriaLogs creates its storage on first start. No migration step, no wizard, no first account.

```bash
cd ~/selfhost/victorialogs
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8190/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
printf '{"_msg":"caniselfhostit install check","service":"install-check"}\n' | curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/stream+json' --data-binary @- 'http://localhost:8190/insert/jsonline?_stream_fields=service'
sleep 3
curl -sS http://localhost:8190/select/logsql/query -d 'query={service="install-check"}' | grep -c 'caniselfhostit install check'
curl -sSL http://localhost:8190/select/vmui/ | grep -c 'UI for VictoriaLogs'
```

Assert all four and print what you received. The loop ends on `200`, `/health` answering `OK`. The
ingest call prints `200`. The query prints `1`: a line went in over the JSON stream API and came
back out through LogsQL, which is the whole product. The last prints more than `0`, because
`UI for VictoriaLogs` is the title the query page carries. If any of the four misses, stop, run
`docker compose logs --tail 40 victorialogs`, and name the cause. If `port is already allocated`
came back, find what holds 8190 (`lsof -nP -iTCP:8190 -sTCP:LISTEN`, `ss -ltnp | grep 8190` on
Linux, `netstat -ano | findstr :8190` on Windows) and stop until it is freed. A running container
is not success.

STOP: tell the user to open http://localhost:8190/select/vmui/, run the query `*`, and confirm they see the `caniselfhostit install check` line. Do not continue until they confirm.

Now ship real logs in. Docker's built-in `splunk` log driver writes to the Splunk HTTP Event
Collector paths VictoriaLogs answers, so nothing more installs. Run
`docker ps --format '{{.Names}}'`. If nothing but `victorialogs` is listed, print the block below,
say where it goes later, and go to step 8. Otherwise:

STOP: ask the user which service should ship its output, and wait. Do not continue until they confirm.

Add that block to the service they name, in its own compose file, then
`docker compose up -d --force-recreate <service>`:

```yaml
    logging:
      driver: splunk
      options:
        splunk-url: "http://127.0.0.1:8190"
        splunk-token: "PLACEHOLDER"
        splunk-verify-connection: "false"
        tag: "{{.Name}}"
```

The token is required by the driver and ignored by VictoriaLogs, which reads no token there. The
connection check is off because the driver probes with an OPTIONS request at start-up expecting
`200`, VictoriaLogs answers OPTIONS with `204`, and a container whose probe fails never starts. Do
not put this block on `victorialogs` itself. Then prove the loop:

```bash
before=$(curl -sS http://localhost:8190/select/logsql/query -d 'query=_time:10m' | wc -l)
sleep 20
after=$(curl -sS http://localhost:8190/select/logsql/query -d 'query=_time:10m' | wc -l)
echo "before=$before after=$after"
curl -sS http://localhost:8190/select/logsql/query -d 'query=_time:2m' | head -2
```

Assert: `after` is greater than `before`, and the last command prints a line of that service's own
output. If the two are equal the driver is not delivering: check the URL with
`docker inspect --format '{{.HostConfig.LogConfig}}' <container>`.

## 8. First backup and restore

One archive: the log partitions and the compose file. The container stops for it, because a
storage directory copied mid-merge is not a backup. Downtime is a few seconds.

```bash
cd ~/selfhost/victorialogs
docker compose stop
tar -C ~/selfhost/victorialogs -czf ~/selfhost/victorialogs/backups/victorialogs-$(date +%F).tar.gz data compose.yml
docker compose start
ls -lh ~/selfhost/victorialogs/backups/
```

Assert: the archive exists and is non-empty. Print its size. There is no password file and no
`.env` here, so it holds logs and configuration, which is whatever those logs say.

That archive sits on the same disk as the data, which is not a backup, and on a laptop the disk and
the machine fail together. Ask the user for a destination that leaves this computer, a sync folder
or a USB stick, and copy it there with `cp`. In Git Bash a Windows drive is written `/d/Backups`,
not `D:\Backups`. Assert: the user confirms the filename is there. If they have nowhere, say that
this install has no backup.

To restore: `cd ~/selfhost/victorialogs`, `docker compose down`, `rm -rf data`, untar the archive
there, `docker compose up -d`, then re-run step 7's health and query asserts. On Linux the partition
files are owned by root, so that `rm -rf` needs `sudo`. The archive is the only copy of what the
containers that produced it have already rotated away.

## 9. Updating later

New versions are at https://github.com/VictoriaMetrics/VictoriaLogs/releases, changelog at
https://docs.victoriametrics.com/victorialogs/changelog/. Stay on the plain tag: `-enterprise` and
`-enterprise-fips` in the same Docker Hub repository are a separate commercial build under its own
licence rather than the Apache-2.0 one, and expect a licence key. Take a backup, then edit the
image line in compose.yml to the new tag and digest:

```bash
cd ~/selfhost/victorialogs
docker compose pull
docker compose up -d
docker compose logs --tail 30 victorialogs
```

Re-run step 7's checks first. Two numbers in `command:` are worth revisiting then:
`-retentionPeriod=30d` decides how far back a query reaches, and
`-retention.maxDiskSpaceUsageBytes=5GiB` drops the oldest days when the disk fills. Raise either,
then watch `du -sh ~/selfhost/victorialogs/data`.

## 10. What will probably go wrong

I closed the lid on a Friday and came back on Monday to a log store with nothing from the weekend.
Nothing was broken. The machine was asleep, the containers were not running, there was nothing to
collect, and the gap reads as a quiet weekend rather than an outage. The second thing is worse and
happens at the same moment: a container with the splunk log driver attached refuses to start if
VictoriaLogs is not up first, saying it failed to initialize the logging driver. Turn on Docker
Desktop's start-at-login, and after a reboot run `cd ~/selfhost/victorialogs && docker compose up -d`
before anything that ships to it.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8190 to 0.0.0.0 so another machine can ship logs here. That publishes a readable
  and writable log store, with no password, on every network this laptop joins.
- Do not add Grafana, Vector, vmauth, vmalert or Alertmanager, and do not configure alerting
  rules: those need vmalert and a notifier, a second install.
````

## docker-compose.yml

```yaml
# VictoriaLogs · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   docker image ... https://docs.victoriametrics.com/victorialogs/quickstart/
#   flags .......... https://docs.victoriametrics.com/victorialogs/
#   log driver ..... https://docs.victoriametrics.com/victorialogs/data-ingestion/splunk/
#
# One service, configured entirely by the `command:` list: there is no
# configuration file and no .env. Upstream's own demo under deployment/docker
# runs seven services; this keeps the one that stores and answers queries. No
# healthcheck: the image is distroless, so every assert runs from the host, and
# no login screen, so Caddy basic_auth is the door. Plain v1.52.0 is the open
# source build; step 9 covers the -enterprise tags. Digest read from Docker Hub
# on 2026-08-14; amd64, arm64, arm/v7 and ppc64le.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  victorialogs:
    image: victoriametrics/victoria-logs:v1.52.0@sha256:47b820890d64c4575a2a0a46415dcd8a4fd59a0f1fcd6a377693d7aea639442e
    container_name: victorialogs
    restart: unless-stopped
    command:
      # Absolute, so the mount below is the only place logs can land.
      - "-storageDataPath=/vlogs"
      # Upstream's default retention is 7 days. Thirty is the choice here.
      - "-retentionPeriod=30d"
      # The ceiling that protects the disk: past this the oldest per-day
      # partitions drop, whatever the retention period says.
      - "-retention.maxDiskSpaceUsageBytes=5GiB"
    volumes:
      # Every ingested line lands here, in per-day partition directories.
      - /srv/victorialogs/data:/vlogs
    ports:
      # Loopback only: Caddy from outside, the Docker log driver from inside.
      - "127.0.0.1:8190:9428"
```

## compose.local.yml

```yaml
# VictoriaLogs · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker image ... https://docs.victoriametrics.com/victorialogs/quickstart/
#   flags .......... https://docs.victoriametrics.com/victorialogs/
#   log driver ..... https://docs.victoriametrics.com/victorialogs/data-ingestion/splunk/
#
# One service on the computer you are sitting at. The data path is relative to
# ~/selfhost/victorialogs/, so one file works on macOS, Linux and Windows, and
# stays a bind mount so the partition directories are visible in Finder or
# Explorer. The `command:` list is the whole configuration: no config file, no
# .env. No healthcheck, because the image is distroless. Plain v1.52.0 is the
# open source build; step 9 covers the -enterprise tags. Digest read from
# Docker Hub on 2026-08-14; amd64, arm64, arm/v7 and ppc64le.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  victorialogs:
    image: victoriametrics/victoria-logs:v1.52.0@sha256:47b820890d64c4575a2a0a46415dcd8a4fd59a0f1fcd6a377693d7aea639442e
    container_name: victorialogs
    restart: unless-stopped
    command:
      # Absolute, so the mount below is the only place logs can land.
      - "-storageDataPath=/vlogs"
      # Upstream's default retention is 7 days. Thirty is the choice here.
      - "-retentionPeriod=30d"
      # The ceiling that protects the disk: past this the oldest per-day
      # partitions drop. A laptop disk fills sooner than a server one.
      - "-retention.maxDiskSpaceUsageBytes=5GiB"
    volumes:
      # Every ingested line lands here, in per-day partition directories.
      - ./data:/vlogs
    ports:
      # Loopback only: no other device on the wifi reaches 8190.
      - "127.0.0.1:8190:9428"
```

## Caddyfile

```text
# VictoriaLogs · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.victoriametrics.com/victorialogs/security-and-lb/
# https://caddyserver.com/docs/automatic-https and
# https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. VictoriaLogs has no accounts and no sign-in form, and
# its one optional Basic Auth flag pair still leaves /health, /ping and
# /robots.txt open, so the door belongs here, where it covers every path.
# Needs Caddy 2.8 or newer.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Credential lives in /etc/caddy/victorialogs-auth.conf (not published here).
	import /etc/caddy/victorialogs-auth.conf

	# 8190 is the loopback port compose publishes. It is not a container port
	# and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8190
}
```

## install.sh

```bash
#!/usr/bin/env bash
# VictoriaLogs · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=logs.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.victoriametrics.com/victorialogs/quickstart/
#   https://docs.victoriametrics.com/victorialogs/
#   https://docs.victoriametrics.com/victorialogs/security-and-lb/
#   https://docs.victoriametrics.com/victorialogs/data-ingestion/splunk/
#   https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# One secret is generated: the Caddy basic_auth password. VictoriaLogs itself
# has no accounts, no sign-in form and no first-run wizard, so Caddy is the only
# door on the public hostname. Apache-2.0; the plain image tag is the open
# source build, and the -enterprise tags in the same Docker Hub repository are a
# separate commercial product this script does not use.
#
# This script installs the log store. It does not make anything ship logs to it;
# the summary at the end says where that block goes.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/victorialogs}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# curl's own -w writes 000 on a failed connection; the || keeps set -e from
# ending the script before the assert below can name what it received.
http_code() {
	local out
	out="$(curl -sS -o /dev/null -w '%{http_code}' "$@")" || out="000"
	printf '%s' "$out"
}

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. logs.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

caddy_ver="$(caddy version | head -1 | cut -d' ' -f1 | sed 's/^v//')"
caddy_major="${caddy_ver%%.*}"
caddy_rest="${caddy_ver#*.}"
caddy_minor="${caddy_rest%%.*}"
if [ "$caddy_major" -lt 2 ] || { [ "$caddy_major" -eq 2 ] && [ "$caddy_minor" -lt 8 ]; }; then
	die "caddy ${caddy_ver} predates 2.8, where the basic_auth directive this install uses arrived"
fi

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; this install wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; a log store wants 20 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}')" || resolved=""
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Layout ---------------------------------------------------------------
#
# data/ becomes /vlogs inside the container and is the only thing it writes.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" \
	"$APP_DIR" "$APP_DIR/backups" "$APP_DIR/data"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Password for Caddy basic_auth ----------------------------------------

if [ ! -f "$APP_DIR/dashboard-password" ]; then
	umask 077
	openssl rand -hex 24 > "$APP_DIR/dashboard-password"
	chmod 600 "$APP_DIR/dashboard-password"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy auth conf + site block -----------------------------------------

umask 077
caddy hash-password < "$APP_DIR/dashboard-password" > "$APP_DIR/auth.hash"
printf 'basic_auth {\n\tvlogs %s\n}\n' "$(cat "$APP_DIR/auth.hash")" > "$APP_DIR/auth.conf"
umask 022
sudo install -m 640 -o root -g caddy "$APP_DIR/auth.conf" /etc/caddy/victorialogs-auth.conf
rm -f "$APP_DIR/auth.hash" "$APP_DIR/auth.conf"
sudo grep -q basic_auth /etc/caddy/victorialogs-auth.conf \
	|| die "the auth conf has no basic_auth block; publishing the site block now would expose an open log store"

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-victorialogs"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Firewall -------------------------------------------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8190 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start and assert -----------------------------------------------------

docker compose pull
docker compose up -d

echo "==> waiting for http://127.0.0.1:8190/health"
code=""
for _ in $(seq 1 30); do
	code="$(http_code "http://127.0.0.1:8190/health")"
	if [ "$code" = "200" ]; then break; fi
	sleep 5
done
[ "$code" = "200" ] || die "loopback /health answered ${code}. Check: docker compose logs --tail 40 victorialogs"

ingest="$(printf '{"_msg":"caniselfhostit install check","service":"install-check"}\n' \
	| curl -sS -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/stream+json' \
	  --data-binary @- 'http://127.0.0.1:8190/insert/jsonline?_stream_fields=service')" || ingest="000"
echo "==> jsonline ingest -> ${ingest}"
[ "$ingest" = "200" ] || die "the jsonline ingest endpoint answered ${ingest}, not 200"

sleep 3
found="$(curl -sS "http://127.0.0.1:8190/select/logsql/query" -d 'query={service="install-check"}' \
	| grep -c 'caniselfhostit install check')" || found="0"
echo "==> query returned ${found} matching line(s)"
[ "$found" -ge 1 ] || die "the ingested line did not come back from /select/logsql/query; the insert was accepted but nothing is queryable"

unauth="$(http_code "https://${DOMAIN_HOST}/select/vmui/")"
echo "==> unauthenticated https://${DOMAIN_HOST}/select/vmui/ -> ${unauth}"
[ "$unauth" = "401" ] || die "unauthenticated public request returned ${unauth}, not 401. The log store is reachable without a password; fix Caddy before using this."

auth="$(http_code -u "vlogs:$(cat "$APP_DIR/dashboard-password")" "https://${DOMAIN_HOST}/select/vmui/")"
echo "==> authenticated https://${DOMAIN_HOST}/select/vmui/ -> ${auth}"
[ "$auth" = "200" ] || die "authenticated public request returned ${auth}, not 200"

# --- 7. First backup ---------------------------------------------------------
#
# Stopped on purpose: a storage directory copied while partitions are being
# merged is not a backup. Downtime is a few seconds.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/victorialogs-${STAMP}.tar.gz" \
	-C "$APP_DIR" data compose.yml dashboard-password \
	-C /etc/caddy Caddyfile victorialogs-auth.conf
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/victorialogs-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	VictoriaLogs is answering at https://${DOMAIN_HOST}/select/vmui/

	  1. Sign in as vlogs. Read the password with:
	       sudo cat ${APP_DIR}/dashboard-password
	     VictoriaLogs has no account of its own; Caddy is the door, and the
	     401 and 200 above are the proof it is shut and openable.
	  2. Nothing ships logs here yet. Add this to a service in its own compose
	     file, keep the keys already there, then recreate that one service with
	     docker compose up -d --force-recreate <service>:

	       logging:
	         driver: splunk
	         options:
	           splunk-url: "http://127.0.0.1:8190"
	           splunk-token: "PLACEHOLDER"
	           splunk-verify-connection: "false"
	           tag: "{{.Name}}"

	     The token is ignored by VictoriaLogs. The connection check is off
	     because the driver expects a 200 to an OPTIONS probe and VictoriaLogs
	     answers 204, which would stop the container from starting. Never put
	     this block on the victorialogs service itself.
	  3. Then confirm lines are landing:
	       curl -sS http://127.0.0.1:8190/select/logsql/query -d 'query=_time:5m'
	  4. Retention is 30 days with a 5 GiB ceiling on ${APP_DIR}/data, both set
	     in compose.yml. Watch: du -sh ${APP_DIR}/data
	  5. First backup at ${APP_DIR}/backups. It carries the password file, so
	     treat it as secret. Copy it off this disk tonight.
	  6. NOT YET VERIFIED on a clean harness machine.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/axiom/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
