Deployment guide

Jellystat 1.1.12

Overview

Jellystat is a free, open-source statistics application for Jellyfin (MIT License). Connect it to your Jellyfin server with an API key, and it records what was played, by whom, on which device and for how long. Dashboards cover libraries, users, activity and watch statistics, with a live view of current sessions and built-in backups.

This guide covers launching and verifying Jellystat 1.1.12 on Ubuntu 24.04 from SecureAnyCloud on Amazon EC2. It is one version of the SecureAnyCloud Hardened SRS Streaming Media Server & Media Servers AMIs listing on AWS Marketplace. You choose it as the software version when you subscribe and launch, and the listing's guide hub describes the other media and streaming products. Jellystat keeps its history in its own PostgreSQL database, so the history survives Jellyfin's log rotation.

What you get:

  • Jellystat 1.1.12, built from the upstream release tarball (checked against its SHA-256 and the release commit) with Node.js 22 from NodeSource. Build tools and development packages are removed, and the shipped npm tree had no high or critical advisory at build time
  • Jellystat on 127.0.0.1:3000 only, behind nginx serving HTTPS on port 443 with a certificate created on your instance. Port 80 redirects to HTTPS
  • PostgreSQL 16 from Ubuntu, listening on 127.0.0.1:5432 only, with a per-instance password for Jellystat's database user
  • The setup wizard already completed on first boot: the admin administrator has a per-instance password, so nobody else can claim the instance
  • jellystat-set-hostname, a helper that serves Jellystat under your own DNS name

Default user: ubuntu. Architecture: x86_64. Root volume: 12 GiB gp3.

Security posture

  • No open setup wizard. Upstream Jellystat starts with a wizard. Whoever reaches it first creates the admin administrator, and until a Jellyfin server is saved, the account-creation call stays open and can replace that administrator. On this image, first boot creates admin through Jellystat's own API with a password generated on your instance, and finishes setup before nginx starts, so the wizard is never reachable. Jellystat itself refuses a second administrator, and nginx never forwards the account-creation and setup calls (both answer 403). The server address saved at first boot is the default http://jellyfin.invalid until you enter your own in Step 6.
  • Per-instance secrets. First boot generates the administrator password, the PostgreSQL password for Jellystat's database user, the secret that signs Jellystat's login tokens (JWT_SECRET) and the TLS key on your instance. The image ships with an empty database and a database user that has no password, so no two instances share a secret or a session.
  • Fails closed. Jellystat (jellystat.service) starts only after first boot has written its environment file, and nginx does not start until first boot has finished. If first boot cannot finish, nothing is served.
  • Only nginx faces the network. Exactly ports 22, 80 and 443 listen beyond loopback. Jellystat listens on 127.0.0.1:3000 and PostgreSQL on 127.0.0.1:5432 (password logins with SCRAM-SHA-256), so neither is reachable from another machine even if your security group allows it. Jellystat runs as the unprivileged jellystat user under a systemd sandbox (read-only system, no home directories, no new privileges, its own /tmp, no capabilities); its code under /opt/jellystat is owned by root, and it can write only its backup folder and the small settings file it hands to the browser.
  • Every API call needs a sign-in. Jellystat's API answers 401 without a valid token. Upstream also serves four Jellyfin helper routes (/proxy/getSessions, /proxy/getAdminUsers, /proxy/getRecentlyAdded and /proxy/validateSettings) without any sign-in, which would let anyone see who is watching what and make the server send requests for them. On this image nginx answers those with 401 unless the request carries a valid Jellystat token or API key. The image routes under /proxy/ stay open because the browser loads them as pictures; they only return images from the Jellyfin server you configure.
  • HTTPS from first boot. nginx serves TLS 1.2 and 1.3 (TLS 1.1 is refused) with a self-signed certificate created on your instance for its public DNS name and addresses, and sends Strict-Transport-Security. Jellystat's live updates use a WebSocket over the same HTTPS connection.
  • The host name comes from configuration, never from a request. First boot reads the instance's public DNS name (else its public or private IPv4 address) from EC2 instance metadata (IMDSv2) and pins nginx to it and to the instance's own addresses; requests for any other host name get their connection closed. nginx passes the real client address to Jellystat and drops client-sent forwarding headers.
  • Cross-site protection. nginx refuses state-changing requests and WebSocket upgrades whose Origin is another site.
  • Sign-in throttling. nginx limits sign-in attempts to 2 a second per client address (short bursts allowed) and answers 429 beyond that.
  • The app cannot read instance metadata. Jellystat, nginx and PostgreSQL are blocked from the instance metadata service (169.254.169.254, fd00:ec2::254) by systemd IPAddressDeny; at build time a request from inside each of the three services was refused while the same request from outside them succeeded.
  • Pinned Node.js source. apt takes only the nodejs package from NodeSource, verified by a keyring holding exactly one key (fingerprint 6F71F525282841EEDAF851B42F59B5F99B1BE0B4); every other package from that repository is refused.
  • Hardened SSH and system. SSH allows keys only, no root login and no X11 forwarding. The AMI requires IMDSv2. SSH host keys and the machine ID are generated fresh on every instance.

Prerequisites

  • An AWS account with permission to launch EC2 instances
  • An EC2 key pair in the Region you launch in
  • A Jellyfin server that the instance can reach over the network, and an administrator account on it to create an API key

Recommended instance type: m5.large. Jellystat itself is light. The main load comes from the first full sync of a large Jellyfin library and from statistics queries.

The history lives in PostgreSQL on the root volume. If you keep years of history for many users, increase the root volume at launch or grow it later.

Connecting to your instance

  • Version: Jellystat 1.1.12 on Ubuntu 24.04
  • Operating system: Ubuntu 24.04 LTS
  • SSH user: ubuntu
1

Launch the instance

Option A: AWS Marketplace console

  • Open the SecureAnyCloud Hardened SRS Streaming Media Server & Media Servers AMIs listing on AWS Marketplace. Choose View purchase options, then Subscribe. One subscription covers every version in the listing.
  • When the subscription is active, choose Launch your software.
  • Choose One-click launch from AWS Marketplace, or Launch from EC2 Console for the full launch wizard. In Version, choose Jellystat 1.1.12 on Ubuntu 24.04, then choose your Region.
  • Pick an instance type and your key pair. m5.large is recommended, and the software charge is $0.04 per vCPU per hour.
  • Configure the security group. The listing's default group opens SSH (22), HTTP (80) and HTTPS (443). Restrict SSH to your own IP address, and restrict 80 and 443 to the networks you view Jellystat from.
  • Choose a VPC and subnet that can reach your Jellyfin server. Leave the metadata defaults, because the image already requires IMDSv2, then choose Launch.

Option B: AWS CLI

Run these commands on your own workstation. Replace the AMI ID with the one shown for your Region on the Marketplace launch page. Replace the VPC, subnet, key pair and CIDR with your own values.

SG=$(aws ec2 create-security-group --group-name jellystat \
  --description "Jellystat statistics for Jellyfin" --vpc-id vpc-0123456789abcdef0 \
  --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr <your-mgmt-cidr>
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 443 --cidr <your-mgmt-cidr>
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 80 --cidr <your-mgmt-cidr>
aws ec2 run-instances \
  --image-id ami-0123456789abcdef0 \
  --instance-type m5.large \
  --key-name my-key-pair \
  --security-group-ids "$SG" \
  --subnet-id subnet-0123456789abcdef0 \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=jellystat}]'

You do not need --metadata-options, because the AMI already requires IMDSv2. Associate an Elastic IP if you want the address to survive a stop and start.

2

Connect and find your login

Connect as ubuntu with the key pair you chose.

ssh -i ~/.ssh/my-key-pair.pem ubuntu@<public-ip>

First boot takes under a minute after the instance starts. It sets the name Jellystat is served as (the instance's public DNS name when it has one). It creates the TLS certificate, the database password, the token-signing secret and the administrator, then writes /root/jellystat-credentials.txt (mode 0600, root). The commands below list the entries in that file without showing the secrets.

sudo grep -E '^(JELLYSTAT_URL|ADMIN_USERNAME)=' /root/jellystat-credentials.txt
sudo grep -oE '^[A-Z_]+=' /root/jellystat-credentials.txt
sudo stat -c '%a %U:%G %n' /root/jellystat-credentials.txt
JELLYSTAT_URL=https://ec2-54-161-223-102.compute-1.amazonaws.com/ ADMIN_USERNAME=admin JELLYSTAT_URL= ADMIN_USERNAME= ADMIN_PASSWORD= <set-at-first-boot>= POSTGRES_PORT= POSTGRES_DB= POSTGRES_USER= POSTGRES_PASSWORD= <set-at-first-boot>= 600 root:root /root/jellystat-credentials.txt

The file holds these values:

  • JELLYSTAT_URL: the address of this instance's Jellystat
  • ADMIN_USERNAME: the administrator's user name (admin)
  • ADMIN_PASSWORD: the administrator password, generated on this instance
  • POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD: Jellystat's own database (jfstat on 127.0.0.1:5432), for backups and maintenance
  • JWT_SECRET: the secret that signs Jellystat's login tokens (also in Jellystat's environment file)

To read the password, run sudo cat /root/jellystat-credentials.txt in your own session and store it in your password manager. Jellystat keeps only a hash of it.

3

Confirm Jellystat is running

Run the commands below to confirm that first boot has finished and that PostgreSQL, Jellystat and nginx are active. Only sshd and nginx listen beyond loopback. Jellystat, shown as node, is on 127.0.0.1:3000, PostgreSQL is on 127.0.0.1:5432 and the resolver is on 127.0.0.53. The provenance file records exactly what was built.

systemctl is-active jellystat-firstboot.service postgresql@16-main.service jellystat.service nginx.service
sudo ss -Htlnp | awk '{print $4, $6}' | sed -E 's/,pid=[0-9]+,fd=[0-9]+//g' | sort -u cat /usr/share/doc/jellystat-sac/PROVENANCE.txt
active active active active 0.0.0.0:22 users:(("sshd"),("systemd")) 0.0.0.0:443 users:(("nginx"),("nginx"),("nginx"),("nginx"),("nginx")) 0.0.0.0:80 users:(("nginx"),("nginx"),("nginx"),("nginx"),("nginx")) 127.0.0.1:3000 users:(("node")) 127.0.0.1:5432 users:(("postgres")) 127.0.0.53%lo:53 users:(("systemd-resolve")) 127.0.0.54:53 users:(("systemd-resolve")) [::]:22 users:(("sshd"),("systemd")) [::]:443 users:(("nginx"),("nginx"),("nginx"),("nginx"),("nginx")) [::]:80 users:(("nginx"),("nginx"),("nginx"),("nginx"),("nginx")) Jellystat 1.1.12 (MIT) - built by sac from the upstream release tarball source: https://github.com/CyferShepard/Jellystat/archive/refs/tags/1.1.12.tar.gz sha256: 2bd456d54e9314c7c0f768d8105affe23387937cc8f80e3daf50c525d7e5614a commit: 10678476792bc88700c574b97d09f300f47f7291 (the tarball's pax commit header) lockfile: upstream f6a9a697f648e12af67d913457d8f85a8be274d37db0fe41cf73e40b5d4ef38f; after npm audit fix (no --force) 9aa59a7593eb643f021bf59707865613b8158b22afca9e1ca444638195c8a52e build: npm ci --ignore-scripts, npm audit fix --ignore-scripts, npm run build (Vite), npm prune --omit=dev audit: npm-audit-omit-dev.json (the shipped tree: 0 high, 0 critical at build time) node: v22.23.4 (NodeSource 22.23.4-1nodesource1)

The number of nginx entries follows the instance's vCPU count. The npm audit report for the shipped tree sits next to the provenance file, in /usr/share/doc/jellystat-sac/npm-audit-omit-dev.json.

4

Verify that setup is closed and the web defaults hold

Run the commands below to confirm each of these behaviours. Setup is complete (state 2). The web app loads. The API and the Jellyfin helper routes refuse a request without a sign-in. A second administrator is refused. Plain HTTP redirects to HTTPS. An unknown Host header gets no answer. TLS 1.1 is not offered.

H=$(sed -n 's/^set \$jellystat_host "\(.*\)";$/\1/p' /etc/nginx/jellystat-host.conf) C=(--cacert /etc/ssl/jellystat/jellystat.crt --resolve "$H:443:127.0.0.1" --resolve "$H:80:127.0.0.1") curl -s "${C[@]}" "https://$H/auth/isConfigured"; echo curl -s "${C[@]}" -o /dev/null -w 'web app -> %{http_code}\n' "https://$H/" curl -s "${C[@]}" -o /dev/null -w 'API without a token -> %{http_code}\n' "https://$H/api/getconfig" curl -s "${C[@]}" -o /dev/null -w 'Jellyfin proxy route without a token -> %{http_code}\n' "https://$H/proxy/getAdminUsers" curl -s "${C[@]}" -o /dev/null -w 'create a second administrator -> %{http_code}\n' \
  -H 'Content-Type: application/json' --data '{"username":"someone","password":"0"}' "https://$H/auth/createuser" curl -s "${C[@]}" -o /dev/null -w 'http -> %{http_code}\n' "http://$H/" curl -sk -o /dev/null -w 'unknown Host -> %{http_code}\n' -H 'Host: evil.example' https://127.0.0.1/ || true curl -s "${C[@]}" -o /dev/null -w 'TLS 1.1 -> %{http_code}\n' --tlsv1.1 --tls-max 1.1 "https://$H/" || true
{"state":2,"version":"1.1.12"} web app -> 200 API without a token -> 401 Jellyfin proxy route without a token -> 401 create a second administrator -> 403 http -> 301 unknown Host -> 000 TLS 1.1 -> 000

--resolve sends each request to this instance while keeping the real host name in the request, exactly as a browser would send it. --cacert trusts this instance's own certificate. A result of 000 means the server closed the connection without an answer.

The administrator can also sign in through the API with the per-instance password. Jellystat's sign-in page sends the SHA-3 hash of the password rather than the password itself. The commands below do the same with the helper on the image, then read the settings with the token they receive.

F=/root/jellystat-credentials.txt
H=$(sudo sed -n 's#^JELLYSTAT_URL=https://\([^/]*\)/$#\1#p' $F) C=(--cacert /etc/ssl/jellystat/jellystat.crt --resolve "$H:443:127.0.0.1")
HASH=$(sudo awk -F= '$1 == "ADMIN_PASSWORD" {printf "%s", substr($0, index($0, "=") + 1)}' $F | node /usr/local/lib/sac/jellystat-sha3.js)
JWT=$(printf '{"username":"admin","password":"%s"}' "$HASH" | curl -s "${C[@]}" -H 'Content-Type: application/json' \
  --data-binary @- "https://$H/auth/login" | jq -r .token) printf 'header = "Authorization: Bearer %s"\n' "$JWT" | curl -s -K - "${C[@]}" "https://$H/api/getconfig" | jq '{APP_USER, JF_HOST}' unset HASH JWT
{ "APP_USER": "admin", "JF_HOST": "http://jellyfin.invalid" }
5

Sign in to Jellystat

Browse to JELLYSTAT_URL (https://<public-dns-name>/). The certificate is self-signed, so your browser warns once. Accept it for this host, or install your own certificate (Step 9).

Jellystat opens on its sign-in page, not on the setup wizard. Sign in with ADMIN_USERNAME and ADMIN_PASSWORD. Until you connect a Jellyfin server, the home page is empty: no sessions, no recently added items and no statistics.

6

Connect your Jellyfin server

  • In Jellyfin, open Dashboard, then API Keys, and create a key for Jellystat (for example named jellystat).
  • In Jellystat, open Settings. Replace the default Jellyfin URL (http://jellyfin.invalid) with your Jellyfin server's address as the instance can reach it, for example http://10.0.1.25:8096 or https://jellyfin.example.com. Paste the key into API Key and choose Update. Jellystat checks the address and key with your server before it saves them.
  • Optionally, set External URL to the address your browser uses for Jellyfin, so links from Jellystat open your server.

Jellystat then synchronises your libraries, items, users and playback history from Jellyfin, and records new playback as it happens. The first sync of a large library can take several minutes. Settings, Logs lists each sync task and its result. When the sync has run, Libraries, Users, Activity and Statistics fill in, and the home page shows the sessions playing now.

The same settings page offers these options:

  • Security: change the administrator password here. Enter the current password and the new one, then choose Update. The credentials file is not updated, so remove the old password from it (sudo nano /root/jellystat-credentials.txt) once you have stored the new one.
  • Require Login: leave this on Yes. With No, Jellystat issues a token to any sign-in attempt, whatever the user name and password. The whole API, your viewing history and the Jellyfin key then become available to anyone who can reach the instance.
  • API Key: use this tab to create Jellystat API keys for scripts and integrations. Send a key in the X-Api-Token header, and treat each key like the administrator password.
  • Backup: use this tab to create and restore Jellystat backups, which are stored in /opt/jellystat/backend/backup-data.

The About page shows the running version and whether a newer Jellystat release exists.

7

Check the security controls

Run the commands below to confirm the following. Jellystat, nginx and PostgreSQL carry IPAddressDeny for the metadata addresses. Jellystat runs sandboxed as the jellystat user. PostgreSQL listens on loopback only. Jellystat's environment file is readable only by root and the jellystat group. SSH runs with the SecureAnyCloud settings.

systemctl show -p IPAddressDeny jellystat.service nginx.service postgresql@16-main.service
systemctl show -p User -p ProtectSystem -p ProtectHome -p NoNewPrivileges -p PrivateTmp jellystat.service cd / && sudo -u postgres psql -X -tAc 'SHOW listen_addresses'
sudo stat -c '%a %U:%G %n' /etc/jellystat/jellystat.env
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|x11forwarding) '
IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32 IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32 IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32 User=jellystat PrivateTmp=yes ProtectHome=yes ProtectSystem=strict NoNewPrivileges=yes localhost 640 root:jellystat /etc/jellystat/jellystat.env permitrootlogin no passwordauthentication no x11forwarding no

Inspect the certificate nginx serves. It was created on this instance for its own name and addresses.

openssl x509 -in /etc/ssl/jellystat/jellystat.crt -noout -subject -ext subjectAltName -enddate
subject=CN = ec2-54-161-223-102.compute-1.amazonaws.com, O = sac X509v3 Subject Alternative Name: DNS:ec2-54-161-223-102.compute-1.amazonaws.com, IP Address:54.161.223.102, IP Address:172.31.86.46, IP Address:127.0.0.1, DNS:localhost notAfter=Feb 10 02:07:36 2029 GMT
8

Check the software versions and the Node.js source

Check the versions in use and the NodeSource pin. Expect one key with the expected fingerprint, nodejs installed from NodeSource, and every other NodeSource package refused.

node --version
node -p 'require("/opt/jellystat/package.json").version' psql --version nginx -v 2>&1 gpg --show-keys --with-colons /etc/apt/keyrings/nodesource.gpg 2>/dev/null | awk -F: '$1=="pub"{n++} $1=="fpr"&&!f{print $10; f=1} END{print n " primary key"}' apt-cache policy nodejs | sed -n 1,3p grep -v '^#' /etc/apt/preferences.d/sac-nodesource
v22.23.4 1.1.12 psql (PostgreSQL) 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1) nginx version: nginx/1.24.0 (Ubuntu) 6F71F525282841EEDAF851B42F59B5F99B1BE0B4 1 primary key nodejs: Installed: 22.23.4-1nodesource1 Candidate: 22.23.4-1nodesource1 Package: nodejs Pin: origin deb.nodesource.com Pin-Priority: 600 Package: * Pin: origin deb.nodesource.com Pin-Priority: -1
9

Serve Jellystat under your own DNS name

Point a DNS record (for example stats.example.com) at the instance's public or Elastic IP address, then tell the image to serve that name. The helper creates a new self-signed certificate for the name, pins nginx to it, updates JELLYSTAT_URL in the credentials file and keeps the name across reboots.

sudo jellystat-set-hostname stats.example.com

For a trusted certificate, obtain one for that name, for example with certbot's certonly mode using its own temporary web server, or from your organization's CA. Copy the certificate chain and key over /etc/ssl/jellystat/jellystat.crt and /etc/ssl/jellystat/jellystat.key (key mode 0600, root). Then run sudo systemctl reload nginx.

Maintenance

  • Operating system updates: unattended security upgrades are on, and Node.js updates come from NodeSource's Node 22 line through the same apt run. Reboot when /var/run/reboot-required exists. PostgreSQL, Jellystat and nginx start again by themselves.
  • Jellystat updates: Jellystat is installed read-only under /opt/jellystat. Its About page only reports whether a newer release exists and cannot update Jellystat. To move to a newer release, launch the newer version of this image when the listing offers it, then restore a Jellystat backup into it (Settings, Backup).
  • Backups: Jellystat's Backup tab writes backups to /opt/jellystat/backend/backup-data. Copy them off the instance. For a database-level backup, run pg_dump against the jfstat database with the PostgreSQL user and password from the credentials file, or take an EBS snapshot of the root volume.
  • Logs: sudo journalctl -u jellystat for Jellystat, /var/log/nginx/ for web requests, /var/log/postgresql/ for the database, and sudo journalctl -u jellystat-firstboot for first boot.
  • What the instance contacts: Jellystat contacts your Jellyfin server once you configure it. It also contacts the GitHub API to check for a newer release, shown on the About page, and never downloads one. All other traffic comes from the operating system: the AWS Systems Manager agent, snapd, unattended upgrades and the time service. Disable the SSM agent if you do not use Session Manager.

Troubleshooting

  • The web page does not load right after launch: first boot is still running. Run sudo journalctl -u jellystat-firstboot.service -f to follow its progress.
  • The browser shows "connection closed": nginx answers only the configured name and the instance's own addresses. Use JELLYSTAT_URL, or run sudo jellystat-set-hostname <name> to set a new name.
  • Saving the Jellyfin URL fails: the instance must be able to reach your Jellyfin server at that address. Check the address and port, the Jellyfin server's firewall and security group, and that the API key exists under Jellyfin's Dashboard, API Keys.
  • Too many sign-in attempts: nginx answers 429 when sign-ins arrive faster than 2 a second from one address. Wait a moment and try again.

Licensing

Jellystat is licensed under the MIT License. The license text is on the instance in /opt/jellystat/LICENSE (and /usr/share/doc/jellystat-sac/LICENSE), and the source is at github.com/CyferShepard/Jellystat/releases/tag/1.1.12. Jellyfin is a separate project and is not included on this image. SecureAnyCloud is not affiliated with, endorsed by or sponsored by the Jellystat or Jellyfin projects or their authors.

Support

SecureAnyCloud provides 24/7 technical support for this image by email and live chat: deployment, first login, connecting Jellyfin, HTTPS and DNS setup, upgrades and troubleshooting. Contact sales@secureanycloud.com.

Deploy on AWS

Launch Jellystat 1.1.12 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.

View on Marketplace

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on AWS