Jellystat 1.1.12 on AWS User Guide
Overview
This guide covers launching and verifying Jellystat 1.1.12 on Ubuntu 24.04 from cloudimg on Amazon EC2. It is one version of the cloudimg Hardened SRS Streaming Media Server & Media Servers AMIs listing on AWS Marketplace; you pick it as the software version when you subscribe and launch. The other media and streaming products in the listing are described on the listing's guide hub.
Jellystat is a free, open-source statistics application for Jellyfin (MIT licence). You point it at your Jellyfin server with an API key, and it keeps a history of what was played, by whom, on which device and for how long, with dashboards for libraries, users, activity and watch statistics, a live view of the sessions playing now, and its own backups. Jellystat keeps that history in its own PostgreSQL database, so it survives Jellyfin's own log rotation.
What is on the image:
- 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; the 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:3000only, 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:5432only, with a per-instance password for Jellystat's database user - The setup wizard already completed on first boot: the administrator
adminhas 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 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
adminthrough Jellystat's own API with a password generated on your instance, and finishes setup before nginx starts, so the wizard is never reachable. A second administrator is refused by Jellystat itself and nginx never forwards the account-creation and setup calls (both answer403). The server address saved at first boot is the placeholderhttp://jellyfin.invaliduntil you enter your own (Step 6). - Per-instance secrets. 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 are all generated on your instance at first boot. 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 once first boot has written its environment file, and nginx refuses to 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:3000and PostgreSQL on127.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 unprivilegedjellystatuser under a systemd sandbox (read-only system, no home directories, no new privileges, its own/tmp, no capabilities); its code under/opt/jellystatis 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
401without a valid token. Upstream also serves four Jellyfin helper routes (/proxy/getSessions,/proxy/getAdminUsers,/proxy/getRecentlyAddedand/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 with401unless 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
Originis another site. - Sign-in throttling. nginx limits sign-in attempts to 2 a second per client address (short bursts allowed) and answers
429beyond 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
nodejspackage from NodeSource, verified by a keyring holding exactly one key (fingerprint6F71F525282841EEDAF851B42F59B5F99B1BE0B4); 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 and 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 first full sync of a large Jellyfin library and the statistics queries are the main load. The history lives in PostgreSQL on the root volume: increase the root volume at launch, or grow it later, if you keep years of history for many users.
Connecting to your instance
| Version | Operating system | SSH user |
|---|---|---|
| Jellystat 1.1.12 on Ubuntu 24.04 | Ubuntu 24.04 LTS | ubuntu |
Step 1: Launch the instance
Option A: AWS Marketplace console
- Open the cloudimg Hardened SRS Streaming Media Server & Media Servers AMIs listing on AWS Marketplace and choose View purchase options, then Subscribe. One subscription covers every version in the listing.
- Once 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 (
m5.largerecommended; the software charge is $0.04 per vCPU per hour) and your key pair. - 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 your VPC and subnet (one that can reach your Jellyfin server), leave the metadata defaults (the image already requires IMDSv2), then choose Launch.
Option B: AWS CLI
Run this on your own workstation. Replace the AMI ID with the one shown for your Region on the Marketplace launch page, and the VPC, subnet, key pair and CIDR with your own:
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: the AMI already makes IMDSv2 required. Associate an Elastic IP if you want the address to survive a stop and start.
Step 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), creates the TLS certificate, the database password, the token-signing secret and the administrator, and writes /root/jellystat-credentials.txt (mode 0600, root). These are the entries in that file (the secrets are not shown here):
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=
POSTGRES_HOST=
POSTGRES_PORT=
POSTGRES_DB=
POSTGRES_USER=
POSTGRES_PASSWORD=
JWT_SECRET=
600 root:root /root/jellystat-credentials.txt
| Value | What it is for |
|---|---|
JELLYSTAT_URL |
The address of this instance's Jellystat |
ADMIN_USERNAME |
The administrator's user name (admin) |
ADMIN_PASSWORD |
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.
Step 3: Confirm Jellystat is running
First boot has finished, PostgreSQL, Jellystat and nginx are active, and only sshd and nginx listen beyond loopback (Jellystat, shown as node, is on 127.0.0.1:3000, PostgreSQL on 127.0.0.1:5432, the resolver 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-cloudimg/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 cloudimg 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 of the shipped tree is next to the provenance file, in /usr/share/doc/jellystat-cloudimg/npm-audit-omit-dev.json.
Step 4: Prove the setup is closed and the web defaults
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 and 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, and --cacert trusts this instance's own certificate. 000 means the server closed the connection without an answer.
The administrator can 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, so this does the same with the helper on the image, then reads the settings with the token it receives:
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/cloudimg/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"
}
Step 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 a Jellyfin server is connected, the home page is empty: no sessions, no recently added items and no statistics.

Step 6: Connect your Jellyfin server
- In Jellyfin, open Dashboard, then API Keys, and create a key for Jellystat (for example named
jellystat). - In Jellystat, choose Settings. Replace the placeholder Jellyfin URL (
http://jellyfin.invalid) with your Jellyfin server's address as the instance can reach it, for examplehttp://10.0.1.25:8096orhttps://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. Once it has run, Libraries, Users, Activity and Statistics fill in, and the home page shows the sessions playing now.
Other settings on the same page:
- Security: change the administrator password here (enter the current one and the new one, then Update). The credentials file is not updated; 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, so the whole API, your viewing history and the Jellyfin key become available to anyone who can reach the instance.
- API Key: the API Key tab creates Jellystat API keys for scripts and integrations; send one in the
X-Api-Tokenheader. Treat each key like the administrator password. - Backup: the Backup tab creates and restores Jellystat backups (stored in
/opt/jellystat/backend/backup-data).
The About page shows the running version and whether a newer Jellystat release exists:

Step 7: Check the security controls
Jellystat, nginx and PostgreSQL carry IPAddressDeny for the metadata addresses, Jellystat runs sandboxed as jellystat, PostgreSQL listens on loopback only, Jellystat's environment file is readable only by root and the jellystat group, and SSH runs with the cloudimg pins:
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
The certificate nginx serves 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 = cloudimg
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
Step 8: Check the software versions and the Node.js source
The versions in use, and the NodeSource pin: 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/cloudimg-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
Step 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. It 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 organisation's CA), copy the certificate chain and key over /etc/ssl/jellystat/jellystat.crt and /etc/ssl/jellystat/jellystat.key (key mode 0600, root), and 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-requiredexists; PostgreSQL, Jellystat and nginx start again by themselves. - Jellystat updates: Jellystat is installed read-only under
/opt/jellystat, and its About page only reports whether a newer release exists; it cannot update itself. To move to a newer release, launch the newer version of this image when the listing offers it and 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, usepg_dumpagainst thejfstatdatabase with the PostgreSQL user and password from the credentials file, or take an EBS snapshot of the root volume. - Logs:
sudo journalctl -u jellystatfor Jellystat,/var/log/nginx/for web requests,/var/log/postgresql/for the database, andsudo journalctl -u jellystat-firstbootfor first boot. - What the instance contacts: Jellystat contacts your Jellyfin server once you configure it, and the GitHub API to check for a newer release (shown on the About page; it never downloads one). Everything else 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.
sudo journalctl -u jellystat-firstboot.service -fshows its progress. - Browser shows "connection closed": nginx answers only the configured name and the instance's own addresses. Use
JELLYSTAT_URL, or runsudo jellystat-set-hostname <name>for a new name. - Saving the Jellyfin URL fails: the instance must 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 in Jellyfin's Dashboard, API Keys.
- Too many sign-in attempts: nginx answers
429when 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 licence text is on the instance in /opt/jellystat/LICENSE (and /usr/share/doc/jellystat-cloudimg/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. cloudimg is not affiliated with, endorsed by or sponsored by the Jellystat or Jellyfin projects or their authors.
Support
cloudimg 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 support@cloudimg.co.uk.