Introduction
Moekura is a self-hostable booru: an image board where posts are found by their tags. The same program and database layout serve a private, single-user collection on a small VPS or Raspberry Pi and a public site with millions of posts, many web servers and a CDN. Growing a site means changing configuration and running more processes, never switching software.
What it does:
- Posts: upload images (JPEG, PNG, GIF, WebP, AVIF, optionally JPEG XL) and videos (MP4, WebM) from a file or a link. Exact duplicates are refused; similar images are found by their perceptual hash.
- Tags: categories (general, artist, copyright, character, meta), aliases and implications, autocomplete, and a search syntax with tags, wildcards and filters. An optional tagger suggests tags for new uploads with a machine learning model, on the CPU.
- People: accounts with roles, favorites, votes, blacklists and per-user settings; registration can be open, invite-only, approved by staff, or closed.
- Moderation: an approval queue, flags, deletion with reasons, bans of users and networks, and a log of every staff action.
- Private sites: keep everything behind a login, with file links that expire.
- An API covering what the site does, with API keys and a reference generated from the code.
It is free software under the AGPL-3.0: if you run a modified version as a public service, you must publish your changes.
Start with installing it with Docker Compose.
Hardware
A small site runs on a VPS with 1 GB of memory and two cores: Docker Compose’s tiny setup is tuned for that, and CI runs the end-to-end tests on every change with the stack limited to it. More memory mostly buys a bigger database cache, which matters once the database outgrows it. The tagger needs about 1 GB more; its page has its numbers.
Numbers marked measured come from moekura admin bench-http and admin bench (see Scaling) against the tiny stack, with the app
and PostgreSQL containers limited to 384 MB and 512 MB of memory and one
core each, on an AMD Ryzen 7 7800X3D with an NVMe SSD. The posts were
generated by admin seed, with comments, notes, pools and favorites, but
no files.
Memory
Measured:
| Right after start | After the e2e tests | 1,000,000 posts, after a burst of requests | |
|---|---|---|---|
moekura serve (web and jobs) | 25 MB | 68 MB | 68 MB |
| PostgreSQL (the compose settings) | 54 MB | 83 MB | 250 MB |
moekura serve stays under 150 MB; CI fails if it doesn’t. PostgreSQL
grows to its shared_buffers (128 MB in the compose file) plus what its
connections use. Making thumbnails runs vips and ffmpeg for a moment
per upload, which for large images and videos takes some more.
That leaves about 150 MB of a 1 GB machine for the system and for the operating system’s file cache. Adding swap is a cheap safety net.
Speed
Measured, p95 in milliseconds for one request at a time:
| Posts (database size) | Pages and APIs | Searches |
|---|---|---|
| 100,000 (240 MB) | under 4 | not measured |
| 1,000,000 (2.1 GB) | under 6 | mostly under 60 |
With the database four times the memory, most searches stay fast, but
the few that read many posts do not: a common word in notes (note:)
takes about 0.9 s, a user’s saved searches (search:all) 0.4 s, a file
type with a common tag 0.25 s, and pool:any and tagger suggestions
(ai:) about 0.1 s. For a site that size, 2–4 GB of memory should keep
the database cached and bring those back under 100 ms, as they are for
5,000,000 posts on a large machine on the Scaling page
(estimated).
Seeding shows what writing costs: about 2,500 posts a second on two cores, database work only.
Disk
- Database: about 2 KB per post measured, with a site’s worth of tags, comments, notes and tagger suggestions (2.1 GB for 1,000,000 posts).
- Files: the originals, plus thumbnails and, for large images, a
sample; estimated at 20–30% on top of the originals, depending on
media.thumbnail_sizesandmedia.sample_size. - Images: about 180 MB for Moekura’s and 300 MB for PostgreSQL’s.
An SSD matters more than cores: searches that miss the cache read pages from disk at random.
Raspberry Pi 4
Estimated, not measured: a Raspberry Pi 4 with 2 GB or more should run
the tiny setup (the images are built for arm64), its cores being a few
times slower than a desktop’s. Expect pages several times slower than
above, still well under a second for a small site. Keep the database on
an SSD over USB 3 rather than the SD card, which is slow at random reads
and wears out. If you run one, moekura admin bench-http measures it.
With Docker Compose
The smallest setup is the app and PostgreSQL on one machine. The
repository’s deploy/compose.tiny.yml runs both with Docker or Podman
Compose:
git clone https://github.com/moe-studios/moekura
cd moekura
echo "POSTGRES_PASSWORD=$(openssl rand -hex 16)" > deploy/.env
MOEKURA_BUILD_VERSION="git-$(git rev-parse --short=7 HEAD)" \
docker compose -f deploy/compose.tiny.yml up -d
curl localhost:8080/readyz # → ok
Open http://localhost:8080. The database is migrated automatically on start.
Two volumes hold everything worth keeping: db (PostgreSQL) and files
(uploads and thumbnails). Back them up together; see Backups.
The PostgreSQL settings in the file suit a machine with 1 GB of memory; Hardware has what the stack needs and how to grow it.
The image (about 180 MB) includes its own builds of libvips and ffmpeg with only the formats Moekura accepts.
To suggest tags for uploads with the tagger, add its compose file:
MOEKURA_BUILD_VERSION="git-$(git rev-parse --short=7 HEAD)" \
docker compose -f deploy/compose.tiny.yml -f deploy/compose.tagger.yml up -d
Published images
Images are published to ghcr.io/moe-studios/moekura for AMD64 and ARM64:
0.4.0(for example): a release;latesttracks stable releases.edge: the latest published main branch commit.git-<hash>: a specific commit, using either its seven-character or full hash.
Append -tagger to any tag for the tagger image, such as edge-tagger.
The page footer shows the release version or git-<short hash> embedded in
that build. Main builds do not move latest.
The compose files build from source. Since the Docker context excludes .git,
the commands above pass the version as a build argument. When building a
release checkout, set MOEKURA_BUILD_VERSION to its semver (without v).
Settings
Configure the app with MOEKURA_* environment variables in the compose file,
for example:
environment:
MOEKURA_DATABASE__URL: postgres://moekura:${POSTGRES_PASSWORD}@db/moekura
MOEKURA_SERVER__PUBLIC_URL: https://booru.example.com
MOEKURA_MEDIA__MAX_UPLOAD_MB: "200"
Every setting is listed under Configuration. Put the site behind a reverse proxy for HTTPS, then continue with First steps.
Updating
git pull
MOEKURA_BUILD_VERSION="git-$(git rev-parse --short=7 HEAD)" \
docker compose -f deploy/compose.tiny.yml up -d --build
Migrations run when the new version starts.
Without containers
You need:
- PostgreSQL 16 or newer, and a database the app owns.
- The media tools, which
serveandworkercheck for at startup:
| Tool | Used for | Fedora | Debian / Ubuntu |
|---|---|---|---|
libvips 8.15+ (vips, vipsheader, vipsthumbnail) | reading images, thumbnails, perceptual hashes | vips-tools (AVIF: vips-heif, JPEG XL: vips-jxl) | libvips-tools libheif-plugin-dav1d libheif-plugin-aomenc |
ffmpeg (ffmpeg, ffprobe) | reading videos, poster frames, and videos of ugoira (with libvpx for VP9) | ffmpeg (RPM Fusion) or ffmpeg-free | ffmpeg |
Download a release binary (see Upgrading), or build one with Rust 1.94 or newer:
cargo build --release
sudo install target/release/moekura /usr/local/bin/
Create the database:
sudo -u postgres createuser --pwprompt moekura
sudo -u postgres createdb --owner moekura moekura
Then write /etc/moekura/moekura.toml (start from
moekura.example.toml in the repository) with at least:
[server]
public_url = "https://booru.example.com"
[database]
url = "postgres://moekura:PASSWORD@localhost/moekura"
[storage]
path = "/var/lib/moekura/data"
and start it:
moekura --config /etc/moekura/moekura.toml serve
As a systemd service
# /etc/systemd/system/moekura.service
[Unit]
Description=Moekura
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
User=moekura
Environment=MOEKURA_CONFIG=/etc/moekura/moekura.toml
# Keeps memory use down after bursts of requests (as in the image).
Environment=MALLOC_ARENA_MAX=2
ExecStart=/usr/local/bin/moekura serve
Restart=on-failure
# Keep the database password out of the config file:
# EnvironmentFile=/etc/moekura/secrets.env
[Install]
WantedBy=multi-user.target
sudo useradd --system --home-dir /var/lib/moekura --create-home moekura
sudo systemctl enable --now moekura
moekura check-config prints the settings it would use, with secrets
redacted.
Behind a reverse proxy
Put a reverse proxy in front of the app for HTTPS. Then tell the app two things:
server.public_url: thehttps://address people use. Cookies are then markedSecure, and forms are only accepted from that origin.server.trusted_proxies: the proxy’s address. The app believes theX-Forwarded-Forheader only from these, and uses it for bans and rate limits. Without it, every visitor appears to come from the proxy.
[server]
public_url = "https://booru.example.com"
trusted_proxies = ["127.0.0.1/32"]
The proxy must pass the Host and Origin headers through unchanged:
they protect forms against cross-site requests.
Caddy
booru.example.com {
reverse_proxy 127.0.0.1:8080
}
Caddy gets a certificate, sets X-Forwarded-For, and has no upload size
limit by default.
nginx
server {
listen 443 ssl;
server_name booru.example.com;
# ssl_certificate …
# At least media.max_upload_mb.
client_max_body_size 110m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Stream uploads to the app instead of buffering them.
proxy_request_buffering off;
}
}
Health checks
GET /healthz answers when the process is up; GET /readyz also checks
the database. Point load balancers at /readyz.
First steps
The first admin
Create the first account from the shell. It asks for a password (or reads one line from standard input when piped):
moekura admin create-user yourname --role admin
# with compose:
docker compose -f deploy/compose.tiny.yml exec app moekura admin create-user yourname --role admin
Log in, then open Admin in the menu.
Name, logo and rules
Under Admin → Settings:
- Site name and Description show in the header, the footer and to
search engines (
<meta name="description">). - Logo: an image (PNG, JPEG, GIF, WebP or AVIF, up to 1 MB) shown beside the name in the header.
- Rules: what may be posted and how to behave, in the same
markup as the wiki. They’re shown at
/rules, which anyone can read (on private sites too), and linked from the footer, the sign-up form and the upload page. Leave them empty for no rules page. - Footer links: one per line, the link’s text then its address, like
Discord https://discord.gg/abcorHelp /wiki/help.
From the shell, the footer links are a JSON list:
moekura admin settings set footer_links '[{"label": "Discord", "url": "https://discord.gg/abc"}]'
Who can register
Registration is open by default. Change it under Admin → Settings, or from the shell:
moekura admin settings set registration_mode closed
| Mode | Who can create an account |
|---|---|
open | anyone |
invite | people with an invite code (moekura admin create-invite) |
approval | anyone, but staff approve new accounts before they can log in (Admin → Users, filter pending) |
closed | nobody; admins create accounts from the shell |
With [mail] set up, people can reset a
forgotten password from the login page, and confirm their address under
Settings → Your email address and password. Without it, those pages
don’t appear, and people who forget their password need an admin.
To make new accounts confirm their address before they can log in, tick New accounts must confirm their email address under Admin → Settings, or:
moekura admin settings set email_verification true
Registering then needs an address. Accounts waiting for the link are
unverified under Admin → Users, where you can also activate one by
hand. With approval registration, confirming the address puts the
account in the approval queue.
What visitors see
Visitors see every active post by default. To hide some unless people opt in, set a default blacklist, which applies to visitors and to users who haven’t set their own:
moekura admin settings set default_blacklist "rating:e"
A blacklist only hides posts until someone turns it off. To keep visitors from seeing some ratings at all, in searches, on post pages, in feeds and through the APIs, limit the ratings they see (logged-in users still see everything):
moekura admin settings set visitor_ratings '["g", "s"]'
To show nothing at all without logging in, make the site private.
Link previews
Links to posts, pools and wiki pages unfurl in chat apps and social
networks (OpenGraph and Twitter card tags), and posts have
oEmbed at /oembed?url=…. Video posts include
OpenGraph video URLs, formats and dimensions for inline playback in Discord,
with their poster as the thumbnail. The site and media URLs must be publicly
reachable; playback depends on the client’s support for the original MP4 or
WebM file. Previews show a post’s image or video only for general and
sensitive posts, unless you tick
Link previews … show questionable and explicit posts’ images too in
the site settings (or moekura admin settings set preview_all_ratings true). Private sites show no previews at all.
Reviewing uploads
When New uploads wait for approval is ticked under Admin → Settings, uploads by users without the Upload without approval permission wait in the approval queue (Moderation). See Moderation.
Upgrading
Releases are tagged vX.Y.Z and listed, with what changed, in
CHANGELOG.md.
Each comes as a container image for amd64 and arm64
(ghcr.io/moe-studios/moekura:X.Y.Z, also tagged X.Y and latest) and as
Linux binaries on the release page.
Before 1.0, a minor release (0.1 → 0.2) may change configuration or behaviour; its changelog says what to do. Patch releases (0.1.0 → 0.1.1) only fix things.
Steps
- Read the changelog for every release between yours and the new one.
- Back up the database.
- Replace the program: pull the new image, or put the new binary in place.
- Start it. Database migrations run on start unless
database.auto_migrate = false; with several servers, runmoekura migrateonce first, then restart them all.
Migrations only move forward. To go back to an older version, restore the backup you took.
With Docker Compose
Point the app service at a release instead of building it:
services:
app:
image: ghcr.io/moe-studios/moekura:0.4
docker compose -f deploy/compose.tiny.yml pull
docker compose -f deploy/compose.tiny.yml up -d
Binaries
The Linux binaries are built on Ubuntu 24.04 and need glibc 2.39 or newer (Debian 13, Ubuntu 24.04, Fedora 40 and later), plus the media tools.
Configuration
There are two kinds of settings:
- Server configuration, read at startup: where the database is, where files go, limits. Changing it needs a restart. It’s described on this page.
- Site settings, stored in the database and changed while the site
runs: the site’s name, registration, the approval queue, the default
blacklist. Change them under Admin → Settings or with
moekura admin settings.
Where server configuration comes from
Each layer overrides the one before:
- built-in defaults,
moekura.tomlin the working directory, or the file given with--config <path>orMOEKURA_CONFIG,- environment variables.
Every key has an environment variable MOEKURA_<SECTION>__<KEY> (two
underscores), for example MOEKURA_DATABASE__URL or
MOEKURA_STORAGE__S3__BUCKET. Lists are written as TOML, e.g.
MOEKURA_SERVER__TRUSTED_PROXIES='["10.0.0.0/8"]'. Unknown keys are refused, so
a typo fails loudly instead of being ignored.
moekura check-config validates the configuration and prints the result
with passwords redacted.
[server]
| Key | Default | Meaning |
|---|---|---|
bind | "0.0.0.0:8080" | address the HTTP server listens on |
public_url | "http://localhost:8080" | the address people use; set it to your https:// URL (cookies become Secure, and forms are only accepted from this origin) |
trusted_proxies | [] | reverse proxies allowed to report the client’s address in X-Forwarded-For (addresses or CIDR ranges) |
request_timeout_secs | 30 | requests running longer are stopped with a 408 |
api_requests_per_minute | 300 | API requests a client may make a minute on average (per account, or per address for visitors); 0 for no limit |
api_burst | 60 | how many API requests may come at once before the per-minute rate applies |
[database]
| Key | Default | Meaning |
|---|---|---|
url | (required) | the primary’s connection URL |
replicas | [] | read replicas’ URLs, used for searches and listings |
max_connections | 16 | per pool (the primary and each replica) |
min_connections | 0 | |
acquire_timeout_secs | 5 | how long to wait for a free connection |
statement_timeout_ms | 30000 | server-side limit per statement; 0 for none |
auto_migrate | true | migrate on start; with several servers, set false and run moekura migrate when deploying |
replica_max_lag_secs | 10 | replicas further behind are skipped until they catch up; also how long someone’s reads stay on the primary after they change something |
[auth]
| Key | Default | Meaning |
|---|---|---|
session_idle_days | 30 | a login ends after this many days unused… |
session_max_days | 365 | …or this long after logging in, however active |
[auth.oidc]
Lets people log in through an OpenID Connect provider (single sign-on): Authentik, Keycloak, Kanidm, Zitadel, Google and others. Leave the section out to turn it off.
| Key | Default | Meaning |
|---|---|---|
issuer | (required) | the provider’s issuer URL, which describes itself at /.well-known/openid-configuration under it; https://, or http:// on the same machine |
client_id | (required) | from registering Moekura with the provider |
client_secret | (empty) | likewise; empty for a public client |
button_label | "Log in with single sign-on" | the button on the login page |
scopes | ["openid", "email", "profile"] | must include openid |
Register the redirect URI https://your.site/login/oidc/callback (from
server.public_url) with the provider. Logins use the authorization code
flow with PKCE.
Someone logging in through the provider for the first time gets a new
account (with no password) when registration is open, or one waiting for
approval when it’s approval; with invite or closed, only people who
linked an existing account can. A new
account takes its name from the provider (the next free one if it’s
taken), and the provider’s email address if it says it’s verified,
nobody here uses it yet, and the site accepts its domain.
[auth.captcha]
A captcha service for the sign-up form and new accounts’ comments: where it’s asked for is chosen under Admin → Settings (nowhere, until then). Leave the section out to turn it off.
| Key | Default | Meaning |
|---|---|---|
provider | (required) | "turnstile" (Cloudflare Turnstile) or "hcaptcha" |
site_key | (required) | the public key the provider gives you |
secret_key | (required) | the private key tokens are checked with |
verify_url | (the provider’s) | where tokens are checked, for a proxy or a compatible service |
The page asking for it loads the provider’s script and frame, which the content security policy then allows from the provider’s origin.
[cache]
| Key | Default | Meaning |
|---|---|---|
backend | "memory" | where rate limit counters and cached search counts live: "memory" (each process on its own) or "valkey" (shared by every web server) |
url | (unset) | for valkey: redis://host:6379, or rediss:// for TLS; Redis and other compatible servers work too |
count_ttl_secs | 30 | how long a search count that reached search.count_limit (“10,000+”) is reused; 0 turns this off |
prefix | "moekura" | starts every key stored in Valkey; give sites that share a server different prefixes |
If Valkey stops answering, each server counts rate limits on its own and stops caching until it’s back, and logs a warning; nothing fails.
[jobs]
| Key | Default | Meaning |
|---|---|---|
workers | 2 | background jobs processed at once, per process |
run_in_serve | true | also run workers inside serve; set false when you run moekura worker separately |
lock_timeout_secs | 300 | a job whose worker stopped responding is retried after this |
[mail]
Outgoing mail over SMTP, for email verification and password resets. Messages are sent by the job workers, so a slow mail server doesn’t hold up the site, and failed sends are retried.
| Key | Default | Meaning |
|---|---|---|
host | (empty) | the SMTP server; empty turns mail off, along with the features that need it |
tls | "starttls" | "starttls" (upgrade a plain connection; required), "tls" (TLS from the start) or "none" (only for a relay on the same machine or network) |
port | (by tls) | 587 for starttls, 465 for tls, 25 for none |
username, password | (empty) | the login, if the server needs one |
from | (empty) | the sender, as address@example.com or Site name <address@example.com>; required with host |
timeout_secs | 30 | connecting or sending one message gives up after this |
Check the settings with moekura admin send-test-mail you@example.com,
which sends straight away and prints any error.
[search]
| Key | Default | Meaning |
|---|---|---|
per_page | 40 | posts per page |
max_per_page | 200 | the most limit: may ask for |
max_page | 1000 | deepest numbered page; “next” links keep working beyond it |
max_terms | 40 | most tags and filters in one search |
wildcard_limit | 100 | most tags a wildcard expands to (the most used) |
count_limit | 10000 | result counts are exact up to this, estimated above |
count_cost_limit | 25000 | counts PostgreSQL expects to cost more than this (roughly pages read) are estimated instead, so filters no index covers don’t read every post |
[storage]
| Key | Default | Meaning |
|---|---|---|
backend | "local" | "local" (a directory) or "s3" |
path | "data" | the directory, for local |
public_base_url | (unset) | where browsers load files from, e.g. a CDN; unset, the app serves them under /data/ |
[storage.s3]
| Key | Default | Meaning |
|---|---|---|
bucket | "" | |
region | "us-east-1" | |
endpoint | (unset) | for MinIO, Garage, R2, B2 and others; unset for AWS |
access_key_id, secret_access_key | "" | empty to use AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY or instance credentials |
path_style | false | put the bucket in the path; most self-hosted stores need this |
See File storage.
[media]
| Key | Default | Meaning |
|---|---|---|
max_upload_mb | 100 | largest upload |
max_pixels | 200000000 | larger images are refused before decoding |
max_duration_secs | 600 | longest video |
allowed_types | ["jpeg", "png", "gif", "webp", "avif", "mp4", "webm", "ugoira"] | ugoira is Pixiv’s zip of animation frames; add "jxl" for JPEG XL (off by default: libvips doesn’t consider its decoder hardened against malicious files) |
thumbnail_sizes | [250, 500] | thumbnail boxes, 1x and 2x for high-density screens |
sample_size | 1600 | larger images also get a resized copy for the post page |
variant_format | "webp" | "webp" or "avif" (smaller, slower) for thumbnails and samples |
tool_timeout_secs | 120 | longest a media tool may run |
work_dir | system temp dir | scratch space for uploads and processing |
[media.tools]
Paths to vips, vipsheader, vipsthumbnail, ffmpeg and ffprobe,
if they aren’t on PATH.
[paths]
| Key | Meaning |
|---|---|
templates_override | a directory whose files replace built-in templates with the same path, e.g. base.html |
static_override | the same for static files, e.g. css/main.css; also where themes are added |
[tagger]
The optional tagger, which suggests tags for new uploads.
| Key | Default | Meaning |
|---|---|---|
enabled | false | queue new uploads for moekura tagger; set it for every process |
model | "wd-vit-tagger-v3" | wd-vit-tagger-v3, wd-convnext-tagger-v3, wd-swinv2-tagger-v3, wd-eva02-large-tagger-v3, or "custom" |
model_url, tags_url | unset | for custom: the ONNX model and its selected_tags.csv |
model_sha256, tags_sha256 | unset | for custom: their SHA-256 checksums |
model_dir | "data/models" | where models are downloaded to, one directory each |
runtime | ORT_DYLIB_PATH, or the system’s | the ONNX Runtime library, libonnxruntime.so |
threads | 0 | threads one image uses; 0 means one per core |
workers | 1 | posts tagged at once |
account | "tagger" | who automatically applied tags are credited to; created without a password on first use |
[telemetry]
| Key | Default | Meaning |
|---|---|---|
log_format | "text" | "text" or "json" |
log_filter | "info,sqlx::postgres::notice=warn,ort=warn" | a tracing filter; RUST_LOG overrides it ("info,tower_http=debug" logs every request) |
[webhooks]
| Key | Default | Meaning |
|---|---|---|
allow_private_addresses | false | let webhooks go to private, loopback and link-local addresses (a service on the same machine or network) |
timeout_secs | 10 | how long a delivery may take |
Roles and permissions
Everyone has a role, and a role is a set of permissions. Logged-out visitors have the Anonymous role. Edit roles under Admin → Roles: rename them, and choose their permissions. You can only hand on permissions you have yourself; ones you lack stay as they are.
Adding roles
Add a role at the bottom of Admin → Roles with a name, a rank below your own, and optionally the permissions of an existing role to start from. The ranks of added roles can be changed later, and they can be deleted, which moves their users to a role you choose. The built-in roles can’t be deleted and keep their ranks.
| Permission | Allows |
|---|---|
| View posts | seeing posts, tags and profiles; take it from Anonymous for a private site |
| Upload | uploading posts |
| Upload without approval | skipping the approval queue, when it’s on |
| Edit posts and tags | changing a post’s tags, rating, source, description and parent; requesting aliases and implications |
| Favorite, Vote | favoriting posts; voting on posts and comments |
| Comment | posting comments, and editing and deleting your own |
| Edit the wiki and artists | |
| Mass edit tags | adding and removing tags on every post a search finds (Moderation → Mass edit) |
| Lock posts and change locked ones | locking a post’s rating, tags, notes or status, and changing them anyway |
| Undo a user’s post edits | taking back every post edit a user made in a range of days (Moderation → Post changes) |
| Replace posts’ files | swapping a post’s file for a better one, keeping everything else (Posts and files) |
| Edit notes | adding, moving, changing and deleting notes on posts |
| Create and edit pools | making pools, and changing their posts, names and descriptions (deleting a pool takes Delete and restore posts) |
| Flag posts | asking moderators to delete a post |
| Approve posts and handle flags | the approval and flag queues |
| Delete and restore posts | |
| Purge posts | removing deleted posts and their files for good |
| Manage tags, aliases and implications | tag categories, deprecating tags, deciding alias and implication requests |
| See deleted posts | deleted posts and comments |
| Hide comments and handle reports about them | hiding and restoring anyone’s comments, and the reported comments queue |
| Ban users and networks | |
| Manage users | changing other users’ roles and account status |
| Manage site settings and roles | |
| Read the moderation log |
The built-in roles and what they start with:
| Role | Rank | Permissions |
|---|---|---|
| Anonymous | 0 | View posts |
| Member | 10 | Anonymous, plus Upload, Edit posts and tags, Comment, Favorite, Vote, Flag posts, Edit the wiki and artists, Create and edit pools, Edit notes |
| Contributor | 20 | Member, plus Upload without approval |
| Janitor | 30 | Contributor, plus Approve posts, Delete and restore posts, Manage tags, See deleted posts, Hide comments |
| Moderator | 40 | Janitor, plus Ban users and networks, Read the moderation log, Mass edit tags, Lock posts, Undo a user’s post edits, Replace posts’ files |
| Admin | 50 | everything |
Upload limits
Each role can limit uploads, under Admin → Roles (empty means no limit):
- Waiting for approval at once: when the approval queue is on, how many of a user’s uploads may wait in it. Members start at 10. Roles with Upload without approval skip the queue, so this doesn’t apply to them.
- Per day: uploads in the last 24 hours, queued or not.
With Limits on uploads waiting for approval grow… ticked in the site settings, the queue limit follows each user’s record, as on Danbooru: one more for every 10 of their uploads that were approved, one fewer for every 5 that were deleted, from 1 up to four times the role’s limit.
The upload page tells users how many uploads they have left, and why an
upload was refused; the API’s /users/me says the same under uploads.
Automatic promotion
With Automatic promotion ticked in the site settings, members become contributors (who upload without approval) once their record is good enough. Every hour, members are promoted who have at least the set number of approved uploads and post edits, have been registered for the set number of days, and have had at most the set number of uploads deleted in the last 30 days. Defaults: 50 uploads, no edits needed, 30 days, no recent deletions.
Banned members aren’t promoted. Staff who manage users can keep someone from automatic promotion with Never promote automatically on their profile. Promotions are in the moderation log, and profiles say when someone was promoted.
Rank
Staff act only on people below them: a moderator can ban members and janitors, but not other moderators or admins. The same goes for changing roles: you can give or take away only roles ranked below your own that grant nothing you lack, and never change your own role or status. That keeps a mistake, or a compromised account, from locking out the people above it.
From the shell, moekura admin set-role NAME ROLE changes anyone’s role,
which is how you recover if the last admin loses access.
Moderation
Everything here is under Moderation in the menu, for people whose role allows it, and every action is recorded in the moderation log with who did it and why.
The approval queue
When New uploads wait for approval is ticked (Admin → Settings), uploads by people without Upload without approval are
pending: only their uploader and staff see them. Approve them, or reject
them with a reason, from Moderation → Approval queue, or under
Moderate on the post’s own page. The queue leaves out your own
uploads; find those with status:pending user:yourname and approve them
from their page.
An approver can also disapprove a post: pass on it without rejecting
it, saying whether it breaks the rules, is of poor quality, or just isn’t
for them, with an optional note. The post stays pending and leaves that
approver’s queue, while other approvers see the disapprovals (and the post
page lists them). The queue holds the posts found by status:unmoderated:
pending posts the approver didn’t upload and hasn’t disapproved. A user’s
moderation record counts the posts they disapproved.
The queue has a search box, which takes the usual
search syntax (tags, user:name, rating:e and so
on, but not status:), and a choice of order: oldest first (the
default), newest first, score, favorites, fewest tags or size. Tick posts
to approve or reject them together (up to 100 at once, with one reason
for the rejections); any that someone else dealt with meanwhile are
skipped.
Flags
Members flag posts that should go, with a reason. Flagged posts stay visible, marked flagged, and appear under Moderation → Flags. Dismiss the flags to keep the post, or delete it, which upholds them. Each person can flag posts and report comments about ten times at once, then once a minute.
Comments
Members report comments, with a reason; reported comments appear under Moderation → Reported comments. Staff with Hide comments can hide any comment (which upholds its reports) and restore it later, or dismiss the reports. Hidden comments, and those their authors deleted, stay visible to staff, marked deleted. Comments voted down to −5 or lower are collapsed for everyone.
Reasons
Deleting, rejecting and flagging a post offer the site’s preset reasons (Duplicate, Poor quality, Off-topic, Breaks the rules to begin with), so reasons stay consistent, with a box for details or a reason of one’s own (Other). A preset with details is recorded as “Poor quality: blurry”. Change the lists under Admin → Settings → Moderation reasons, one per line; empty lists leave just the box. The API and Danbooru clients send free text as before.
Deleting, restoring, purging
Deleting a post (with a reason, which is required and shown on the post) hides it from everyone without See deleted posts, except its uploader, who still sees the post and why it went, but can’t change it. It can be restored. Purging a deleted post removes it, its files and its history for good, in the background.
Purging in bulk
Staff with Purge posts (admins, by default) purge many deleted posts at
once under Moderation → Purge. Search the deleted posts with the
usual search syntax, such as user:name for a
spam account’s leftovers (status:deleted is implied, and other
statuses are refused), or leave the search empty for every deleted post.
The preview says how many posts match and shows the first of them; tick
some and Purge ticked (up to 500 at once), or Purge all to purge
every match. Either way, tick the box confirming the posts, their files
and their history go for good.
A background job then purges the posts one by one, just as purging each
would, logging each purge as yours; starting the purge is logged too,
with its search or the posts ticked. Posts are checked again as the job
gets to them, so one restored meanwhile is skipped. A post whose files
can’t be removed is counted as failed and left deleted (purge it again
later); if several in a row fail, the job stops and is retried later,
carrying on where it stopped rather than starting over. The page lists
recent purges with how many posts were purged, skipped and failed. The
API has the same operation (POST /api/v1/moderation/purge with a
query or post_ids, followed with
GET /api/v1/moderation/post-batches/{id}).
Locks
Staff with Lock posts (moderators, by default) lock a post’s rating, tags, notes or status under Moderate on the post page, for instance to end an edit war. The post says what’s locked, and locking and unlocking show in its history and the log. For everyone without Lock posts:
- a locked rating or tags can’t be changed: not by editing the post (on the site, through the API, Danbooru apps or tag scripts), nor by reverting to an earlier version;
- locked notes can’t be added, changed or deleted;
- a locked status means the post can’t be flagged, approved, rejected, deleted, restored or appealed.
Mass tag edits leave posts with locked tags alone, and the tagger doesn’t touch locked tags or ratings. Tag aliases and implications still apply to every post, so a renamed tag stays renamed.
Appeals
The uploader of a deleted post (and anyone who can see deleted posts),
if their role can flag posts, can appeal it from the post page with a
reason. A post has one open appeal at a time, and each person can appeal
three posts at once, then one more every four hours. Open appeals are
listed under Moderation → Appeals (and found with status:appealed)
for those who can delete and restore posts: Restore brings the post
back and grants the appeal (so does restoring it any other way); Keep
deleted turns the appeal down, with an optional reason, in the log. The
post page keeps its appeals and how they ended, for staff and the
uploader.
Bans
Ban a user from their profile, for a set time or until lifted, with a reason. Banned users can still log in and look around as visitors do, see why they’re banned, and can’t change anything; their API keys are limited the same way. Banning someone who’s already banned replaces their ban with the new reason and length. Timed bans last up to 3650 days.
Networks (an address or a CIDR range such as 203.0.113.0/24, or
2001:db8::/64 for IPv6, where one household usually has a whole /64)
are banned under Moderation → Bans, partly or fully:
- a partial ban lets requests from the network read, but not register, log in or change anything;
- a full ban keeps the network from seeing the site at all: every page and API call answers that the network is banned, with the reason.
The range may not include your own address, or be wider than a /8
(IPv4) or /16 (IPv6). Network bans are kept in memory on every node,
so checking them costs nothing per request; changes reach other nodes
within moments.
A user’s record
Staff who can ban users or read the log see a Moderation record link on each profile (and the log’s user names lead there too). The page puts a user’s history in one place: their role, status, when they joined and were last seen, whether two-factor login is on and whether they’re kept from automatic promotion; their bans, with controls to ban or lift the ban; their uploads by status and the recent deletions with reasons; the flags on their uploads, and the flags they filed with how many were upheld or dismissed; reports about their comments and their hidden comments; and, for those who read the log, what was logged about them and what they did themselves.
Deleting all of a user’s uploads
To clean up after a spam account, staff with Delete posts can delete
every upload of a user ranked below them at once: Delete all uploads
under Uploads on the record says how many posts that is, and asks for a
reason (the same presets as deleting one post) and a tick to confirm.
A background job then deletes the user’s active, flagged and pending
posts in batches, just as deleting each one would: the posts show the
reason, open flags on them are upheld, tag counts drop, each deletion is
logged as yours, and every post can still be restored (webhooks aren’t
sent a post.deleted event for each, though). Posts already
deleted are left out; those whose status is locked are skipped unless
you have Lock posts. The record shows the progress: how many posts
were deleted, skipped (dealt with meanwhile, or locked) and failed. Only
one such deletion runs per user at a time, and the account itself stays;
ban it separately. The API has the same operation
(POST /api/v1/users/{name}/delete-uploads, followed with
GET /api/v1/moderation/post-batches/{id}).
Staff notes
The same staff keep private notes about users, on the profile and the record: who wrote each and when, in the same markup as comments. Only staff see them, not the user. Authors delete their own notes; those who can ban users delete anyone’s.
Addresses
For staff who can ban users, the record also lists the addresses the
account used, when each was first and last seen, and the other accounts
seen on the same addresses, which is how ban evaders usually show. Each
address has shortcuts to ban it, or its /24 (IPv4) or /64 (IPv6)
network, under Moderation → Bans.
What’s stored, for your privacy policy: for each account, each address it logged in or changed something from (posting, editing, voting, changing settings and so on; merely reading pages isn’t recorded), with the first and last time it was seen, at most hourly. Addresses are kept for 365 days after they were last seen, then forgotten by a daily job; change that under Admin → Settings (Keep the addresses accounts use), where 0 keeps none and stops recording them. Deleting an account deletes its addresses. Sessions separately keep the address they were started from until they end.
Spam accounts
Besides rate limits, email confirmation and approval of new accounts (Admin → Settings → Registration), two settings under Admin → Settings → Spam keep spam accounts out:
- Email domains: a list of domains whose addresses are refused (such as disposable-mail services), or the only ones accepted (such as a school’s). Each domain covers its subdomains. It applies when signing up and changing an address; accounts made through single sign-on simply don’t take a refused address.
- Captcha: with a service set up (see
[auth.captcha]), ask for it when signing up, and on comments by accounts younger than a number of days.
Tag aliases and implications
Members request them under Tags → Aliases or Implications; people who can manage tags approve or reject them (their own requests apply at once). See Tags.
Mass tag edits
Moderation → Mass edit (for those with Mass edit tags) adds and
removes tags on every post a search finds: cat_ears → add
animal_ears, remove cat_ears. Preview shows how many posts match
and the first of them; Change starts a background job. The page
lists recent mass edits with their progress. Changes show in each
post’s history, credited to whoever started the edit, and added tags
bring the tags they imply. The tags to add can also take -tag and
rating:e to set every post’s rating; locked tags and ratings are left
alone. Deleted posts are only changed if the search asks for them
(status:deleted or status:any).
Bulk update requests (Tags → Requests) bundle several alias, implication, category and mass edit changes; approving one applies them in order, and approving or rejecting it is logged. See Tags.
Post changes and undoing vandalism
Moderation → Post changes (/post_versions, also linked from each
post’s history and each profile) lists every change to posts across the
site, newest first, for anyone: tags added and removed, rating, source,
parent, description and locks. Filter it by who made the change, the
post, a tag added or removed, and a range of days. Changes to tags,
wiki pages, pools and notes have lists of their own (/tag_versions,
/wiki_page_versions, /pool_versions, /note_versions), filtered by
user the same way and linked from each user’s moderation page.
Filtered to one user, it offers those with Undo a user’s post edits (moderators, by default) Undo their edits, for users ranked below them: in the background, every post edit the user made in the range is taken back. Tags they added come off and tags they removed go back; a rating, source, description or parent they set is put back where nobody changed it since, and locked tags and ratings are left alone. Uploads aren’t edits and stay. Each post’s history credits whoever started the undo, and the log records it.
The moderation log
Moderation → Log lists every staff action: approvals, deletions, purges, flag decisions, bans, tag and relation changes, role and setting changes (including those made from the shell). Filter it by action, moderator, post, the user acted on, or a range of days. Role, status and setting changes show what they changed from.
Private sites
To keep everything behind a login, take View posts away from the Anonymous role (Admin → Roles). Then:
- visitors are sent to the login page;
- the API answers
401to requests without a key; - file links on pages carry a signature that expires after an hour or two, so files can’t be fetched by guessing or sharing their URLs.
Pick a registration mode to
match: usually invite, approval or closed.
Files need to go through the app
Signed links only protect files the app serves itself. Leave
storage.public_base_url unset: with local storage the app serves files
under /data/, and with S3 it streams them from the bucket, so the bucket
can stay private.
A CDN or public bucket address would hand files to anyone who has the link, so the server warns about this combination at startup.
Feeds need a user’s feed token on a private site; see Feeds.
File storage
Originals, thumbnails, samples and video posters are stored under keys
derived from the file’s SHA-256, such as
original/ab/cd/abcd….png. The same file is only ever stored once.
On disk
The default. Files go under storage.path (./data, or
/var/lib/moekura/data in the container image), and the app serves them
under /data/ with long cache lifetimes: a key never changes content.
S3-compatible storage
Any S3-compatible store works: AWS S3, MinIO, Garage, SeaweedFS, Cloudflare R2, Backblaze B2.
[storage]
backend = "s3"
[storage.s3]
bucket = "moekura"
region = "auto"
endpoint = "https://ACCOUNT.r2.cloudflarestorage.com"
# Or MOEKURA_STORAGE__S3__ACCESS_KEY_ID / MOEKURA_STORAGE__S3__SECRET_ACCESS_KEY.
access_key_id = "…"
secret_access_key = "…"
# Most self-hosted stores need this.
path_style = true
Objects are stored with their content type and
Cache-Control: public, max-age=31536000, immutable: a key never changes
content, so a CDN or browser may keep it forever.
Without public_base_url, the app streams files from the bucket, which can
stay private.
A CDN or public bucket
Set storage.public_base_url to where browsers can load the files, and the
app links there instead of serving them:
[storage]
public_base_url = "https://cdn.example.com"
The site’s content security policy allows images and videos from that origin. Don’t do this on a private site.
For a CDN in front of a bucket:
- make the bucket (or the path the CDN reads) publicly readable, or give the CDN its own credentials;
- let the CDN honour the objects’
Cache-Control, or cache everything for a long time: nothing under a key ever changes; - serve the files from a different domain than the site (for example
cdn.example.com), so uploaded files can never run as the site.
Files stored by versions before 0.1 have no content type or caching
headers of their own. moekura admin regenerate-media --all stores the
thumbnails and samples again; the originals keep what they had, so give the
CDN a default Cache-Control.
Media settings
After changing thumbnail sizes, the sample size or the format under
[media], regenerate what’s stored:
moekura admin regenerate-media --all
Background jobs
Work that shouldn’t hold up a page runs as background jobs, queued in PostgreSQL:
| Job | Does |
|---|---|
media.process | makes thumbnails, samples and video posters, and the perceptual hash for similar-image search, after an upload |
tags.apply_relation | re-tags existing posts when an alias or implication is approved |
posts.purge | removes a purged post and its files |
ml.tag_post | suggests tags for a post; only moekura tagger takes these |
serve runs job workers itself, jobs.workers at a time. On a busier site,
run workers as separate processes and turn them off in the web servers:
moekura worker # as many as you like, on any machine
[jobs]
run_in_serve = false # on the web servers
Workers claim jobs without stepping on each other, and only the kinds
they can do: ml.tag_post jobs wait for a tagger. A failed job is retried
with increasing delays; after its last attempt it’s kept as failed.
Admin → Overview shows the queue, and failed jobs with their errors,
to retry or discard.
Webhooks
Webhooks tell other services about events on the site as they happen: a chat bot announcing uploads, a mirror, or your own scripts. Add them under Admin → Webhooks (for those who manage site settings), with the URL to send to and the events it wants:
| Event | When |
|---|---|
post.created | a post is uploaded (by any means) |
post.approved | a pending post is approved |
post.deleted | a post is deleted or rejected |
post.flagged | a post is flagged |
comment.created | a comment is posted |
user.registered | someone registers (with a form or single sign-on) |
Deliveries
Each event is a POST of JSON:
{
"id": 42,
"event": "post.created",
"created_at": "2026-09-26T12:00:00Z",
"data": { "post_id": 123, "url": "https://booru.example.com/posts/123", "tags": ["cat"], … }
}
with these headers:
| Header | |
|---|---|
X-Moekura-Event | the event |
X-Moekura-Delivery | the delivery’s number (the same across retries) |
X-Moekura-Timestamp | seconds since 1970 when it was sent |
X-Moekura-Signature | sha256= and the HMAC-SHA256, in hex, of {timestamp}.{body} with the webhook’s secret |
Check the signature and reject old timestamps (older than five minutes, say), so nobody else can send you events or replay old ones. In Python:
import hashlib, hmac, time
def valid(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(timestamp)) < 300
return fresh and hmac.compare_digest("sha256=" + expected, signature)
Answer with any 2xx status. Network errors, 5xx, 408 and 429 are
retried in the background, waiting longer each time, for about five
hours; other statuses are given up at once. A webhook’s page lists its
latest deliveries with what came back, and Send a test sends a
ping event. Deliveries are kept for 30 days.
Webhooks don’t follow redirects, and don’t go to private or local
addresses unless webhooks.allow_private_addresses is on in the
configuration.
Themes
A theme sets the site’s colours, in both light and dark mode. Moekura comes with four:
| Theme | Colours |
|---|---|
| Default | neutral greys with a slate-blue accent |
| Forest | moss green |
| Ocean | deep blue-green |
| Sakura | pink, Moekura’s colours before 0.5 |
Choose the theme visitors see under Admin → Settings → Default theme. Logged-in users can pick any theme the site has, and light, dark or their device’s mode, under Settings or at the foot of any page. A user whose theme is removed gets the site’s default again.
Adding a theme
A theme is a stylesheet named themes/<name>.css in the
static_override directory. The name may
use lowercase letters, digits and dashes (high-contrast shows as
“High contrast”), but not system, light or dark. The file sets the
colour tokens of css/main.css, each as light-dark(<light>, <dark>);
tokens it leaves out keep the default theme’s colours:
/* themes/dusk.css */
:root {
--bg: light-dark(#faf7f2, #17140f);
--surface: light-dark(#ffffff, #201c16);
--text: light-dark(#231f19, #efe9df);
--muted: light-dark(#6b6358, #a9a093);
--border: light-dark(#e7e0d4, #37302a);
--accent: light-dark(#a1471a, #f0925c);
--accent-text: light-dark(#ffffff, #17140f);
--danger: light-dark(#c92a2a, #ff8787);
/* Tag categories. */
--tag-general: light-dark(#0068e0, #4fa3ff);
--tag-artist: light-dark(#c00004, #ff8a8b);
--tag-copyright: light-dark(#a800aa, #d98cff);
--tag-character: light-dark(#007e25, #35c64a);
--tag-meta: light-dark(#ad5c00, #ffb54a);
}
Keep text, links (--accent) and tag colours at a contrast of at least
4.5:1 against --bg and --surface, and --accent-text against
--accent, so everyone can read them. Restart moekura serve to pick
up new or changed themes. A file with a built-in theme’s name replaces
it.
Backups
A site is its PostgreSQL database plus its stored files. Back up both, the database first: a file with no database row is harmless, a row whose file is missing is not.
The database
pg_dump --format=custom --file=moekura-$(date +%F).dump moekura
# with compose:
docker compose -f deploy/compose.tiny.yml exec -T db pg_dump -U moekura --format=custom moekura > moekura-$(date +%F).dump
Restore into an empty database with pg_restore --dbname=moekura FILE.
Files
Files never change once written, so incremental copies are cheap:
rsync -a /var/lib/moekura/data/ backup:/srv/moekura-data/
For S3 storage, use the provider’s replication or versioning, or a tool
like rclone sync.
Secrets
The key that signs file links on private sites is stored in the database, so a database backup includes it.
Bulk import
moekura admin import adds a folder of images and videos as posts,
taking their tags from the files downloaders and tag managers write next to
them:
moekura admin import ~/downloads/art --uploader yourname --rating s
# with compose, mount the folder into the container first:
docker compose -f deploy/compose.tiny.yml run --rm -v ~/downloads/art:/import:ro \
app admin import /import --uploader yourname --rating s
| Option | Meaning |
|---|---|
--uploader NAME | the account the posts are uploaded by (required) |
--rating R | rating for files whose sidecar has none: g, s, q or e |
--tags "a b" | tags to add to every file |
-r, --recursive | also import files in subfolders |
--dry-run | show what would happen, without importing anything |
Every file goes through the same checks as an upload: supported types only, within the size and pixel limits. Files already on the site are skipped rather than refused, so if an import stops halfway, run it again. Each file is reported as it goes, then a summary; the command fails if any file couldn’t be imported. Thumbnails are made afterwards by the job workers, as for uploads.
Sidecar files
For pic.png, the importer reads pic.png.json or pic.json, and
pic.png.txt or pic.txt; when there are both a JSON and a text file,
their tags are combined.
Text files list tags one per line, as gallery-dl and Hydrus write them (spaces within a line become underscores), or on a single line separated by commas or spaces:
long hair
creator:some artist
series:some show
rating:safe
Namespaces are mapped to categories: creator: and artist: to artist,
series: and copyright: to copyright, character: and meta: as they
are. rating: sets the rating; safe counts as general. Other namespaces
stay part of the tag’s name.
JSON files are objects with any of:
| Field | Meaning |
|---|---|
tags | a list of tags, a string of them separated by spaces, or an object of lists by category ({"artist": ["someone"], "general": ["cat"]}) |
tag_string, tag_string_general, tag_string_artist, tag_string_copyright, tag_string_character, tag_string_meta | Danbooru’s fields, as its API and gallery-dl’s Danbooru metadata have them |
rating | g, s, q, e, their names, or safe |
source | where the file came from |
description |
Tags that aren’t valid here (with *, or starting with a search prefix
like user:) are left out with a note; the rest of the file is still
imported.
From other boorus
moekura admin import-remote copies posts from another booru through its
public API, with their tags, rating and source, to move a collection
here or keep a copy of a tag:
moekura admin import-remote https://danbooru.donmai.us "some_artist" --uploader boss
moekura admin import-remote https://yande.re "rating:s cat" --uploader boss --limit 500
| Site | --kind |
|---|---|
| Danbooru, and other Moekura sites | danbooru |
| e621, e926 | e621 |
| Gelbooru, Safebooru, Rule34 and other Gelbooru 0.2 sites | gelbooru |
| Moebooru: Konachan, yande.re | moebooru |
The kind is guessed for the sites named; give --kind for others. The
search is in the other site’s syntax. Posts are imported newest first:
- Tags keep their categories where the site has them (Danbooru and e621); from other sites, new tags are general. Tags that aren’t valid here are left out.
- The source is the post’s own source, or its page on the other site.
- Files already here (by MD5, or the same file) aren’t downloaded again; parents and children are linked as both arrive.
--notesand--poolsalso bring notes and pools from Danbooru-style sites. Pools are matched by name.
It waits a second between requests (--delay-ms); please don’t set it
much lower on other people’s sites. Some sites want an account for their
API or to show some files: give --login and --api-key (Gelbooru:
your user id and API key). Posts whose file the site hides are skipped.
Progress is saved as it goes. Running the same site and search again
carries on where it stopped (after --limit, a failure or Ctrl-C), and
once it’s finished there’s nothing to do; --restart starts from the
newest posts again, which also picks up posts added since. To import from
a site on your own network, add --allow-private-addresses.
The tagger
The tagger suggests tags and a rating for new uploads with a machine
learning model: one of SmilingWolf’s WD taggers, trained on Danbooru and
run on the CPU with ONNX Runtime. Suggestions appear under Edit on each
post (see Tags), and
ai:tag searches for posts where a tag is suggested but not applied. The
tagger can also apply what it’s surest of by itself.
It is optional, and a process of its own, moekura tagger: it takes work
from the job queue, so it can run on the same machine as the site or on a
bigger one elsewhere, as long as it reaches the database and the file
storage.
Setting it up
With Docker Compose, add the tagger to the tiny setup:
docker compose -f deploy/compose.tiny.yml -f deploy/compose.tagger.yml up -d
That runs the -tagger image (ghcr.io/moe-studios/moekura:<version>-tagger,
the app plus ONNX Runtime, for amd64 and arm64) as a second service, and
turns tagger.enabled on for both. On first start the tagger downloads its
model into the models volume, checks it against its SHA-256 checksum, and
starts on the queue.
Elsewhere, it takes three things:
- A build with the tagger: the release binaries and images have it;
from source,
cargo build --release -p moekura --features tagger. - ONNX Runtime 1.17 or newer: Microsoft’s
onnxruntime-linux-x64or-aarch64archives havelibonnxruntime.so. Pointtagger.runtime(orORT_DYLIB_PATH) at it, or put it on the library path. enabled = truein[tagger], for every process:serveandworkerqueue each processed upload for the tagger, andmoekura taggerdoes the work.
[tagger]
enabled = true
runtime = "/opt/onnxruntime/lib/libonnxruntime.so"
moekura tagger --check loads ONNX Runtime and says where from, without
touching the database. Posts uploaded before the tagger was set up are
queued with
moekura admin tag-backlog # posts it hasn't seen, oldest first
moekura admin tag-backlog --all # every post, e.g. after changing models
moekura admin tag-backlog --limit 1000
What it suggests
Admin → Settings has the tagger’s settings:
- Thresholds, per tag category: how sure the model must be, in
percent, to suggest a tag. The defaults, 35% for general tags and 85%
for characters, follow what the models’ authors recommend; categories
left blank use General’s. Suggestions below the thresholds at the time a
post is tagged aren’t kept, so lowering them affects posts tagged
afterwards (or requeued with
tag-backlog --all); raising them hides suggestions at once. - Applying suggestions itself, when the tagger is at least a given
percent sure (95% by default), and optionally the rating too. These
edits are made by the account
tagger.account(taggerby default), so they show in each post’s history like anyone’s and can be reverted. The account is created on first use without a password, so nobody can log in as it; if the name belongs to an account that has one, the tagger refuses to use it.
The model’s tag names are Danbooru’s. They’re matched to the site’s tags through aliases; tags the site doesn’t have yet are created, in the model’s category (general or character), when first suggested. Deprecated tags aren’t suggested.
Choosing a model
tagger.model picks one of the v3 WD taggers, which share a list of
about 10,800 tags:
| Model | Download | Notes |
|---|---|---|
wd-vit-tagger-v3 (default) | 380 MB | the fastest; fine on small machines |
wd-convnext-tagger-v3 | 400 MB | similar size and accuracy, somewhat slower |
wd-swinv2-tagger-v3 | 470 MB | a little more accurate, slower |
wd-eva02-large-tagger-v3 | 1.3 GB | the most accurate, several times slower and larger in memory; for a desktop-class machine |
Each is pinned to one revision with its checksum. Any other model shaped
like these (one input of square BGR images, [batch, size, size, 3], and
one score per line of a selected_tags.csv) works with model = "custom"
and model_url, model_sha256, tags_url and tags_sha256. After
changing models, moekura admin tag-backlog --all retags existing posts.
Hardware
The tagger needs no GPU. What it takes, with the default model:
-
Memory: about 600 MB once the model is loaded, and about 750 MB while tagging. Plan for 1 GB on top of the site. EVA02-Large, going by its size, takes over three times as much.
-
Disk: the model, 380 MB, in
tagger.model_dir. -
Time: measured on a desktop (AMD Ryzen 7 7800X3D), from taking the job to saving the suggestions:
Threads ( tagger.threads)Per post 16 ( 0, all of them)0.35 s 4 0.46 s 1 1.35 s About 0.15 s of that is preparing the image, the rest running the model.
On a Raspberry Pi 4 (4 GB or more; 2 GB is too tight next to PostgreSQL and the site), expect several seconds per post with the default model, very roughly 3 to 6: its four Cortex-A72 cores have a fraction of a desktop’s speed at this work. That’s an estimate, not a measurement; the tagger logs how long each post took, so check yours. It keeps up with a small site’s uploads easily, and a large backlog takes hours rather than minutes. Don’t run EVA02-Large on one.
tagger.threads is threads per post; the default uses every core, which
is right for a machine of its own. Next to the site on a small machine,
leave a core or two free for it. tagger.workers tags several posts at
once, each with the whole model; one is usually best.
Keeping an eye on it
Queued ml.tag_post jobs show on Admin → Overview. Only a tagger takes
them, so if they pile up, it isn’t running. A post the tagger hasn’t seen
says so under Edit. The tagger logs each post it tags, with how many
suggestions it saved and whether it applied any.
Commands
| Command | Does |
|---|---|
moekura serve | runs the web server, plus job workers unless jobs.run_in_serve = false; migrates first unless database.auto_migrate = false |
moekura worker | runs job workers only |
moekura tagger [--check] | runs the tagger (a build with the tagger feature); --check only loads ONNX Runtime |
moekura migrate | applies pending migrations and exits |
moekura check-config | validates the configuration and prints it, secrets redacted |
moekura openapi | prints the API’s OpenAPI description |
moekura admin create-user NAME [--role ROLE] [--email E] | creates an account; asks for the password, or reads one line from standard input |
moekura admin set-role NAME ROLE | changes someone’s role |
moekura admin create-invite [--uses N] [--expires-days D] | makes an invite code, shown once |
moekura admin settings | shows the site settings |
moekura admin settings set KEY VALUE | changes one; VALUE is JSON, or else a plain string |
moekura admin regenerate-media (--all | IDS…) | remakes thumbnails and samples, and rereads the files’ metadata |
moekura admin recount-tags | recomputes every tag’s post count |
moekura admin tag-backlog [--all] [--limit N] | queues posts the tagger hasn’t seen (or, with --all, every post) |
moekura admin send-test-mail ADDRESS | sends a test message through the [mail] settings |
moekura admin import DIR --uploader NAME … | imports a folder of files; see Bulk import |
moekura admin seed --posts N [--tags T] [--seed S] | fills a test database with synthetic posts for load testing; see Scaling |
moekura admin bench [--check] [--explain NAME] | times a suite of searches against the database |
moekura admin bench-http [--url URL] [--concurrency N] [--check-ms MS] | times pages and API responses from a running server; see Scaling |
Every command takes --config PATH. Role and setting changes made here
appear in the moderation log.
Scaling
A single moekura serve with PostgreSQL on the same machine is enough for
a private collection or a small community. As a site grows, the same
program scales out:
- run web servers behind a load balancer; they keep no state of their own;
- move background jobs to separate
moekura workerprocesses; - add PostgreSQL read replicas (
database.replicas), which take searches and listings; - store files in S3-compatible storage with a CDN in front;
- with several web servers, point them at a shared Valkey
(
cache.backend = "valkey"), so login and registration limits count across all of them; - with several web servers, set
database.auto_migrate = falseand runmoekura migratewhen deploying.
Read replicas
List PostgreSQL streaming replicas in database.replicas:
[database]
url = "postgres://moekura:…@primary/moekura"
replicas = ["postgres://moekura:…@replica-1/moekura", "postgres://moekura:…@replica-2/moekura"]
Searches, listings, tag pages, profiles and history then read from the
replicas in turn; everything else, and every change, uses the primary.
Every few seconds each server checks its replicas: one that doesn’t answer,
or is more than database.replica_max_lag_secs behind, is skipped until
it’s back (the log says when). With no usable replica, reads go to the
primary.
After someone changes something (an upload, an edit, a favorite), their
own reads go to the primary for replica_max_lag_secs, so they always see
what they just did. Other people may see it a moment later.
How fast is search?
Measured with 5,000,000 synthetic posts (250,000 tags, with 1.8 million
comments, 530,000 notes, 12,500 pools and 14 million tagger suggestions;
an 11 GB database) on a 16-core machine with 32 GB of memory, PostgreSQL 18
given shared_buffers = 4GB. Each search is what a results page runs:
looking up the tags, fetching the page, and counting. Times are the 95th
percentile of 20 runs, in milliseconds:
| Search | Example | ms |
|---|---|---|
| front page | 0.3 | |
| a tag on 70% of posts | red_red | 0.7 |
| two / three common tags | red_red blue_red | 8 / 15 |
| a common tag without another | blue_red -red_red | 19 |
| a rare tag (200 posts) | detailed_back_3 | 1.2 |
| a common and a rare tag | red_red detailed_back_3 | 8.7 |
| either of two tags | ~wet_red ~dry_red | 30 |
| wildcards | wet_*, *_red | 38, 6 |
| file type and a tag | filetype:mp4 blue_red | 53 |
| rating and score | rating:e score:>20 | 1.6 |
| a year, with a tag | date:2021, blue_red date:2021 | 2.4, 5.4 |
| page 500 of a common tag | 6.5 | |
| a cursor deep into a common tag | page=b2500000 | 0.6 |
| a pool, in its own order | pool:23, ordpool:23 | 1.8, 1.6 |
| posts in any pool | pool:any | 41 |
| a favorite group (300 posts) | favgroup:1235 | 2 |
| a word in notes, common / rare | note:red, note:new | 74 / 23 |
| recently commented / noted | order:comment, order:note | 12, 3.5 |
| comment count | commentcount:>3 | 28 |
| suggested by the tagger, alone / with a common tag | ai:long_red | 32 / 49 |
| a user’s 16 saved searches | search:all | 82 |
What keeps it fast:
- Tag searches pick their strategy from exact tag counts. Common tags walk the newest posts until a page is full; rare ones are collected through the tag index. PostgreSQL alone often guesses wrong for tag combinations.
- Counts stop early. Counts are exact up to
search.count_limit(10,000). Counts that would still read too much, per PostgreSQL’s estimate (search.count_cost_limit), are shown as estimates instead. - “Next” links use cursors, which cost the same however deep they go;
numbered pages stop at
search.max_page. - Saved searches run four at a time for
search:, each contributing its newest 500 posts.
Measuring your own
Seed a separate database, then benchmark it:
MOEKURA_DATABASE__URL=postgres://…/moekura_bench moekura admin seed --posts 5000000
MOEKURA_DATABASE__URL=postgres://…/moekura_bench moekura admin bench
Seeding generates everything in PostgreSQL, about 2,000 posts a second
(40 minutes for 5,000,000): posts with their comments and votes, notes,
pools, favorite groups, saved searches and tagger suggestions. admin bench --explain NAME prints the query plans of the matching
searches, and --check fails if a search that should be selective reads
more than half as many pages as the posts table has (CI runs that check on
200,000 posts).
How fast are pages?
Searches are only part of a page: it also loads the posts, their tags,
comments and notes, and renders. moekura admin bench-http times whole
responses from a running server, picking what to load from its database:
the busiest posts (with comments, notes and a pool), common and rare tags,
the biggest pools. Point it at a server using the seeded database:
export MOEKURA_SERVER__API_REQUESTS_PER_MINUTE=0 # no API rate limit
MOEKURA_DATABASE__URL=postgres://…/moekura_bench moekura serve &
MOEKURA_DATABASE__URL=postgres://…/moekura_bench moekura admin bench-http --url http://localhost:8080
Requests are anonymous, one at a time (--concurrency for more); a
target with several posts or tags loads them in turn. --check-ms 100
fails if a p95 is above 100 ms, and any response other than 200 OK fails
the run.
On the same 5,000,000 posts and machine, with the server (a release build) and the benchmark on it too, p95 in milliseconds, for one request at a time and for 16:
| Page | Path | 1 | 16 |
|---|---|---|---|
| front page | / | 3.3 | 8.5 |
| a common tag | /posts?tags=red_red | 4.2 | 10 |
| two common tags | /posts?tags=red_red+blue_red | 4 | 9.7 |
| rare tags | /posts?tags=detailed_back_3 | 4.9 | 12 |
| posts with 20–33 comments, notes and a pool | /posts/67561 | 3.6 | 9.6 |
| pools | /pools/23 | 2.7 | 6.5 |
| newest comments | /comments | 4.4 | 9.8 |
| tag list | /tags | 0.6 | 1.8 |
| API search, with a tag | /api/v1/posts?tags=red_red | 3.6 | 11 |
| API post | /api/v1/posts/67561 | 1.1 | 4.2 |
| Danbooru search, with a tag | /posts.json?tags=red_red | 3.4 | 9.5 |
| Danbooru post | /posts/67561.json | 2.5 | 9.3 |
| autocomplete (site, API, Danbooru) | /tags/autocomplete?q=re | 1.9 | 3.6 |
| feed, with a tag | /posts.atom?tags=red_red | 3.8 | 10 |
Common tags come out faster than in the search table because counts
that reach the count limit are reused for cache.count_ttl_secs (30
seconds); one visitor in that time pays for the count.
Tuning PostgreSQL
The defaults of a stock PostgreSQL are sized for a small machine. For a large site, start from:
shared_buffers = 25% of memory
effective_cache_size = 50–75% of memory
work_mem = 32MB
maintenance_work_mem = 1GB
random_page_cost = 1.1 # on SSDs
Your account
Settings
Settings, at the top of every page, holds how the site looks to you:
- Posts per page, the theme and light or dark mode.
- Blacklist: posts matching a line are left out of grids, with a count and a link to show them. Tick Blur blacklisted posts to keep them in grids, blurred, instead.
- Safe mode shows only general-rated posts, everywhere: searches, post pages, pools and the API.
- Post pages show large images resized, saying so above them with a link to the original. Show original images always shows the original.
- Include deleted posts in searches, for those allowed to see them.
- Large thumbnails use the site’s second thumbnail size in grids.
- Square thumbnails crop grids’ thumbnails to squares of each picture’s most interesting part (see Square thumbnails).
- Hide comments leaves comments off post pages, with a link to read them.
- Suggest tags while typing and Keyboard shortcuts can be turned off.
- Time zone is used for the dates pages show. Otherwise they’re in UTC.
- Custom CSS is applied after the site’s styles, for you alone.
The rest of this page is under Settings → Your email address and password.
Email address and password
Changing either needs your current password. Changing the password logs you out everywhere else.
On sites that send mail, a new address only replaces the old one once you follow the link sent to it, and Forgot your password? on the login page emails you a link to choose a new one. The link works for an hour, and using it logs you out everywhere.
Single sign-on
On sites set up for it, Log in with … on the login page logs you in through another service’s account. The first time, that makes you an account here (if the site is taking new ones).
To use it with an account you already have, log in with your password and choose Link under Single sign-on. You can unlink it later, as long as you have a password to log in with instead. Accounts made through single sign-on have no password; to set one, use Forgot your password? if the site sends mail.
Two-factor login
With two-factor login on, logging in needs a code from an authenticator app (such as Aegis, 2FAS, Google Authenticator or 1Password) as well as your password, so a leaked password isn’t enough to get in.
- Open Two-factor login settings and choose Set it up.
- Scan the QR code with your app, or type in the key shown under it.
- Enter the code the app shows, to check it has the key.
- Save the ten recovery codes somewhere safe: each logs you in once if you lose your device. You can make new ones at any time, which replaces the old ones.
When logging in, enter a code from the app, or a recovery code, after your password (or after single sign-on). If your device’s clock is off by more than about half a minute, codes won’t work; most phones set the time automatically.
If you’ve lost both your device and your recovery codes, ask the staff: people who can manage users can turn two-factor login off for you (Admin → Users → Turn off 2FA), which is recorded in the moderation log.
API keys don’t need a code: keep them secret, and revoke any you no longer use.
Saved searches
Save a search from its results (Save this search, beside the
results), or under Settings → Saved searches, optionally with labels.
Then search:all shows the newest posts of all your saved searches
together, and search:artists those of the searches labelled artists;
both combine with other tags and filters, like search:all rating:g.
Each saved search adds up to its newest 500 posts, and a search: term
runs at most 20 saved searches. Only you see your saved searches.
Favorite groups
Favorite groups are your own named lists of posts, in the order you
choose: make one under Your favorite groups (linked from your
profile) or from a post page, add posts from their pages, and reorder
them by dragging on the group’s edit page. A group is public (listed on
your profile, and anyone can open it) unless you untick Public.
favgroup:name searches one of your groups, favgroup:7 any public
group by number, and ordfavgroup:name shows a group in its own order.
Posts and files
Uploading from a link
…or a link to it on the upload form downloads a file from the web. Give it the file itself, or a work’s page on a site Moekura can read:
| Site | Pages |
|---|---|
| Pixiv | pixiv.net/artworks/<id>, and its files on i.pximg.net |
| X (Twitter) | x.com/<user>/status/<id>, and …/photo/<n> for one picture |
| Bluesky | bsky.app/profile/<user>/post/<id> |
| DeviantArt | deviantart.com/<user>/art/<work> |
| pixivFANBOX | <creator>.fanbox.cc/posts/<id> (public posts) |
| Skeb | skeb.jp/@<creator>/works/<n> |
Moekura asks the site for the work’s best (original) file and downloads that, and the page, not the file, becomes the post’s source. Any other page whose preview tags (OpenGraph) name an image works the same way. What the site says is used as well:
- The artist: beside the tags box, the tag of the artist whose artist entry lists their profile there; an artist without one gets a link to start it, filled in with their name and profiles.
- Tags: the Related tags panel’s From Pixiv (or the site’s name) group lists the site’s tags as this site’s: tags whose wiki pages list one as an other name, and tags named like one or its English translation.
- Commentary: the work’s title and description become the post’s artist’s commentary, unless you wrote one in the form.
The same happens with a file and a Source that is such a page, and for uploads through the APIs. When a site can’t be reached or has changed, the link is downloaded as it is, without extras.
Ugoira
Pixiv’s animations (ugoira) are a zip of JPEG or PNG frames. Upload the
zip like any file, or link to the work on Pixiv, which downloads the zip
and keeps each frame’s time in it. A zip you upload yourself can say how
long each frame shows in an animation.json beside the frames, in
Pixiv’s form:
{"frames": [{"file": "000000.jpg", "delay": 100}, {"file": "000001.jpg", "delay": 80}]}
Without one, every frame shows for a tenth of a second. Once it’s
processed, the post plays the animation as a video made from its frames
(a WebM), and Download the frames gets the zip. filetype:ugoira
(or filetype:zip) finds them, and the Danbooru API gives the video as
the post’s large file.
Replacing a post’s file
Staff with Replace posts’ files (moderators and admins, by default) can swap a post’s file for a better one: a higher resolution, an uncropped version, a fixed scan. Replace the file, below the picture, takes a file or a link, a reason, and whether to move and resize the notes to the new size (on by default). The post keeps its id, tags, comments, notes, pools and favourites; its thumbnails and metadata are made again from the new file, and the file can’t be one another post already has.
Replacements, under the picture, lists a post’s replacements: when,
by whom and why, and the file before (still downloadable, until the post
is purged) and after. Replacements are recorded in the moderation log,
and Danbooru clients can read them at /post_replacements.json.
Square thumbnails
Square thumbnails, under Settings, makes grids show every post as
a square: the part of the picture libvips finds most interesting, which
is usually the face or the subject rather than the middle. Staff who
review posts can choose the square themselves under Square thumbnail
on a post’s page: give its left and top edges and its side in the
picture’s pixels, or with scripts, click the picture where it should be
centred. Back to automatic undoes the choice. Squares are made when a
file is processed; for older posts, moekura admin regenerate-media --all makes them, and until then grids show their usual thumbnails.
File metadata
When a file is processed, its metadata is read and kept: the image’s
make-up (colour components, bit depth, colour space, frames), EXIF (the
camera, the software, comments), XMP (title, creator tool, keywords), PNG
text chunks, and for videos the container and each stream (codec, frame
rate, bit rate, audio channels). Metadata, under the picture on a
post’s page, lists it by group, with names like exiftool’s: EXIF:Make,
PNG:Software, File:ColorComponents.
Where a photo was taken and whose camera took it stay private: GPS and other location fields, serial numbers and owners’ names are never read into it. The original file itself is kept as it was uploaded, so Download original still has whatever it contained.
Posts uploaded before metadata was read get it when their files are
processed again: moekura admin regenerate-media --all.
Search syntax
Type tags and filters into the search box, separated by spaces. Tags are
case-insensitive, and spaces inside a tag are written as underscores
(long_hair).
Tags
| You type | Finds posts that… |
|---|---|
cat | have the tag cat |
cat cute | have both tags |
cat -dog | have cat but not dog |
~cat ~dog | have cat, dog or both |
long_* | have any tag starting with long_ |
*_hair | have any tag ending in _hair |
-*_hair | have no tag ending in _hair |
The ~ terms form one group: a ~b ~c means a, plus at least one of b
and c. A wildcard stands for the (up to 100) most used tags it matches.
Searches follow tag aliases: if kitty is aliased to cat, searching for
kitty finds posts tagged cat. Category prefixes are ignored, so
artist:someone searches for someone.
Groups and or
Parentheses group terms, and or between two terms or groups means
either of them. Filters work inside groups too.
| You type | Finds posts that… |
|---|---|
(cat or dog) -rating:e | have cat or dog, and aren’t explicit |
(cat cute) or (dog rating:g) | have cat and cute, or are general and have dog |
-(cat dog) | don’t have both cat and dog |
cat (user:alice or score:>10) | have cat, and were uploaded by alice or score above 10 |
Terms side by side bind tighter than or, so a b or c means (a b) or c. ~ is shorthand for or: ~a ~b is (a or b), and inside a group
the ~ terms form an or of that group. Groups can be nested up to 10
deep, and every tag and filter in them counts towards the site’s limit on
terms.
A ( at the start of a word opens a group, and a ) at the end of a word
closes one, unless it belongs to the tag: (ganyu_(genshin_impact) or klee_(genshin_impact)) works as expected. To search for a tag that starts
with (, put a category in front of it (general:(tag)). order:,
limit:, ordfav: and the other orders apply to the whole search, so they
can’t go inside a group or next to or.
Filters
Filters look like name:value. Put - in front of one to exclude what it
matches (-rating:e); order: and limit: can’t be excluded.
| Filter | Example | Meaning |
|---|---|---|
rating: | rating:e,q | rating general, sensitive, questionable or explicit (letters or names, comma-separated) |
score: | score:>=10 | score |
favcount: | favcount:>5 | number of favourites |
commentcount: | commentcount:>0 | number of comments |
notecount: | notecount:>0 | number of notes |
note: | note:good_morning | notes contain these words (underscores for spaces) |
id: | id:1000..2000 | post number |
user: | user:alice | uploaded by this user |
fav: | fav:alice | favorited by this user |
approver: | approver:alice, approver:any, approver:none | approved by this user, by anyone, or by no one (posts that never waited for approval) |
commenter: | commenter:alice | has a comment by this user |
comment: | comment:nice_art | comments contain these words (underscores for spaces) |
commentary: | commentary:true, commentary:untranslated, commentary:new_work | has artist’s commentary (true), none (false), a translation (translated), an original without one (untranslated), or commentary containing these words |
exif: | exif:file:colorcomponents=1, exif:exif:model=canon_eos, exif:png:parameters | the file’s metadata has this field (group:tag), with this value if =value is given; regardless of case, underscores for spaces. Every field on a post’s Metadata page links to its search |
pixiv:, pixiv_id: | pixiv:123456, pixiv:any, pixiv:none, pixiv_id:>1000 | the source is this Pixiv work (a number or a range like other numbers), any Pixiv work, or none; works’ pages and their files on i.pximg.net both count |
embedded: | embedded:true | the post’s notes are drawn on the picture (see Notes), or not |
noter: | noter:alice | has a note this user wrote or edited |
upvote:, downvote: | upvote:alice | voted up / down by this user; votes are private, so only staff who review posts may search for others’ votes, everyone else only for their own |
flagger: | flagger:alice | flagged by this user; only staff who review posts may search for others’ flags, everyone else only for their own |
search: | search:all, search:artists | the newest posts (500 each) of your saved searches, all or those with a label |
favgroup: | favgroup:best, favgroup:7, favgroup:any, favgroup:none | in one of your favorite groups (by name), or any public group (by number); in any or none of your groups |
pool: | pool:my_comic, pool:12, pool:any, pool:none | in this pool (by name or number), in any pool, or in none |
width:, height: | width:>=1920 | size in pixels |
mpixels: | mpixels:>2 | megapixels (width × height ÷ 1,000,000) |
ratio: | ratio:16:9, ratio:<1 | width ÷ height (16:9 or a number; exact values match within 0.01) |
filesize: | filesize:>2mb | file size, in bytes or with kb, mb, gb (an exact size with a unit matches within 5%) |
duration: | duration:>30 | length of a video, in seconds |
filetype: | filetype:png,webm | file type: jpg, png, gif, webp, avif, jxl, mp4, webm, ugoira (or zip) |
date: | date:2026-01 | upload date (UTC): a day, month or year |
source: | source:https://twitter.com/foo, source:*pixiv.net*, source:none, source:any | the source starts with this, or matches a pattern with *, regardless of case; or posts without / with a source |
age: | age:<1w, age:2d..1mo | uploaded this long ago: <1w is less than a week ago; units s, mi, h, d, w, mo (30 days; m works too) and y |
updated: | updated:<1d, updated:2026-01 | last changed (tags, rating, source, status, …) this long ago, or on these days |
md5: | md5:d41d8cd9… | the file’s MD5 hash |
similar: | similar:123 | looks like post 123 (the post included); found once files are processed |
parent: | parent:123, parent:none, parent:any | a post and its children, posts without a parent, or posts with one |
child: | child:any, child:none | posts with children (that aren’t deleted), or without |
tagcount: | tagcount:<5 | number of tags |
<category>tags: | arttags:0, gentags:>20 | number of tags in a category: the category’s name followed by tags (artisttags:, charactertags:), or Danbooru’s gentags:, arttags:, copytags:, chartags: and metatags:; arttags:0 finds posts missing an artist |
ai: | ai:long_hair | the tagger suggests this tag, and the post doesn’t have it yet |
status: | status:deleted | pending, active, flagged, deleted, modqueue, unmoderated, appealed or any (see below) |
is: and has: are shorthands for other filters, as on Danbooru:
| Shorthand | Same as |
|---|---|
is:parent, has:children | child:any |
is:child, has:parent | parent:any |
is:sfw, is:nsfw | rating:g,s, rating:q,e |
is:general, is:explicit, … | rating:g, rating:e, … |
is:pending, is:deleted, … | status:pending, status:deleted, … |
has:source | source:any |
has:pools | pool:any |
has:notes, has:comments | notecount:>0, commentcount:>0 |
Numbers (and sizes and dates) can be compared:
| Form | Meaning |
|---|---|
5 | exactly 5 |
>5, >=5, <5, <=5 | more / at least / less / at most |
5..10 | from 5 to 10, both included |
5.., ..10 | at least 5 / at most 10 |
1,2,3 | any of these |
Dates take the same forms: date:2026-01-31, date:>=2026-01,
date:2025..2026 (all of 2025 and 2026).
A site can limit the ratings logged-out visitors see (Admin → Settings → Ratings visitors see). Their searches, post pages, feeds and API results then leave out other ratings, whatever the search asks for.
Statuses
Searches show active and flagged posts, plus your own uploads that are
waiting for approval. Staff who review uploads also see pending posts.
Deleted posts only appear with status:deleted or status:any (or with a
status: inside a group, such as (status:deleted or rating:e)), and only
to those allowed to see them. They see how many deleted posts a search
left out, with a link to include them, or can include them in every
search with Include deleted posts in searches in their settings. For staff who review uploads,
status:unmoderated finds the pending posts left for them: ones they
didn’t upload and haven’t disapproved; status:appealed finds deleted
posts with an open appeal. status:modqueue finds everything waiting for
a moderator: pending and flagged posts.
When nothing is found
If a search finds nothing, tags in it that match no posts get
suggestions: the tag a retired alias pointed to, or used tags spelled
almost the same (long_hiar → long_hair). Each links to the same
search with the tag swapped. Excluded tags, wildcards and filters get
none.
Order and page size
| Filter | Order |
|---|---|
order:id (default), order:id_asc | newest / oldest first (order:created_at works too) |
order:score, order:score_asc | highest / lowest score |
order:favcount, order:favcount_asc | most / fewest favourites |
order:mpixels, order:mpixels_asc | largest / smallest image |
order:filesize, order:filesize_asc | largest / smallest file |
order:landscape, order:portrait | widest / tallest first |
order:duration, order:duration_asc | longest / shortest video |
order:tagcount, order:tagcount_asc | most / fewest tags |
order:arttags, order:gentags_asc, … | most / fewest tags in a category |
order:comment, order:comment_asc | most / least recently commented (only posts with comments) |
order:note, order:note_asc | most / least recently noted (only posts with notes) |
order:change, order:change_asc | most / least recently changed, e.g. to follow recent tag edits (order:updated works too) |
order:rank | hot posts: from the last two days with a positive score, highest score first, discounted by age (the Hot link) |
order:upvotes, order:downvotes (and _asc) | most / fewest up or down votes |
order:comment_bumped, order:comment_bumped_asc | like order:comment, leaving out comments posted with Don’t bump the post |
order:comment_count, order:note_count (and _asc) | most / fewest comments or notes |
order:custom | in the order of the search’s id: list: id:3,1,2 order:custom |
order:md5, order:md5_asc | by the file’s MD5, for a stable order that isn’t upload order |
order:random | shuffled |
ordfav:alice | alice’s favorites, most recently favorited first |
ordpool:my_comic | the pool’s posts, in the pool’s order |
ordfavgroup:best | the favorite group’s posts, in its order |
limit:100 shows more posts per page (up to the site’s maximum, 200 by
default).
Pages
Results have numbered pages up to page 1000 (configurable). Sorted by id,
“next” links keep working beyond that; they use page=b<id> (posts before
that id) and page=a<id> (after), which are as fast on page 50,000 as on
page 2.
Counts are exact up to 10,000 posts. Above that, a single tag shows its known post count, and other searches show “10,000+”.
Searching by image
Search by image (/iqdb_queries, linked from Popular and as
Look-alikes under every post) takes a picture, a link to one (a
work’s page on a site Moekura reads works too), or a post, and lists the
posts that look most like it, with how alike they are, without uploading
anything. Matches are found by the same perceptual hash as similar:,
so a resized or recompressed copy is found, but a crop or an edit may
not be. Each search compares the picture with every post, so they’re
limited to a few a minute. The API has it as POST /api/v1/posts/similar,
and Danbooru clients as /iqdb_queries.json.
Popular posts and searches
Popular in the menu shows what’s going on, for a day, a week (the seven days ending on the date) or a month, with links to earlier and later ones:
- Popular: the best-scored posts posted then.
- Most viewed: the posts whose pages were looked at most.
- Searches: the tag searches made most.
- Missed searches: tag searches that found nothing, most often because of a misspelling or a name the site calls something else; an alias can send them to the right tag.
Each person counts once a day per post or search; crawlers and link
previews don’t count. Only searches of plain tags are counted (and only
their first page): searches with metatags such as fav: or user:,
which may name people, are left out. Counts are kept for about a year.
Feeds
Every search has an Atom feed of its newest posts: the Feed link
beside the results, or /posts.atom?tags=cat+-dog. Add it to a feed
reader to follow new posts of a tag, an artist (user:alice for a
user’s uploads), or anything else you can search for. /comments.atom
follows the newest comments.
On a private site, feed readers can’t log in; make a feed token under
Settings → Feeds and add &token=… to the feed’s address. The token
reads feeds as you (with your blacklist) and does nothing else; making a
new one or revoking it stops the old one working.
Tags
Tags describe what’s in a post. They’re lower case, with underscores
instead of spaces (long_hair), and belong to a category:
| Category | For |
|---|---|
| general | what’s in the picture |
| artist | who made it |
| copyright | the series or franchise |
| character | who’s in it |
| meta | things about the file, such as animated or translated |
When you tag a post, write a new tag with a prefix to put it in a category:
artist:someone. A tag already on posts keeps its category, unless you
can manage tags; then the prefix moves it, as does editing it in the tag
list.
Each tag can have a wiki page describing it.
Metatags in the tag box
The tags box on the edit and upload forms takes more than tags, as on
Danbooru. -tag takes a tag off, and these metatags change other
things:
| Metatag | Does |
|---|---|
rating:g, s, q, e (or the full name) | sets the rating |
source:https://…, source:none | sets or clears the source |
parent:123, parent:none or -parent | sets or clears the parent; -parent:123 clears it only if it’s 123 |
child:123, -child:123 | makes post 123 a child of this one, or stops it being one |
pool:12, pool:name, -pool:12 | adds the post to the end of a pool, or takes it out |
newpool:name | starts a pool with the post (or adds it to the pool of that name) |
fav, -fav | favorites the post, or stops favoriting it |
favgroup:12, favgroup:name, -favgroup:12 | adds the post to one of your favorite groups, or takes it out |
upvote, downvote | votes on the post |
Each needs the permission it would need done by hand (editing pools to
use pool:, favoriting to use fav, …), and is recorded where that
would be: parents in the posts’ history, pools in the pool’s. A metatag
you can’t use, or one naming something that doesn’t exist, stops the
save with the reason. Metatags in the box win over the form’s own
rating, source and parent fields. They also work in tag scripts and the
APIs’ tag fields; in a mass edit, only -tag and rating: do.
Warnings after saving
After an upload or an edit, the post page lists what may be missing,
without stopping the save: no artist, copyright or character tag, fewer
than 10 general tags, tags no other post has yet (often a typo), and a
category prefix that couldn’t move an existing tag (artist:cat when
cat is already a general tag; only those who manage tags can move
used tags).
Sites can also have request tags added by themselves (Admin →
Settings, off by default): artist_request while a post has no artist
tag and tagme while it has fewer than 10 general tags. They come off
again when an edit fixes that.
Related tags
Beside the tags box of the upload and edit forms, a panel lists tags to
consider, updated as you type: tags often used with those in the box
(or with the tag under the cursor), your recent and most frequent tags,
the site’s tags for words in the box that are a wiki page’s
other names (paste 長い髪 and it offers
long_hair), the links on the wiki page of the tag under the cursor,
and, when the link or source is a work on a site Moekura can read, the
artist and the site’s tags in this site’s terms (see
Uploading from a link). Click a tag to add it, or to take it out if it’s already in the
box. Without scripts, Related tags opens the same lists on a page of
their own (/tags/related).
Copying tags from related posts
A post’s Edit form lists its parent and children under Copy tags. Click one to add that post’s tags to the tags box, then change what doesn’t fit and save (without scripts, the click adds them and saves at once).
Suggestions from the tagger
On sites that run the tagger, a model looks at each new upload, usually within a minute, and suggests tags and a rating. They show under Edit on the post, most confident first, with how sure the model is: click one to add it to the tags box (or, without scripts, to add it and save), then save. Tags the post already has aren’t suggested, nor tags below the confidence the site asks for in their category.
The model is often right about what’s in a picture and sometimes
confidently wrong, so check before saving. Search for ai:tag to find
posts where a tag is suggested but not yet applied, for tidying up many
posts at once.
Sites can also have the tagger apply the suggestions it is surest of by
itself. Those edits appear in the post’s history as the tagger’s account
(normally tagger), and are undone like anyone’s.
Aliases and implications
An alias makes one tag stand for another: with kitty aliased to
cat, posts tagged kitty are tagged cat instead, and searching for
kitty finds them.
An implication adds a tag: with cat implying animal, every post
tagged cat is also tagged animal.
Anyone who can edit posts can request them under Tags → Aliases and Implications; people who can manage tags approve them. Once approved, they’re applied to existing posts in the background, and to every edit after. Each post’s history shows which changes came from an alias or implication.
Voting and discussion
Each request has its own page (click its status or score in the list): members vote for or against it while it’s pending, and discuss it underneath. Votes help staff decide; they don’t decide by themselves.
Bulk update requests
Tags → Requests holds requests for several changes at once, decided together. A request has a title, a reason, and a script with one change a line:
| Line | Does |
|---|---|
alias kitty -> cat | aliases kitty to cat |
imply cat -> animal | makes cat imply animal |
unalias kitty -> cat, unimply cat -> animal | ends an alias or implication |
update cat_ears solo -> animal_ears -cat_ears | a mass edit: the posts a search finds get the tags after the arrow, and lose those with - |
category someone -> artist | moves a tag to a category |
Lines starting with # are ignored. Mistakes are pointed out, by line,
when you send the request. Members vote and discuss as for single
requests; staff who manage tags approve (which applies the lines in
order, in the background) or reject it, and you can withdraw your own
while it’s pending. If a line can’t be applied (say, it would make an
implication loop), the request stops there, marked failed with the
reason; the lines before it stay applied.
History
Tags → History lists every change to tags: when each was created and who changed its category or deprecation, newest first, with the old and new values. Filter it by tag or by user; a tag’s edit page links to its own history.
Deprecated tags
A deprecated tag can’t be added to posts any more, but stays on the posts that already have it until someone takes it off.
Tag scripts
To tag many posts quickly, open Tag script beside search results
(for those who can edit posts), type a script and tick Apply by
clicking posts. Clicking a post then applies the script to it instead
of opening it: tag adds a tag, -tag removes one, and rating:s sets
the rating, so cat_ears -cat rating:g does all three; the other
metatags (pool:12, fav, …) work too. Changed posts
are outlined green, refused ones red with the reason below the script.
Each change is in the post’s history as if you had edited it.
The wiki
Every tag can have a wiki page explaining what it’s for and how to use it. Find them under Tags → Wiki, or with the ? next to a tag in search results and on post pages. Searching for a single tag shows the first paragraph of its page above the results.
A page’s title is the tag’s name, so Long Hair and long_hair are the
same page, and a page can exist before any post has the tag. The page of
an aliased tag points to the tag it’s aliased to.
Anyone with the Edit the wiki and artists permission (members, by default) can
start or change a page. Every change is kept: History lists them,
and you can view any old version or revert to it. If someone else saves
the page while you’re editing it, you’re told instead of overwriting
their change, and your text is kept so you can save again.
Recent changes on the wiki’s index (/wiki_page_versions) lists
the changes to every page, newest first, and can be filtered by user.
Other names
A page can list the tag’s other names: what it’s called elsewhere,
such as its Japanese name, a romanisation or a pixiv tag. Write them
separated by spaces, with underscores for spaces within a name
(長い髪 long_locks). They’re shown under the title and kept in the
page’s history, and the wiki’s search finds pages by them as well as by
title, so a tag can be found by what other sites call it.
Formatting
| Write | For |
|---|---|
| A blank line | A new paragraph |
h2. Heading (h1. to h6.) | A heading, at the start of a line |
* item, ** nested item | A list |
[b]bold[/b], [i]italic[/i], [s]struck[/s], [u]underlined[/u] | Styles |
[[long_hair]], [[long_hair|long hair]] | A link to a wiki page, with its own text after the | |
{{cat -dog}} | A link to search results |
post #123, comment #45 | A link to a post or a comment |
[quote] and [/quote] on lines of their own | A quote |
https://example.com | A link to another site |
Everything else is shown as written; HTML isn’t allowed. The syntax is a subset of Danbooru’s DText, so pages copied from there mostly work.
Artists
An artist tag can have an artist entry: the artist’s other names,
their group or circle, and the places they post their work. Find them
under Tags → Artists (/artists), searchable by any of their names,
their group, or one of their URLs.
Anyone with the Edit the wiki and artists permission (members, by
default) can start or change an entry: New artist on the list, or
Start an artist entry on an artist tag’s wiki page. An entry’s name
is its tag; a tag that doesn’t exist yet, or that no post uses yet,
becomes an artist tag. Every change is kept under History, and
Recent changes (/artist_versions) lists every entry’s changes,
filterable by user. Entries can be deleted and restored.
The entry’s page shows the URLs, the first paragraph of the tag’s wiki page (the longer description lives there), and the artist’s newest posts.
URLs
List the artist’s profiles and galleries, one per line. Put - in front
of ones no longer in use (a deleted account, a site that closed): they’re
shown struck out, but still identify the artist.
Find an artist by URL (/artists/finder) takes any address, a
profile or a page of one of the artist’s works, and lists the artists
whose URLs it falls under: https://x.com/someone/status/123 finds the
artist with https://twitter.com/someone. Addresses are compared without
www., the scheme, or anything after ?, and x.com counts as
twitter.com.
The upload form does the same with the link you upload from and the source: when they belong to a known artist, their tag is offered beside the tags box. For works on Pixiv, X, Bluesky, DeviantArt, Fanbox and Skeb, the site is asked who made it, so a work’s page finds the artist whose entry lists their profile, even when the page’s address doesn’t contain it (Pixiv’s don’t); an artist without an entry gets a link to start one, with their name and profiles filled in.
Banned artists
Staff who can manage tags can ban an artist from their entry (for example at the artist’s request). What that does is a site setting, under Admin → Settings → Banned artists:
- Hide their posts (on by default): posts with the artist’s tag are left out of searches, and their pages aren’t found.
- Refuse uploads (on by default): uploads with the tag, and edits adding it, are refused.
Staff who approve posts still see the posts and can post them. Bans are recorded in the moderation log.
Commentary
A post can have the artist’s commentary: the title and description
the artist gave the work where they posted it, and a translation. It’s
shown under the picture, translated when there’s a translation (with the
original a click away), and anyone who can edit posts can add or change
it from the same place. Descriptions use the wiki’s markup. Every change
is kept (Commentary history; /artist_commentary_versions lists
them sitewide), and commentary: searches find posts with
commentary, with or without a translation, or by its words:
commentary:untranslated lists commentary waiting for a translator.
Comments
Every post has comments under the image. Anyone with the Comment
permission (members, by default) can write one; they use the same
formatting as the wiki, including [quote] blocks
and comment #45 links. Reply starts a new comment quoting the one
you’re answering. The newest 50 comments are shown on the post; the rest
are a link away.
You can edit and delete your own comments; edited ones say so. Deleting
a post’s last comment takes it out of order:comment. Deleted posts
can’t be commented on, and comments are rate limited: a few at once,
then one every 20 seconds.
Tick Don’t bump the post to comment without moving the post up in
order:comment_bumped; it still counts for order:comment. Moderators
can Pin to top a comment, which then comes first among the post’s
comments.
Comments in the menu lists the newest comments on the whole site, beside their posts; each profile links to that user’s comments.
Votes and reports
Vote comments up or down with the arrows (not your own). Comments voted down to −5 or lower are folded away, a click from being read.
Report asks the moderators to look at a comment; they can hide it, restore it later, or dismiss the report. See Moderation.
Searching
| Filter | Finds |
|---|---|
commentcount:>0 | posts with comments |
order:comment | posts with comments, most recently commented first |
Pools
A pool is an ordered collection of posts: a series (a comic, a set of pages meant to be read in order) or a collection (posts that belong together). Find them under Pools in the menu.
Making and changing pools
Anyone with Create and edit pools (members, by default) can start a pool with New pool and change any pool’s name, kind, description and posts. The posts are a list of post numbers in order; on the edit page you can also drag the thumbnails into order. To add one post, open it and use Add to a pool beside it, which puts it at the end.
Every change is kept: History shows who added, removed or reordered
posts, and any version can be restored. If someone else saves the pool
while you’re editing it, you’re told instead of overwriting their
change. Staff who can delete posts can also delete and restore pools.
Recent changes on the pool list (/pool_versions) shows the changes
to every pool, newest first, and can be filtered by user.
Reading
A post in a pool shows a bar above the image with the pool’s name, the
post’s place in it, and links to the first, previous, next and last
post. Posts opened from a pool’s page step through the pool with the
a and d keys (or the arrow keys).
Series have a reader: Read on the pool’s page shows the posts one
after another, 20 at a time, and One page at a time shows a single
page, with a and d to turn it (clicking the image turns it too).
Your browser remembers the last page you read, and the pool’s page
offers to continue from there.
Searching
| Filter | Finds |
|---|---|
pool:my_comic, pool:12 | posts in the pool (by name, regardless of case, or number) |
pool:any, pool:none | posts in some pool, or in none |
ordpool:my_comic | the pool’s posts, in the pool’s order |
Pools also appear in the API and to Danbooru apps.
Notes
Notes are boxes on a post’s image with text in them, usually
translations of what’s written in the picture. A post with notes shows
them as boxes over the image; point at a box, tap it or tab to it to
read its note. Hide notes under the image (or the n key) hides
them, and your browser remembers that. Every note is also listed below
the image under Notes, which is what screen readers read.
Notes are drawn on the original image, so they stay in place at any size the image is shown. Videos don’t have notes.
Adding and changing notes
Anyone with Edit notes (members, by default) gets Edit notes under the image. While it’s on:
- drag across the image to draw a new note, then type its text and Save;
- drag a note to move it, or drag its bottom-right corner to resize it;
- click a note to change its text, or Delete it.
Each change is saved as you make it. If someone else changed the same note since you loaded the page, yours is refused rather than overwriting theirs; reload to see their change. Notes use the same formatting as the wiki. Drawing notes needs scripts; without them you can still read notes and revert them.
Embedded notes
Translations of speech bubbles and signs read best in place. Draw the
notes’ text on the picture, under the list of notes, shows each note’s
text inside its box all the time, rather than when pointed at
(Danbooru’s embedded notes); the same button switches back. It’s for
the whole post, needs Edit notes, and embedded:true finds such
posts.
History
Note history (beside the post’s history) lists every version of
every note on the post: its box, its text, who changed it and when.
Revert a note to an earlier version to undo a change, or to bring back
a deleted note. All note changes (/note_versions) lists the note
changes on every post you can see, newest first, and can be filtered by
user.
Searching
| Filter | Finds |
|---|---|
note:good_morning | posts whose notes contain these words (underscores for spaces) |
notecount:>0 | posts with notes |
order:note | posts with notes, most recently changed first |
The API
Everything the site does is available as JSON under /api/v1. Each site
documents its own version of the API at /api/docs, generated from the
code, and serves the OpenAPI description at /api/v1/openapi.json for
generating clients (moekura openapi prints it too).
Apps made for Danbooru can use the site too; see Danbooru apps.
Authenticating
Create a key under Settings → API keys and send it with each request:
curl -H "Authorization: Bearer mka_…" https://booru.example.com/api/v1/me
A key acts as you: it can do what your role allows, and while you’re
banned only what visitors can. It’s shown once, when created; revoke it
from the same page if it leaks. Keys start with mka_ so that secret
scanners can spot them. Without a key, requests are made as a visitor.
Errors
Errors are JSON with the HTTP status:
{"error": {"status": 422, "message": "Choose a rating."}}
A duplicate upload is a 409 whose error also has post_id, the post that
already has the file.
Rate limits
Each client may make a few hundred requests a minute (300 by default,
with bursts of up to 60; the site’s admins set
api_requests_per_minute and api_burst).
Requests with a key count against its account, requests without one
against the address they come from. The Danbooru-compatible API shares
the same allowance. Every response says what’s left:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | requests that may be made at once (the burst) |
X-RateLimit-Remaining | requests left right now |
X-RateLimit-Reset | when the full burst is available again, in Unix time |
Past the limit, requests get a 429 with Retry-After, the seconds to
wait. Some actions also have their own, tighter limits, the same as on the
site: logging in, posting comments, flagging and reporting, and forms that
send email.
Examples
Search, 100 posts at a time:
curl "https://booru.example.com/api/v1/posts?tags=cat+-dog&limit=100"
The response has posts, a count, and next: pass it back as page for
the next page, until it’s absent.
Upload a file:
curl -H "Authorization: Bearer $KEY" \
-F file=@cat.png -F rating=g -F "tags=cat artist:someone" \
https://booru.example.com/api/v1/posts
Add and remove tags without touching the others:
curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"add_tags": ["sleeping"], "remove_tags": ["standing"]}' \
https://booru.example.com/api/v1/posts/123
Change a wiki page, refusing if someone else changed it since version 3:
curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"body": "A small [[animal]].", "base_version": 3}' \
https://booru.example.com/api/v1/wiki-pages/cat
Comment on a post:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"body": "Lovely colours."}' \
https://booru.example.com/api/v1/posts/123/comments
Make a pool of three posts, then add a fourth at the end:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name": "My comic", "category": "series", "post_ids": [120, 121, 122]}' \
https://booru.example.com/api/v1/pools
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"post_id": 123}' \
https://booru.example.com/api/v1/pools/1/posts
Add a note (the box is in the original image’s pixels):
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"x": 120, "y": 40, "width": 200, "height": 80, "body": "Good morning!"}' \
https://booru.example.com/api/v1/posts/123/notes
Saved searches (/saved-searches) and favorite groups
(/favorite-groups) work the same way; the site’s /api/docs lists
every endpoint.
Danbooru apps
Moekura answers Danbooru’s API too, so apps and tools made for Danbooru can use a Moekura site: point them at the site as if it were a Danbooru instance. Logged out, they see what visitors see. To log in, make an API key under Settings → API keys and give the app your name and the key (not your password).
gallery-dl
Put Danbooru: in front of a site URL:
gallery-dl "Danbooru:https://booru.example.com/posts?tags=cat"
gallery-dl -u yourname -p mka_… "Danbooru:https://booru.example.com/posts?tags=cat"
Or add the site once in gallery-dl’s configuration, and use its URLs as they are:
{
"extractor": {
"Danbooru": {
"mybooru": { "root": "https://booru.example.com" }
},
"mybooru": { "username": "yourname", "password": "mka_…" }
}
}
CI checks gallery-dl against every change.
The apps below aren’t tested automatically; if one trips over something, please open an issue.
Grabber
Add a source with the site’s address and choose Danbooru (2.0) as its type. In the source’s settings, enter your name and your API key under login.
Boorusama
Add a booru with the site’s address and choose Danbooru as its engine, then log in with your name and your API key.
What works
| Endpoint | Notes |
|---|---|
/posts.json, /posts/{id}.json, /posts/random.json, /counts/posts.json | search with the site’s syntax; page takes numbers, b<id> and a<id>; up to 200 per page; only= picks fields |
PUT /posts/{id}.json | post[tag_string] (with post[old_tag_string]), post[rating], post[source], post[parent_id] |
/post_versions.json | by search[post_id] |
/tags.json, /autocomplete.json, /related_tag.json | related tags are estimated from a search’s newest 200 posts; for a single tag, wiki_page_tags are the tags its wiki page links to |
/tag_aliases.json, /tag_implications.json | |
/tag_versions.json | by search[tag_id], search[name], search[updater_id] or search[updater_name] |
/wiki_pages.json, /wiki_pages/{title or id}.json | by search[title], search[other_names_match] (* wildcards; without one, the whole name) or search[other_names_include_any] |
/profile.json, /users.json, /users/{id}.json | levels follow roles: Member 20, Contributor 35, Janitor 37, Moderator 40, Admin 50 |
/favorites.json, /favorites/{post_id}.json, /posts/{id}/favorites.json | |
/posts/{id}/votes.json, /post_votes.json | only your own votes are listed |
/artists.json, /artists/{id}.json, /artist_urls.json, /artist_versions.json | read-only; artists by search[name], search[any_name_matches], search[url_matches] (any page of the artist’s), search[is_banned], search[is_deleted] or search[id], each with its urls; URLs by search[artist_id] or search[url_matches]; versions by search[artist_id], search[updater_id] or search[updater_name] |
/artist_commentaries.json, /artist_commentaries/{post_id}.json, /posts/{id}/artist_commentary.json, /artist_commentary_versions.json | a commentary’s id is its post’s; by search[post_id], search[text_matches], search[original_present] or search[translated_present]; PUT /artist_commentaries/create_or_update.json sets artist_commentary[post_id]’s texts (those left out stay) |
/media_assets.json, /media_assets/{id}.json, /media_metadata.json | a post’s file has the post’s id; files by search[id] or search[md5]; metadata by search[media_asset_id], as Group:Tag pairs like EXIF:Make |
/post_replacements.json | read-only, by search[post_id] or search[creator_id] |
/iqdb_queries.json | searching by image: GET with search[url] or search[post_id], or POST a file too; [{post_id, score, post}], score in percent |
/wiki_page_versions.json, /pool_versions.json | wiki versions by search[wiki_page_id], search[title], search[updater_id] or search[updater_name]; pool versions by search[pool_id] or the updater, with added_post_ids and removed_post_ids |
/explore/posts/popular.json, /explore/posts/viewed.json | the best-scored and most viewed posts of a date’s day, or with scale, week or month |
/explore/posts/searches.json, /explore/posts/missed_searches.json | [query, count] pairs: the searches made most, and those that most often found nothing, by date and scale like the posts |
/comments.json, /comments/{id}.json | listed newest first, by search[post_id], search[creator_id] or search[creator_name]; page takes numbers and b<id>; post with comment[post_id] and comment[body], and change or delete your own |
/comments/{id}/votes.json, /comment_votes.json | only your own votes are listed |
/pools.json, /pools/{id}.json | by search[name_matches], search[name_contains], search[id] or search[category]; post_ids lists the posts you can see |
/favorite_groups.json, /favorite_groups/{id}.json | by search[creator_id] or search[creator_name], otherwise yours |
/notes.json, /notes/{id}.json, /note_versions.json | notes by search[post_id] (one or more posts); versions by search[post_id] or search[note_id]; read-only |
POST /uploads.json, /uploads/{id}.json | the first step of an upload: a file as upload[files][0], or a link as upload[source]; its upload, upload media asset and media asset share one id |
POST /posts.json | the second step: upload_media_asset_id with post[tag_string], post[rating], post[source] and optionally post[parent_id]; upload limits apply. Uploads not made into posts within a day are removed |
/ai_tags.json | the tagger’s suggestions, newest posts first, by search[post_id] (or search[media_asset_id], the same number), search[tag_name], search[tag_id], search[is_posted] and search[score] (>=50, 50..90); score is 0 to 100 |
/saved_searches.json | yours; add with saved_search[query] and saved_search[label_string], and delete |
What doesn’t
- Forums and messages don’t exist in Moekura: their lists are empty, and single ones are “not found”.
- Favorites and your votes on posts and comments have no ids of their
own: their
idis the post’s or comment’s. - Pools, favorite groups and notes are read-only here; change them on the site or with Moekura’s API.
- Responses are JSON only, not XML.
Development
Moekura is written in Rust, with pages rendered on the server and a little
TypeScript on top. The plan and roadmap are in
docs/design.md.
You need Rust 1.94+, the media tools, and a PostgreSQL server for the database tests:
podman run -d --name moekura-pg -p 55432:5432 \
-e POSTGRES_USER=moekura -e POSTGRES_PASSWORD=moekura -e POSTGRES_DB=moekura \
docker.io/library/postgres:18-alpine
export DATABASE_URL=postgres://moekura:moekura@localhost:55432/moekura
cargo test --workspace # each database test gets its own database
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all
cargo deny check # licenses, advisories, sources
The page scripts are TypeScript in frontend/, bundled with esbuild into
crates/web/static/js/. The bundle is committed, so building the binary
needs no Node; after changing frontend/src, rebuild it (Node 24+):
cd frontend
npm ci
npm run check # typecheck
npm test # unit tests
npm run build # or npm run watch
Build version
The footer embeds the version when the Rust binary is compiled. A checkout
at the release tag matching Cargo.toml shows that semver; other commits
show git- followed by the first seven characters of the commit hash.
MOEKURA_BUILD_VERSION overrides detection, so CI can distinguish release
builds from main builds even when they share a commit.
For a local Docker build, pass the version explicitly because .git is
excluded from the build context:
docker build --build-arg MOEKURA_BUILD_VERSION="git-$(git rev-parse --short=7 HEAD)" -t moekura .
For a release build, pass its semver instead. Source archives without Git
metadata also need MOEKURA_BUILD_VERSION; without it, the footer displays
git-unknown and the build emits a warning.
End-to-end tests
e2e/ has Playwright tests that drive a real
browser through a running site: registering, uploading, tagging,
searching, the wiki, favorites, moderation and private mode. CI runs them
against deploy/compose.tiny.yml; to run them yourself, see
e2e/README.md.
This book
The documentation is an mdBook in
docs/:
mdbook serve docs # http://localhost:3000, rebuilt as you edit
CI builds it and checks its links on every pull request. Pushes to main
publish it to GitHub Pages once the repository is public (set Settings →
Pages → Source to GitHub Actions).
Releasing
- Draft the changelog entry from the commits since the last release, then
edit it into notes people can read:
git cliff --unreleased --tag vX.Y.Z --prepend CHANGELOG.md. - Set the workspace version in
Cargo.toml, and the date in the changelog heading. - Merge that, then tag the merge commit
vX.Y.Zand push the tag. The Release workflow checks the version and changelog, publishes the image and creates the GitHub release with the binaries.
Layout
crates/core domain types and pure logic (config, permissions, search syntax)
crates/db PostgreSQL pools, migrations, queries, the search planner
crates/storage file storage: local disk or S3
crates/media identifying and processing media with vips and ffmpeg
crates/jobs the job queue's workers and handlers
crates/web the HTTP server: pages, the API, middleware
crates/app the moekura binary: commands, configuration, logging
frontend/ TypeScript for the pages
deploy/ compose files
docs/ this book