Grimmory on Ubuntu 24.04 on Azure User Guide
Overview
Grimmory is an open source, self hosted digital library for people and teams with a serious collection of ebooks, comics and audiobooks. Point it at your EPUB, PDF, MOBI, comic and audiobook files and it organises them into libraries and shelves, fetches cover art and rich metadata, and gives every member of your household or organisation their own account, their own shelves and their own reading and listening progress. A built in browser reader and audiobook player handle the core formats with bookmarks and per user position tracking, and an OPDS catalogue plus Kobo and KOReader sync let you carry the same library onto a dedicated e-reader.
The cloudimg image runs Grimmory the way upstream recommends: from its official container image. The application (a Spring Boot service with the Angular web console built in, listening on 127.0.0.1:6060) and a bundled MariaDB run as a two service Docker Compose project on a single VM, with a hardened nginx reverse proxy in front on port 80. The database is reachable only on a private container network, never on the host, and the application's own port is published to loopback only, so nginx is the sole ingress. The database, book and audiobook files, generated covers and the watched auto import folder all live on a dedicated data volume mounted at /var/lib/grimmory, so the library grows on its own disk and can be expanded or snapshotted independently of the operating system.
The appliance is secure by default, and unusually strongly so. Grimmory creates its first administrator through a one time setup call that the API accepts only while no users exist, which means upstream ships no default account at all rather than a vendor password to rotate. This image goes further: the shipped image contains an empty database with no credential of any kind, and on the first boot of every instance a one shot service generates a unique database password and administrator password, brings the bundled database up so it initialises with those per instance secrets, claims the administrator through that same one time endpoint before nginx ever serves port 80, and then proves the new credentials authenticate, that a forged token is rejected and that the setup endpoint is closed.
What is included:
- Grimmory 3.3.1 from the pinned official image
ghcr.io/grimmory-tools/grimmory:v3.3.1 grimmory.service— the Docker Compose stack (the app on127.0.0.1:6060and a bundled MariaDB on a private container network)- A bundled MariaDB 11.4 container for library, user and progress data, with no host port
nginx.servicereverse proxy on TCP 80, hardened and tuned for Grimmory's large headers, streaming uploads and progress websockets, TLS readygrimmory-firstboot.servicegenerating every per instance secret and claiming the administrator on first boot- A dedicated 64 GiB data volume mounted at
/var/lib/grimmoryfor the database, books, audiobooks, covers and the auto import folder - The complete AGPL-3.0 corresponding source shipped on the instance
- Ubuntu 24.04 LTS base, latest patches, unattended security upgrades enabled
- 24/7 cloudimg support, 24h response SLA
Prerequisites
An active Azure subscription, an SSH key, and a VNet with a subnet. Recommended VM size: Standard_B2s (2 vCPU, 4 GB RAM). The whole stack is memory tuned to run comfortably in 4 GB with no swap at all — the Java heap is capped rather than left to claim a percentage of RAM, and MariaDB's buffers are capped to match. Move to a larger size if you plan to import very large collections or serve many concurrent readers.
Step 1: Deploy from the Azure Portal
Search the Azure Marketplace for Grimmory, choose the plan, and create the VM. In the networking step attach an NSG that allows inbound TCP 22 (SSH) and TCP 80 (the web console) from your client networks only. Put a TLS reverse proxy in front of port 80 for production. No other port needs to be open: the bundled MariaDB stays on a private container network and the application's own port 6060 is published to loopback only, so nginx is the only ingress.
Step 2: Deploy from the Azure CLI
RG="grimmory-prod"; LOCATION="eastus"; VM_NAME="grimmory-01"
GALLERY_IMAGE_ID="/subscriptions/<sub-id>/resourceGroups/azure-cloudimg/providers/Microsoft.Compute/galleries/cloudimgGallery/images/grimmory-ubuntu-24-04/versions/<version>"
SSH_KEY="$(cat ~/.ssh/id_rsa.pub)"
az group create --name "$RG" --location "$LOCATION"
az network vnet create -g "$RG" --name gr-vnet --address-prefix 10.114.0.0/16 --subnet-name gr-subnet --subnet-prefix 10.114.1.0/24
az network nsg create -g "$RG" --name gr-nsg
az network nsg rule create -g "$RG" --nsg-name gr-nsg --name allow-ssh --priority 100 \
--source-address-prefixes "<your-mgmt-cidr>" --destination-port-ranges 22 --access Allow --protocol Tcp
az network nsg rule create -g "$RG" --nsg-name gr-nsg --name allow-web --priority 110 \
--source-address-prefixes "<your-mgmt-cidr>" --destination-port-ranges 80 --access Allow --protocol Tcp
az vm create -g "$RG" --name "$VM_NAME" --image "$GALLERY_IMAGE_ID" \
--size Standard_B2s --storage-sku StandardSSD_LRS \
--admin-username azureuser --ssh-key-values "$SSH_KEY" \
--vnet-name gr-vnet --subnet gr-subnet --nsg gr-nsg --public-ip-sku Standard
The library data disk is part of the image, so it is created and mounted for you automatically. There is no extra disk to attach.
Step 3: Connect via SSH
ssh azureuser@<vm-ip>
All services start automatically on first boot, after grimmory-firstboot.service has generated this instance's secrets, brought the bundled database up and claimed the administrator. First boot typically completes within a minute or two of the VM reporting ready, since it also brings the container stack up for the first time.
Step 4: Verify the Services
sudo systemctl is-active grimmory nginx
sudo test -f /var/lib/cloudimg/grimmory-firstboot.done && echo FIRSTBOOT_DONE
sudo ss -tlnH | awk '{print $1, $4}' | sort -u
Expected output — both units are active, first boot has completed, and only SSH and nginx are on a public address while the application port stays on loopback and the database is on no host port at all:
active
active
FIRSTBOOT_DONE
LISTEN 0.0.0.0:22
LISTEN 0.0.0.0:80
LISTEN 127.0.0.1:6060
LISTEN 127.0.0.53%lo:53
LISTEN [::]:22
LISTEN [::]:80
Only SSH and nginx are on a public address. The application is published on 127.0.0.1:6060 so it is reachable only from the VM itself, the port 53 entry is the local systemd-resolved DNS stub, and the bundled MariaDB appears nowhere in the list because it lives on a private container network with no host port. nginx is the sole ingress. You can see the two containers with sudo docker compose -f /opt/grimmory/docker-compose.yml ps.

Step 5: Retrieve Your Administrator Credentials
Every instance generates its own administrator password and database password on first boot and writes them to a root only file:
sudo ls -l /root/grimmory-credentials.txt
sudo cat /root/grimmory-credentials.txt
The file is mode 600 owned by root, and contains the console URL, the administrator username and password, and the database credentials. Sign in with those values, then change the administrator e-mail address and password from Settings → Users.

Step 6: Confirm There Is No Default Login
Grimmory creates its first administrator through a one time setup endpoint that the API accepts only while no users exist. This image claims that administrator during first boot, before nginx starts, so nobody can reach your instance and claim it first. Confirm the setup endpoint is closed:
curl -s http://127.0.0.1/api/v1/setup/status
Expected output — setup has already been completed on this instance:
{"data":true,"message":"Initial setup has already been completed.","status":200,"timestamp":"..."}
A protected endpoint must refuse an anonymous caller and a forged token:
curl -s -o /dev/null -w 'anonymous: %{http_code}\n' http://127.0.0.1/api/v1/users/me
curl -s -o /dev/null -w 'forged token: %{http_code}\n' http://127.0.0.1/api/v1/users/me \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJub2JvZHkifQ.not-a-valid-signature'
Expected output:
anonymous: 401
forged token: 401
Step 7: Check the Application and the Library Volume
curl -s http://127.0.0.1/api/v1/healthcheck
df -h /var/lib/grimmory | tail -1
free -m | head -2
swapon --show
The health endpoint reports the running version, the dedicated 64 GiB library volume is mounted at /var/lib/grimmory, the whole stack sits comfortably inside 4 GB, and swapon --show prints nothing because the image ships with no swap at all.

Step 8: Sign in to the Console
Browse to http://<vm-ip>/ and sign in with the administrator username and password from the credentials file.

Step 9: Add Your Books
Create a library from the Libraries section of the sidebar and point it at the container library folder /books, which is the data volume path /var/lib/grimmory/books on the host. Copy your EPUB, PDF, MOBI, comic or audiobook files there and Grimmory scans them, extracts titles, authors and covers, and shelves them.
sudo install -d -o grimmory -g grimmory -m 0750 /var/lib/grimmory/books
ls -ld /var/lib/grimmory/books /var/lib/grimmory/bookdrop
Then copy your own files in and hand them to the grimmory user, replacing the source path with your own:
sudo cp ~/my-collection/*.epub /var/lib/grimmory/books/
sudo chown -R grimmory:grimmory /var/lib/grimmory/books
You can also drop files into the watched auto import folder (the container path /bookdrop, on the host /var/lib/grimmory/bookdrop), or upload them straight from the browser with the upload button in the toolbar. The All Books view shows the shelved collection with covers, and the filter panel lets you narrow by author, format, read status, publisher, language or year.

Step 10: Read in the Browser
Click any book to open it in the built in reader. EPUB, PDF and comic formats all render in the browser, with per user reading position, bookmarks and a progress indicator, so you can stop on one device and resume on another. Audiobooks open in the built in player with the same per user position tracking.

Step 11: Settings, Users and Sync
Settings exposes reader and player preferences per format, metadata provider configuration, user administration, e-mail delivery, OIDC single sign on and the task manager. Create an account for each reader under Settings → Users and give them only the permissions they need; each user keeps their own shelves and progress.
Grimmory also serves an OPDS catalogue and supports Kobo and KOReader sync, so the same library is reachable from a dedicated e-reader. Metadata fetching from providers such as Amazon, Google, Open Library and Hardcover requires outbound internet access from the VM; it is optional and disabled until you configure it.

Step 12: Server Components
| Component | Detail |
|---|---|
| Application | ghcr.io/grimmory-tools/grimmory:v3.3.1, Spring Boot on Temurin JDK 25, published on 127.0.0.1:6060 |
| Web console | Angular, built into the same image and served by the application |
| Database | Bundled MariaDB 11.4 container (grimmory-mariadb), private container network, database and role grimmory |
| Reverse proxy | nginx.service on TCP 80, hardened (server tokens off, host guard, sensitive paths denied) |
| Compose project | /opt/grimmory/docker-compose.yml managed by grimmory.service |
| Library volume | 64 GiB disk at /var/lib/grimmory (config/ = database, data/, books/, bookdrop/) |
| Secrets file | /opt/grimmory/.env (mode 600, generated at first boot) |
| Credentials | /root/grimmory-credentials.txt (mode 600) |
| Corresponding source | /usr/share/grimmory/source/ and /usr/share/doc/grimmory/ |
The Java heap is capped at 768 MB and MariaDB's buffer pool at 128 MB so the two never contend on a 2 vCPU instance. If you move to a larger VM and want to raise the heap, edit JDK_JAVA_OPTIONS in /opt/grimmory/docker-compose.yml, then sudo systemctl restart grimmory.
Step 13: The AGPL-3.0 Source Offer
Grimmory is licensed under the GNU Affero General Public License version 3. Section 13 requires that anyone interacting with the program over a network is offered the corresponding source of the running version, and this instance satisfies that on disk with no network access required:
cat /usr/share/doc/grimmory/SOURCE-OFFER.txt
cd /usr/share/grimmory/source && sha256sum -c grimmory-3.3.1-source.tar.gz.sha256
The complete unmodified v3.3.1 source tree is shipped as a release tarball at /usr/share/grimmory/source/, the licence text is at /usr/share/doc/grimmory/LICENSE, and the written offer names the exact running image digest and all of the paths.
cloudimg makes no modifications to Grimmory itself. This image supplies only the Docker Compose project, systemd units, a hardened nginx vhost and the first boot credential bootstrap.

Step 14: Configuration and Logs
A quick health check of the whole stack:
sudo systemctl is-active grimmory nginx
sudo nginx -t
For deeper investigation, read the service journals and the container logs. These print a lot of output, so run them on their own:
sudo systemctl status grimmory
sudo journalctl -u grimmory-firstboot --no-pager
sudo docker logs --tail 100 grimmory
sudo docker compose -f /opt/grimmory/docker-compose.yml ps
The stack reads its per instance secrets from /opt/grimmory/.env. After editing the compose project, restart it with sudo systemctl restart grimmory.
Database maintenance runs inside the bundled MariaDB container and reads this instance's password straight out of the credentials file, so there is nothing to type or paste:
sudo docker exec grimmory-mariadb mariadb -u grimmory \
-p"$(sudo sed -n 's/^grimmory\.db\.pass=//p' /root/grimmory-credentials.txt)" \
grimmory -e "SELECT COUNT(*) AS tables FROM information_schema.tables WHERE table_schema='grimmory';"
To back up the library, snapshot the data volume and dump the database the same way:
sudo docker exec grimmory-mariadb mariadb-dump -u grimmory \
-p"$(sudo sed -n 's/^grimmory\.db\.pass=//p' /root/grimmory-credentials.txt)" \
grimmory > ~/grimmory-backup.sql
Troubleshooting
The console does not load. Check that grimmory.service is active and that first boot completed: sudo systemctl is-active grimmory && sudo journalctl -u grimmory-firstboot --no-pager. On first boot the stack pulls nothing (the images are already cached in the image), brings the bundled database up and applies its schema migrations, which takes a minute or two on a Standard_B2s.
A sign in attempt returns "A data conflict occurred". Grimmory issues a refresh token whose validity timestamps have one second resolution, and stores it under a uniqueness constraint, so two sign ins for the same account inside the same second collide. Wait a second and sign in again.
Sign in returns "Too many failed login attempts". Grimmory rate limits five failed attempts per source address and per username within fifteen minutes. Wait for the window to pass, or retrieve the current password from /root/grimmory-credentials.txt.
Uploads or large books fail. The nginx vhost allows request bodies up to 4 GB and streams them straight through. If you front the instance with another proxy or a load balancer, raise the equivalent limits there too.
Metadata lookups return nothing. Metadata providers are third party services reached over the internet. Confirm the VM has outbound access and that the provider is enabled under Settings → Metadata.
Support
24/7 technical support is included with this image. Contact support@cloudimg.co.uk for help with deployment, library and shelf organisation, metadata providers, OPDS, Kobo and KOReader sync, bulk imports, user administration, TLS and custom domains, version upgrades, performance tuning and database administration.