Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 startAfter the e2e tests1,000,000 posts, after a burst of requests
moekura serve (web and jobs)25 MB68 MB68 MB
PostgreSQL (the compose settings)54 MB83 MB250 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 APIsSearches
100,000 (240 MB)under 4not measured
1,000,000 (2.1 GB)under 6mostly 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_sizes and media.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; latest tracks 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 serve and worker check for at startup:
ToolUsed forFedoraDebian / Ubuntu
libvips 8.15+ (vips, vipsheader, vipsthumbnail)reading images, thumbnails, perceptual hashesvips-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-freeffmpeg

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: the https:// address people use. Cookies are then marked Secure, and forms are only accepted from that origin.
  • server.trusted_proxies: the proxy’s address. The app believes the X-Forwarded-For header 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/abc or Help /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
ModeWho can create an account
openanyone
invitepeople with an invite code (moekura admin create-invite)
approvalanyone, but staff approve new accounts before they can log in (Admin → Users, filter pending)
closednobody; admins create accounts from the shell

Email

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.

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

  1. Read the changelog for every release between yours and the new one.
  2. Back up the database.
  3. Replace the program: pull the new image, or put the new binary in place.
  4. Start it. Database migrations run on start unless database.auto_migrate = false; with several servers, run moekura migrate once 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:

  1. built-in defaults,
  2. moekura.toml in the working directory, or the file given with --config <path> or MOEKURA_CONFIG,
  3. 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]

KeyDefaultMeaning
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_secs30requests running longer are stopped with a 408
api_requests_per_minute300API requests a client may make a minute on average (per account, or per address for visitors); 0 for no limit
api_burst60how many API requests may come at once before the per-minute rate applies

[database]

KeyDefaultMeaning
url(required)the primary’s connection URL
replicas[]read replicas’ URLs, used for searches and listings
max_connections16per pool (the primary and each replica)
min_connections0
acquire_timeout_secs5how long to wait for a free connection
statement_timeout_ms30000server-side limit per statement; 0 for none
auto_migratetruemigrate on start; with several servers, set false and run moekura migrate when deploying
replica_max_lag_secs10replicas further behind are skipped until they catch up; also how long someone’s reads stay on the primary after they change something

[auth]

KeyDefaultMeaning
session_idle_days30a login ends after this many days unused…
session_max_days365…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.

KeyDefaultMeaning
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.

KeyDefaultMeaning
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]

KeyDefaultMeaning
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_secs30how 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]

KeyDefaultMeaning
workers2background jobs processed at once, per process
run_in_servetruealso run workers inside serve; set false when you run moekura worker separately
lock_timeout_secs300a 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.

KeyDefaultMeaning
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_secs30connecting 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.

KeyDefaultMeaning
per_page40posts per page
max_per_page200the most limit: may ask for
max_page1000deepest numbered page; “next” links keep working beyond it
max_terms40most tags and filters in one search
wildcard_limit100most tags a wildcard expands to (the most used)
count_limit10000result counts are exact up to this, estimated above
count_cost_limit25000counts PostgreSQL expects to cost more than this (roughly pages read) are estimated instead, so filters no index covers don’t read every post

[storage]

KeyDefaultMeaning
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]

KeyDefaultMeaning
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_stylefalseput the bucket in the path; most self-hosted stores need this

See File storage.

[media]

KeyDefaultMeaning
max_upload_mb100largest upload
max_pixels200000000larger images are refused before decoding
max_duration_secs600longest 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_size1600larger images also get a resized copy for the post page
variant_format"webp""webp" or "avif" (smaller, slower) for thumbnails and samples
tool_timeout_secs120longest a media tool may run
work_dirsystem temp dirscratch space for uploads and processing

[media.tools]

Paths to vips, vipsheader, vipsthumbnail, ffmpeg and ffprobe, if they aren’t on PATH.

[paths]

KeyMeaning
templates_overridea directory whose files replace built-in templates with the same path, e.g. base.html
static_overridethe same for static files, e.g. css/main.css; also where themes are added

[tagger]

The optional tagger, which suggests tags for new uploads.

KeyDefaultMeaning
enabledfalsequeue 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_urlunsetfor custom: the ONNX model and its selected_tags.csv
model_sha256, tags_sha256unsetfor custom: their SHA-256 checksums
model_dir"data/models"where models are downloaded to, one directory each
runtimeORT_DYLIB_PATH, or the system’sthe ONNX Runtime library, libonnxruntime.so
threads0threads one image uses; 0 means one per core
workers1posts tagged at once
account"tagger"who automatically applied tags are credited to; created without a password on first use

[telemetry]

KeyDefaultMeaning
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]

KeyDefaultMeaning
allow_private_addressesfalselet webhooks go to private, loopback and link-local addresses (a service on the same machine or network)
timeout_secs10how 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.

PermissionAllows
View postsseeing posts, tags and profiles; take it from Anonymous for a private site
Uploaduploading posts
Upload without approvalskipping the approval queue, when it’s on
Edit posts and tagschanging a post’s tags, rating, source, description and parent; requesting aliases and implications
Favorite, Votefavoriting posts; voting on posts and comments
Commentposting comments, and editing and deleting your own
Edit the wiki and artists
Mass edit tagsadding and removing tags on every post a search finds (Moderation → Mass edit)
Lock posts and change locked oneslocking a post’s rating, tags, notes or status, and changing them anyway
Undo a user’s post editstaking back every post edit a user made in a range of days (Moderation → Post changes)
Replace posts’ filesswapping a post’s file for a better one, keeping everything else (Posts and files)
Edit notesadding, moving, changing and deleting notes on posts
Create and edit poolsmaking pools, and changing their posts, names and descriptions (deleting a pool takes Delete and restore posts)
Flag postsasking moderators to delete a post
Approve posts and handle flagsthe approval and flag queues
Delete and restore posts
Purge postsremoving deleted posts and their files for good
Manage tags, aliases and implicationstag categories, deprecating tags, deciding alias and implication requests
See deleted postsdeleted posts and comments
Hide comments and handle reports about themhiding and restoring anyone’s comments, and the reported comments queue
Ban users and networks
Manage userschanging other users’ roles and account status
Manage site settings and roles
Read the moderation log

The built-in roles and what they start with:

RoleRankPermissions
Anonymous0View posts
Member10Anonymous, plus Upload, Edit posts and tags, Comment, Favorite, Vote, Flag posts, Edit the wiki and artists, Create and edit pools, Edit notes
Contributor20Member, plus Upload without approval
Janitor30Contributor, plus Approve posts, Delete and restore posts, Manage tags, See deleted posts, Hide comments
Moderator40Janitor, plus Ban users and networks, Read the moderation log, Mass edit tags, Lock posts, Undo a user’s post edits, Replace posts’ files
Admin50everything

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 401 to 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:

JobDoes
media.processmakes thumbnails, samples and video posters, and the perceptual hash for similar-image search, after an upload
tags.apply_relationre-tags existing posts when an alias or implication is approved
posts.purgeremoves a purged post and its files
ml.tag_postsuggests 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:

EventWhen
post.createda post is uploaded (by any means)
post.approveda pending post is approved
post.deleteda post is deleted or rejected
post.flaggeda post is flagged
comment.createda comment is posted
user.registeredsomeone 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-Eventthe event
X-Moekura-Deliverythe delivery’s number (the same across retries)
X-Moekura-Timestampseconds since 1970 when it was sent
X-Moekura-Signaturesha256= 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:

ThemeColours
Defaultneutral greys with a slate-blue accent
Forestmoss green
Oceandeep blue-green
Sakurapink, 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
OptionMeaning
--uploader NAMEthe account the posts are uploaded by (required)
--rating Rrating for files whose sidecar has none: g, s, q or e
--tags "a b"tags to add to every file
-r, --recursivealso import files in subfolders
--dry-runshow 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:

FieldMeaning
tagsa 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_metaDanbooru’s fields, as its API and gallery-dl’s Danbooru metadata have them
ratingg, s, q, e, their names, or safe
sourcewhere 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 sitesdanbooru
e621, e926e621
Gelbooru, Safebooru, Rule34 and other Gelbooru 0.2 sitesgelbooru
Moebooru: Konachan, yande.remoebooru

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.
  • --notes and --pools also 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:

  1. A build with the tagger: the release binaries and images have it; from source, cargo build --release -p moekura --features tagger.
  2. ONNX Runtime 1.17 or newer: Microsoft’s onnxruntime-linux-x64 or -aarch64 archives have libonnxruntime.so. Point tagger.runtime (or ORT_DYLIB_PATH) at it, or put it on the library path.
  3. enabled = true in [tagger], for every process: serve and worker queue each processed upload for the tagger, and moekura tagger does 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 (tagger by 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:

ModelDownloadNotes
wd-vit-tagger-v3 (default)380 MBthe fastest; fine on small machines
wd-convnext-tagger-v3400 MBsimilar size and accuracy, somewhat slower
wd-swinv2-tagger-v3470 MBa little more accurate, slower
wd-eva02-large-tagger-v31.3 GBthe 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
    40.46 s
    11.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

CommandDoes
moekura serveruns the web server, plus job workers unless jobs.run_in_serve = false; migrates first unless database.auto_migrate = false
moekura workerruns job workers only
moekura tagger [--check]runs the tagger (a build with the tagger feature); --check only loads ONNX Runtime
moekura migrateapplies pending migrations and exits
moekura check-configvalidates the configuration and prints it, secrets redacted
moekura openapiprints 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 ROLEchanges someone’s role
moekura admin create-invite [--uses N] [--expires-days D]makes an invite code, shown once
moekura admin settingsshows the site settings
moekura admin settings set KEY VALUEchanges 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-tagsrecomputes 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 ADDRESSsends 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 worker processes;
  • 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 = false and run moekura migrate when 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.

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:

SearchExamplems
front page0.3
a tag on 70% of postsred_red0.7
two / three common tagsred_red blue_red8 / 15
a common tag without anotherblue_red -red_red19
a rare tag (200 posts)detailed_back_31.2
a common and a rare tagred_red detailed_back_38.7
either of two tags~wet_red ~dry_red30
wildcardswet_*, *_red38, 6
file type and a tagfiletype:mp4 blue_red53
rating and scorerating:e score:>201.6
a year, with a tagdate:2021, blue_red date:20212.4, 5.4
page 500 of a common tag6.5
a cursor deep into a common tagpage=b25000000.6
a pool, in its own orderpool:23, ordpool:231.8, 1.6
posts in any poolpool:any41
a favorite group (300 posts)favgroup:12352
a word in notes, common / rarenote:red, note:new74 / 23
recently commented / notedorder:comment, order:note12, 3.5
comment countcommentcount:>328
suggested by the tagger, alone / with a common tagai:long_red32 / 49
a user’s 16 saved searchessearch:all82

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:

PagePath116
front page/3.38.5
a common tag/posts?tags=red_red4.210
two common tags/posts?tags=red_red+blue_red49.7
rare tags/posts?tags=detailed_back_34.912
posts with 20–33 comments, notes and a pool/posts/675613.69.6
pools/pools/232.76.5
newest comments/comments4.49.8
tag list/tags0.61.8
API search, with a tag/api/v1/posts?tags=red_red3.611
API post/api/v1/posts/675611.14.2
Danbooru search, with a tag/posts.json?tags=red_red3.49.5
Danbooru post/posts/67561.json2.59.3
autocomplete (site, API, Danbooru)/tags/autocomplete?q=re1.93.6
feed, with a tag/posts.atom?tags=red_red3.810

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.

  1. Open Two-factor login settings and choose Set it up.
  2. Scan the QR code with your app, or type in the key shown under it.
  3. Enter the code the app shows, to check it has the key.
  4. 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

…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:

SitePages
Pixivpixiv.net/artworks/<id>, and its files on i.pximg.net
X (Twitter)x.com/<user>/status/<id>, and …/photo/<n> for one picture
Blueskybsky.app/profile/<user>/post/<id>
DeviantArtdeviantart.com/<user>/art/<work>
pixivFANBOX<creator>.fanbox.cc/posts/<id> (public posts)
Skebskeb.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 typeFinds posts that…
cathave the tag cat
cat cutehave both tags
cat -doghave cat but not dog
~cat ~doghave cat, dog or both
long_*have any tag starting with long_
*_hairhave any tag ending in _hair
-*_hairhave 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 typeFinds posts that…
(cat or dog) -rating:ehave 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.

FilterExampleMeaning
rating:rating:e,qrating general, sensitive, questionable or explicit (letters or names, comma-separated)
score:score:>=10score
favcount:favcount:>5number of favourites
commentcount:commentcount:>0number of comments
notecount:notecount:>0number of notes
note:note:good_morningnotes contain these words (underscores for spaces)
id:id:1000..2000post number
user:user:aliceuploaded by this user
fav:fav:alicefavorited by this user
approver:approver:alice, approver:any, approver:noneapproved by this user, by anyone, or by no one (posts that never waited for approval)
commenter:commenter:alicehas a comment by this user
comment:comment:nice_artcomments contain these words (underscores for spaces)
commentary:commentary:true, commentary:untranslated, commentary:new_workhas 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:parametersthe 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:>1000the 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:truethe post’s notes are drawn on the picture (see Notes), or not
noter:noter:alicehas a note this user wrote or edited
upvote:, downvote:upvote:alicevoted 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:aliceflagged by this user; only staff who review posts may search for others’ flags, everyone else only for their own
search:search:all, search:artiststhe newest posts (500 each) of your saved searches, all or those with a label
favgroup:favgroup:best, favgroup:7, favgroup:any, favgroup:nonein 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:nonein this pool (by name or number), in any pool, or in none
width:, height:width:>=1920size in pixels
mpixels:mpixels:>2megapixels (width × height ÷ 1,000,000)
ratio:ratio:16:9, ratio:<1width ÷ height (16:9 or a number; exact values match within 0.01)
filesize:filesize:>2mbfile size, in bytes or with kb, mb, gb (an exact size with a unit matches within 5%)
duration:duration:>30length of a video, in seconds
filetype:filetype:png,webmfile type: jpg, png, gif, webp, avif, jxl, mp4, webm, ugoira (or zip)
date:date:2026-01upload date (UTC): a day, month or year
source:source:https://twitter.com/foo, source:*pixiv.net*, source:none, source:anythe source starts with this, or matches a pattern with *, regardless of case; or posts without / with a source
age:age:<1w, age:2d..1mouploaded 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-01last changed (tags, rating, source, status, …) this long ago, or on these days
md5:md5:d41d8cd9…the file’s MD5 hash
similar:similar:123looks like post 123 (the post included); found once files are processed
parent:parent:123, parent:none, parent:anya post and its children, posts without a parent, or posts with one
child:child:any, child:noneposts with children (that aren’t deleted), or without
tagcount:tagcount:<5number of tags
<category>tags:arttags:0, gentags:>20number 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_hairthe tagger suggests this tag, and the post doesn’t have it yet
status:status:deletedpending, active, flagged, deleted, modqueue, unmoderated, appealed or any (see below)

is: and has: are shorthands for other filters, as on Danbooru:

ShorthandSame as
is:parent, has:childrenchild:any
is:child, has:parentparent:any
is:sfw, is:nsfwrating:g,s, rating:q,e
is:general, is:explicit, …rating:g, rating:e, …
is:pending, is:deleted, …status:pending, status:deleted, …
has:sourcesource:any
has:poolspool:any
has:notes, has:commentsnotecount:>0, commentcount:>0

Numbers (and sizes and dates) can be compared:

FormMeaning
5exactly 5
>5, >=5, <5, <=5more / at least / less / at most
5..10from 5 to 10, both included
5.., ..10at least 5 / at most 10
1,2,3any 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

FilterOrder
order:id (default), order:id_ascnewest / oldest first (order:created_at works too)
order:score, order:score_aschighest / lowest score
order:favcount, order:favcount_ascmost / fewest favourites
order:mpixels, order:mpixels_asclargest / smallest image
order:filesize, order:filesize_asclargest / smallest file
order:landscape, order:portraitwidest / tallest first
order:duration, order:duration_asclongest / shortest video
order:tagcount, order:tagcount_ascmost / fewest tags
order:arttags, order:gentags_asc, …most / fewest tags in a category
order:comment, order:comment_ascmost / least recently commented (only posts with comments)
order:note, order:note_ascmost / least recently noted (only posts with notes)
order:change, order:change_ascmost / least recently changed, e.g. to follow recent tag edits (order:updated works too)
order:rankhot 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_asclike 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:customin the order of the search’s id: list: id:3,1,2 order:custom
order:md5, order:md5_ascby the file’s MD5, for a stable order that isn’t upload order
order:randomshuffled
ordfav:alicealice’s favorites, most recently favorited first
ordpool:my_comicthe pool’s posts, in the pool’s order
ordfavgroup:bestthe 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 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:

CategoryFor
generalwhat’s in the picture
artistwho made it
copyrightthe series or franchise
characterwho’s in it
metathings 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:

MetatagDoes
rating:g, s, q, e (or the full name)sets the rating
source:https://…, source:nonesets or clears the source
parent:123, parent:none or -parentsets or clears the parent; -parent:123 clears it only if it’s 123
child:123, -child:123makes post 123 a child of this one, or stops it being one
pool:12, pool:name, -pool:12adds the post to the end of a pool, or takes it out
newpool:namestarts a pool with the post (or adds it to the pool of that name)
fav, -favfavorites the post, or stops favoriting it
favgroup:12, favgroup:name, -favgroup:12adds the post to one of your favorite groups, or takes it out
upvote, downvotevotes 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.

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).

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:

LineDoes
alias kitty -> cataliases kitty to cat
imply cat -> animalmakes cat imply animal
unalias kitty -> cat, unimply cat -> animalends an alias or implication
update cat_ears solo -> animal_ears -cat_earsa mass edit: the posts a search finds get the tags after the arrow, and lose those with -
category someone -> artistmoves 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

WriteFor
A blank lineA new paragraph
h2. Heading (h1. to h6.)A heading, at the start of a line
* item, ** nested itemA 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 #45A link to a post or a comment
[quote] and [/quote] on lines of their ownA quote
https://example.comA 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

FilterFinds
commentcount:>0posts with comments
order:commentposts 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

FilterFinds
pool:my_comic, pool:12posts in the pool (by name, regardless of case, or number)
pool:any, pool:noneposts in some pool, or in none
ordpool:my_comicthe 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

FilterFinds
note:good_morningposts whose notes contain these words (underscores for spaces)
notecount:>0posts with notes
order:noteposts 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:

HeaderMeaning
X-RateLimit-Limitrequests that may be made at once (the burst)
X-RateLimit-Remainingrequests left right now
X-RateLimit-Resetwhen 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).

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

EndpointNotes
/posts.json, /posts/{id}.json, /posts/random.json, /counts/posts.jsonsearch with the site’s syntax; page takes numbers, b<id> and a<id>; up to 200 per page; only= picks fields
PUT /posts/{id}.jsonpost[tag_string] (with post[old_tag_string]), post[rating], post[source], post[parent_id]
/post_versions.jsonby search[post_id]
/tags.json, /autocomplete.json, /related_tag.jsonrelated 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.jsonby search[tag_id], search[name], search[updater_id] or search[updater_name]
/wiki_pages.json, /wiki_pages/{title or id}.jsonby search[title], search[other_names_match] (* wildcards; without one, the whole name) or search[other_names_include_any]
/profile.json, /users.json, /users/{id}.jsonlevels 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.jsononly your own votes are listed
/artists.json, /artists/{id}.json, /artist_urls.json, /artist_versions.jsonread-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.jsona 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.jsona 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.jsonread-only, by search[post_id] or search[creator_id]
/iqdb_queries.jsonsearching 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.jsonwiki 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.jsonthe 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}.jsonlisted 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.jsononly your own votes are listed
/pools.json, /pools/{id}.jsonby 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}.jsonby search[creator_id] or search[creator_name], otherwise yours
/notes.json, /notes/{id}.json, /note_versions.jsonnotes by search[post_id] (one or more posts); versions by search[post_id] or search[note_id]; read-only
POST /uploads.json, /uploads/{id}.jsonthe 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.jsonthe 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.jsonthe 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.jsonyours; 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 id is 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

  1. 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.
  2. Set the workspace version in Cargo.toml, and the date in the changelog heading.
  3. Merge that, then tag the merge commit vX.Y.Z and 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