Self-host on a cheap VPS
A complete deployment on a 1 vCPU / 512 MB machine: a service that restarts itself without root and without systemd, a certificate the server gets for itself, and backups you can restore from. The server is a single self-contained executable — DuckDB is compiled into it — so deploying it means copying one file onto the box.
What the box needs
| Resource | Amount | Notes |
|---|---|---|
| vCPU | 1 | Plenty. The server is idle between flushes. |
| RAM | 512 MB | DuckDB is capped at 128 MB by default so the OOM killer stays away. |
| Disk | 10 GB | The database is small; page views are a few dozen bytes each. |
| OS | Debian 12 / Ubuntu 22.04+ | Any Linux works. Alpine needs libstdc++ and libgcc. |
| Programs | one executable | sumimi — the server and the analysis CLI in one program. Self-contained: DuckDB is compiled in. |
DuckDB's memory limit is what makes 512 MB viable. Set it explicitly if you want to be sure:
SUMIMI_DUCKDB_MEMORY_LIMIT=256MB
And set this one too — it is not a Sumimi setting, and it is worth more than all the others together:
MALLOC_MMAP_THRESHOLD_=65536
glibc keeps the pages of a freed large block instead of returning them to the operating system, so the peak never comes back down. This tells it to map anything over 64 KB instead, and the pages go back as soon as the block is freed: measured on the same server, at the same rate, −12% to −21% of the peak. The cost is a syscall per large block, which is about 5% of the throughput — it is a knob, not a free win. The container image sets it for you. It is a glibc setting, so on macOS it does nothing.
Other knobs worth knowing about on a small box:
SUMIMI_MAX_QUEUE(default100000) — how many events may be buffered in memory before the oldest are dropped. Under overload the server drops data rather than dying; the counter shows up in/api/heartbeat.SUMIMI_FLUSH_INTERVAL_SECS(default2) — how often the writer thread commits a batch. Two seconds keeps disk I/O low and means the dashboard lags by at most that.SUMIMI_MAX_BATCH(default1000) — how many events one write may hold. The batch is what the write path costs in memory, so it is also what the writer holds on purpose while it waits.
Option A: a container
If the box has Docker, this is the shortest path: one container, one volume, and Docker restarts it when it dies. The install guide has the command.
docker run -d --name sumimi \
--restart unless-stopped \
-p 3000:3000 -p 443:443 \
-v sumimi-data:/data \
-e SUMIMI_SSL_DOMAIN=stats.example.com \
-e SUMIMI_SSL_EMAIL=you@example.com \
ghcr.io/sumimi-dev/sumimi:latest
The rest of this page is for the case where you would rather not run a container: one executable and one directory.
Option B: one executable, run as a service
This is the default when there is no container runtime. sumimi service start
runs the server under a supervisor — a second process that owns the
server and restarts it when it dies. No systemd, no init script, no root: everything
lives inside the instance directory, so it runs as the user who installed it.
1. Create the user and the directories
Run the server as its own unprivileged user. It never needs root and it never needs to write anywhere except its data directory.
sudo useradd --system --home /var/lib/sumimi --shell /usr/sbin/nologin sumimi
sudo mkdir -p /var/lib/sumimi
sudo chown sumimi:sumimi /var/lib/sumimi
sudo chmod 0750 /var/lib/sumimi
Or skip the dedicated user and run it as yourself — that works too, and it is what the
--dir ~/sumimi form of the commands below does.
2. Install the executable
# download and unpack the release archive
cd /tmp
curl -fsSL https://github.com/sumimi-dev/sumimi/releases/latest/download/sumimi-linux-amd64.tar.gz | tar xz
# install the one executable — that is all there is to do
sudo install -m 0755 sumimi /usr/local/bin/
# the js/ and docs/ directories from the archive (see below)
sudo install -d -m 0755 /opt/sumimi
sudo cp -r js docs /opt/sumimi/
One file, nothing to configure
DuckDB is compiled into the executable, so sumimi is completely
self-contained: there is no libduckdb.so to install, no
LD_LIBRARY_PATH to set and no ldconfig to run. Copy one file
onto the box and it runs.
The archive also ships sumimi-server and sumimi-client as
symlinks to sumimi, for people who prefer the explicit names — they are
the same file.
The archive also contains two directories: docs/, which the server serves as
this documentation site at /docs, and js/, which holds the
dashboard frontend. You only need js/ if you serve the frontend from the
backend yourself with SUMIMI_APP_URL=/app — by default the frontend comes
from a CDN. The tracker is compiled into the binary, so
/sumimi.js works whether or not you copy the directory across.
3. Start it
One command, with the domain the certificate is issued for:
sudo -u sumimi sumimi service start \
--dir /var/lib/sumimi \
--domain stats.example.com \
--email you@example.com
The service is then managed with four commands:
sumimi service status --dir /var/lib/sumimi
sumimi service restart --dir /var/lib/sumimi
sumimi service stop --dir /var/lib/sumimi
| The command | What it does |
|---|---|
start | Starts the supervisor, which starts the server. It refuses to start twice. |
status | Whether it is running, for how long, how many times it has been restarted, where the log is. |
stop | Asks the server to shut down gracefully — the writer flushes, the instance store is checkpointed — and waits for it. |
restart | Stops it and starts it again, with the same arguments. |
The server's output goes to /var/lib/sumimi/service/log, so
tail -f on it is the log:
tail -f /var/lib/sumimi/service/log
4. What happens when it dies
The supervisor watches the server. If the server exits — the OOM killer, a segfault, an unhandled error — the supervisor starts a new one, with a backoff: a second, then two, then four, up to thirty. If the server dies five times in a row without ever staying up for ten seconds, the supervisor gives up and says so, instead of restarting a broken thing forever.
Two processes, on purpose
A supervisor inside the same process would be useless: the process that is killed by the kernel is gone, and nothing inside it can bring it back. That is why the server is a child of a small supervisor, and why the supervisor is a separate executable that survives it.
The state lives in three files inside the instance directory, so status
works without any privileges:
/var/lib/sumimi/
├── data.duckdb the events
├── instance.sqlite the accounts, the log, the imports, the cache
├── acme/ the TLS certificate cache
├── jwt-secret the signing secret
└── service/
├── status.json the state, rewritten every heartbeat
├── log the server's output
└── stop the stop request
TLS — the certificate
This is the part that usually takes the longest on a cheap VPS, so Sumimi does it itself. There are two ways, and they are alternatives.
Option A: let Sumimi manage TLS itself (the default)
With a domain, the server asks Let's Encrypt for a certificate over ACME and renews it in the background. There is no certbot, no renewal cron job and no reverse proxy whose only job is TLS — the challenge is TLS-ALPN-01, so port 443 has to reach the Sumimi process directly.
# as a service (what the start command above does)
sumimi service start --dir /var/lib/sumimi \
--domain stats.example.com --email you@example.com
# or in the foreground
sumimi serve --dir /var/lib/sumimi \
--ssl stats.example.com --ssl-email you@example.com
Two things have to be true: the domain has to point at the host, and port 443 has to
reach the process. A DNS record that has not propagated yet is the most common reason
for a failed first issuance — check with dig +short stats.example.com.
The certificate cache lives in acme/ inside the instance directory — back it
up with the rest, or the server has to ask for a new one.
Option B: put a reverse proxy in front
If something else on the box already owns port 443, or you want a proxy for other
reasons, put one in front. The server binds to 127.0.0.1, so nothing on
the internet can reach it directly; the proxy terminates TLS and forwards plain HTTP.
Caddy
Caddy obtains and renews a Let's Encrypt certificate on its own — no cron, no certbot. This is the shortest correct configuration.
sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
stats.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3999
}
EOF
sudo systemctl reload caddy
nginx
sudo tee /etc/nginx/sites-available/sumimi >/dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name stats.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name stats.example.com;
ssl_certificate /etc/letsencrypt/live/stats.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stats.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
location / {
proxy_pass http://127.0.0.1:3999;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# the tracker is a static file; let it cache briefly
location = /sumimi.js {
proxy_pass http://127.0.0.1:3999;
proxy_set_header Host $host;
add_header Cache-Control "public, max-age=300";
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/sumimi /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
With nginx, get the certificate with certbot:
sudo certbot --nginx -d stats.example.com
certbot installs a timer that renews the certificate twice a day and reloads nginx.
ACME in general
Either way the certificate comes from Let's Encrypt over ACME, and renewal is automatic. Two things are required for it to work: port 80 must be reachable from the internet, and the domain must already point at your server's IP address.
If port 80 is closed, use the DNS-01 challenge instead (certbot with a DNS plugin, or
acme.sh --dns). That works with any DNS provider and needs no inbound port.
Sumimi's own certificate uses TLS-ALPN-01, which needs port 443.
The firewall
Only the proxy needs to be reachable. Everything else stays closed.
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
Port 3000 (or 3999) is deliberately not opened: with a reverse proxy, the server is bound to loopback and is only reachable through the proxy.
If you let Sumimi manage TLS itself, port 443 has to reach the Sumimi process directly, so bind the server to a public address and open the port:
# the service binds to every address by default; if you bound it
# to loopback, change that
sumimi service stop --dir /var/lib/sumimi
sumimi service start --dir /var/lib/sumimi \
--domain stats.example.com --email you@example.com --host 0.0.0.0
Port 80 can stay closed — the ACME challenge Sumimi uses runs over port 443.
Backups
An instance is a directory, so a backup is one directory copy. It holds
the events (data.duckdb), the accounts, the log and the cache
(instance.sqlite), the signing secret (jwt-secret), the logs and
the TLS cache. Stop the service first so the copy is consistent — DuckDB's write-ahead
log means a live copy can otherwise be incomplete. There is no library to back up: the
executables are self-contained, so only the instance directory needs copying.
# a nightly snapshot
sudo tee /usr/local/bin/sumimi-backup >/dev/null <<'EOF'
#!/bin/sh
set -eu
DEST=/var/backups/sumimi
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p "$DEST"
sumimi service stop --dir /var/lib/sumimi
# the events, the accounts and the signing secret
cp -a /var/lib/sumimi/data.duckdb "$DEST/data-$STAMP.duckdb"
cp -a /var/lib/sumimi/instance.sqlite "$DEST/instance-$STAMP.sqlite"
# the write-ahead log, if the server was killed instead of stopped —
# it holds whatever was written since the last checkpoint
cp -a /var/lib/sumimi/instance.sqlite-wal "$DEST/" 2>/dev/null || true
cp -a /var/lib/sumimi/jwt-secret "$DEST/" 2>/dev/null || true
# the ACME account and certificates, if Sumimi manages TLS itself
cp -a /var/lib/sumimi/acme "$DEST/" 2>/dev/null || true
sumimi service start --dir /var/lib/sumimi
find "$DEST" -name 'data-*.duckdb' -mtime +30 -delete
EOF
sudo chmod 0755 /usr/local/bin/sumimi-backup
sudo tee /etc/cron.d/sumimi-backup >/dev/null <<'EOF'
30 3 * * * root /usr/local/bin/sumimi-backup
EOF
sudo chmod 0644 /etc/cron.d/sumimi-backup
Keep the copies off the box as well — rsync to another machine, or
restic to object storage you control. A backup that lives on the same disk
is not a backup.
Restoring
sumimi service stop --dir /var/lib/sumimi
cp -a /var/backups/sumimi/data-20260115T033000Z.duckdb /var/lib/sumimi/data.duckdb
cp -a /var/backups/sumimi/instance-20260115T033000Z.sqlite /var/lib/sumimi/instance.sqlite
chown sumimi:sumimi /var/lib/sumimi/data.duckdb /var/lib/sumimi/instance.sqlite
sumimi service start --dir /var/lib/sumimi
curl -s http://127.0.0.1:3000/api/heartbeat
Option C: let systemd manage it
Some boxes are better left to the system. A systemd unit is four lines of configuration
and it gives you the log in journalctl and the restart policy in one place.
The server does not care which of the two you choose.
The environment file
Secrets do not belong in a unit file or in the repository. Put them in a file only the service user can read.
sudo install -d -m 0750 /etc/sumimi
sudo tee /etc/sumimi/sumimi.env >/dev/null <<'EOF'
# the instance directory: the events, the accounts, the logs, the secret
SUMIMI_DIR=/var/lib/sumimi
SUMIMI_HOST=127.0.0.1
SUMIMI_PORT=3999
# a long random string; mint one with: openssl rand -hex 32
SUMIMI_JWT_SECRET=replace-me-with-64-hex-characters
SUMIMI_ADMIN_USER=admin
SUMIMI_ADMIN_PASSWORD=replace-me-with-a-long-passphrase
# keep events for a year, then prune
SUMIMI_RETENTION_DAYS=365
# stay inside a 512 MB box
SUMIMI_DUCKDB_MEMORY_LIMIT=256MB
SUMIMI_MAX_QUEUE=100000
SUMIMI_FLUSH_INTERVAL_SECS=2
# give the pages of a large block back to the OS instead of keeping them:
# -12% to -21% of the peak, for about 5% of the throughput (Linux only)
MALLOC_MMAP_THRESHOLD_=65536
# directory holding the dashboard frontend and this docs site
SUMIMI_JS_DIR=/opt/sumimi/js
SUMIMI_DOCS_DIR=/opt/sumimi/docs
# the dashboard frontend comes from a CDN by default; uncomment this to
# serve it from the backend's own js/ directory instead (air-gapped installs)
# SUMIMI_APP_URL=/app
# let Sumimi manage TLS itself (see the TLS section above)
# SUMIMI_SSL_DOMAIN=stats.example.com
# SUMIMI_SSL_EMAIL=you@example.com
EOF
sudo chown root:sumimi /etc/sumimi/sumimi.env
sudo chmod 0640 /etc/sumimi/sumimi.env
If you leave SUMIMI_JWT_SECRET unset, the server generates a random secret
on first run and writes it in the instance directory
(/var/lib/sumimi/jwt-secret, mode 0600). That is
fine, but setting it explicitly means the secret survives a move to another machine.
The unit
sudo tee /etc/systemd/system/sumimi.service >/dev/null <<'EOF'
[Unit]
Description=Sumimi web analytics
Documentation=https://sumimi.dev/docs/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=sumimi
Group=sumimi
EnvironmentFile=/etc/sumimi/sumimi.env
ExecStart=/usr/local/bin/sumimi serve
Restart=on-failure
RestartSec=5
# hardening: the service only ever needs to write its own data directory
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
ReadWritePaths=/var/lib/sumimi
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now sumimi
sudo systemctl status sumimi
The command line is intentionally empty of flags: everything comes from the
environment file, so sudo systemctl edit sumimi is enough to change a
setting on a live box.
sudo journalctl -u sumimi -f
On the very first start the server creates the admin user. If
SUMIMI_ADMIN_PASSWORD was not set, the generated password is printed to the
log exactly once:
[sumimi] created the admin user 'admin'
[sumimi] password: 8f3c1b9d0e7a4f26b5c8d1e3a9f7b2c4
[sumimi] store it now — it is not shown again
Upgrades
Replace the executable and restart. The database schema is additive — a new version opens an old database without a migration step — and the data stays where it is.
# download the new release
cd /tmp
curl -fsSL https://github.com/sumimi-dev/sumimi/releases/latest/download/sumimi-linux-amd64.tar.gz | tar xz
# stop, replace, start
sumimi service stop --dir /var/lib/sumimi
sudo install -m 0755 sumimi /usr/local/bin/
sumimi service start --dir /var/lib/sumimi
Take a backup first — it costs nothing and it is the only thing you cannot get back.
Sumimi is an independent open-source project, an open-source alternative to Umami that implements the Umami v2 HTTP API. Apache-2.0 licensed.