Start here

Configuration

Create a .env file at the project root before the first run. It is not committed, and neither are the signing-key and TLS .pem files it points at. docker-compose.yml is the reference for which service reads which variable.

On Kubernetes the same variables go in an overlay's config.env and secrets.env instead: see Deploying.

Shared

Variable Used by Value
ENV all dev, staging or prod. Decides the default public URLs below, and in dev all mail goes to DEVELOPER_EMAIL
DSN services with a database MySQL DSN, e.g. root:root@(mysql:3306)/anahita?parseTime=true
REDIS_ADDR, REDIS_PASSWORD HTTP services Redis for sessions, e.g. redis:6379
REDIS_DB HTTP services Optional. The Redis database number, 0 when left out. Useful when several installations share one Redis
REDIS_TLS HTTP services true to connect to Redis over TLS, which most managed Redis and Valkey services require. The certificate is checked against the host in REDIS_ADDR. Default false
SECURE_COOKIE_KEY HTTP services Key that signs session cookies. auth-service also derives from it the key that sign-in codes are hashed with, so changing it invalidates the codes outstanding at that moment
AMQP_ADDR publishers and consumers RabbitMQ, e.g. amqp://rabbitmq:5672
OAUTH_ISSUER all HTTP services Issuer URL that tokens are signed with and checked against
OAUTH_JWKS_URL all HTTP services Where public signing keys are fetched. Defaults to auth-service inside the network
WEB_ORIGIN nginx, actor-service The web app's origin, e.g. https://example.org. For nginx, the one origin allowed to call the API with the session cookie. For actor-service, where the link in an event's calendar file leads; without it the link is left out. Default http://localhost:3000

Client addresses

auth-service takes a request's address from X-Forwarded-For, reading from the right and skipping proxies, so an entry a client typed is never used. It treats loopback, link-local and private addresses as proxies. That fits nginx in docker-compose and an ingress in a cluster. A proxy on a public address in front of auth-service would be recorded as the client.

Each proxy has to append to the header, which nginx does with $proxy_add_x_forwarded_for. The address keys the per-address rate limits and is recorded in the sign-in log.

A sign-in's city and country are looked up at ipapi.co, with a 3 second limit, which means that third party is sent the address of everybody who signs in. Set IP_LOCATION_URL=off to stop the lookup; the sign-in log then shows no city or country. Set it to another address to use a service of your own that answers the same way (GET <url>/<ip>/json/ returning city and country_name).

Private addresses are never sent, and are recorded as localhost, development. Seeing that in a deployed sign-in log means the address reaching auth-service is a proxy's, not the client's.

Internal calls

The HTTP services call graph-grpc-service, file-grpc-service and mail-grpc-service over gRPC. Those calls use mutual TLS: they are encrypted, and each side presents a certificate signed by the installation's own certificate authority. A caller without one is refused.

Variable Used by Value
GRPC_TLS all on or off. Unset means on, except with ENV=dev. docker-compose.yml sets on
GRPC_TLS_DIR all The directory holding tls.crt, tls.key and ca.crt. Default /run/secrets/grpc
  • In docker-compose scripts/grpc-certs-init.sh makes a development authority and one certificate in ./grpc-certs, which every service mounts. make up and make up_build run it.
  • On Kubernetes cert-manager issues a certificate per gRPC service and one for the callers: see Deploying.
  • Renewal needs no restart. A service looks at the files at most once a minute and uses a replaced certificate or authority from then on.
  • With on and no certificates, a gRPC service refuses to start and a caller refuses to call. Nothing falls back to plain text.
  • off is for an installation where a service mesh such as Linkerd encrypts and authenticates these calls instead.

The server's certificate must name the host that was dialled, so the names in FILE_SERVICE_ADDR, GRAPH_SERVICE_ADDR and MAIL_SERVICE_ADDR have to be on the certificates.

A service written in another language joins in by loading the same three files in its gRPC library: present tls.crt and tls.key, trust ca.crt, and, for a server, require a client certificate.

Site identity

Read by auth-service, and mail-grpc-service for the branding. PUBLIC_DOMAIN and PUBLIC_API_URL are for what speaks to other servers (anahita-libs/site): activitypub-service, whose other settings and the order to do things in are in Federation. atproto-service reads and writes Bluesky and needs none of them; its settings are under Federation.

Variable Value
DOMAIN_NAME The installation's domain. Public URLs are derived from it on staging and production
PUBLIC_DOMAIN Optional. The domain in a person's handle, ana@example.org, as other servers will know them. Left out, it is DOMAIN_NAME on a live site, staging.DOMAIN_NAME on a staging one, or the host of PUBLIC_WEB_URL when that is set. Not to be changed once anybody here has been spoken of to another server: they would become somebody else there
FEDERATION_PROTOCOLS Optional, read by auth-service. The other networks the installation takes part in, with commas between: activitypub when activitypub-service is run for real, atproto when atproto-service is. NodeInfo's protocols claims the first; both are in metadata.networks, which the web app's front page reads to say that the site is part of the fediverse, with Mastodon and Bluesky. Left out, nothing is claimed. See Federation
PUBLIC_API_URL Optional. The address other servers reach the API at. Left out, https://api.DOMAIN_NAME on a live site and https://api.staging.DOMAIN_NAME on a staging one. The same warning applies: the address of every person and post, as told to other servers, begins with it
SITE_NAME Name shown on auth pages and in email
ANAHITA_INSTANCE_NAME How this installation introduces itself to other servers, published as nodeName in NodeInfo. Optional — falls back to SITE_NAME, then DOMAIN_NAME
ANAHITA_INSTANCE_DESCRIPTION A sentence about this installation, published as nodeDescription. Optional, and left empty rather than generated when unset
SITE_LOGO_URL The logo at the top of the sign-in pages and of every email. Optional: unset, the pages show Anahita's mark (auth-service/web/static/images/logo.svg), and emails its PNG, which auth-service serves at <OAUTH_ISSUER>/static/images/logo.png. An absolute URL to a PNG, since mail clients drop SVG
SYSTEM_EMAIL The From address
SUPPORT_EMAIL, SUPPORT_PHONE, SUPPORT_WEBSITE Contact details on the web app's public /support page, published through NodeInfo. Phone and website are optional
TOTP_ISSUER Optional. The label authenticator apps show; derived from DOMAIN_NAME when unset
FRONTEND_URL Optional, not in docker-compose.yml. The web app's URL used in links. Defaults to http://localhost:3000 in dev and https://DOMAIN_NAME in production
AUTH_URL Optional, not in docker-compose.yml. auth-service's public URL. Defaults to http://localhost in dev and https://auth.DOMAIN_NAME in production

Rules and promises

What the site says about itself to the people who use it (roadmap row 102): its rules, which everybody agrees to by ticking each one; its promises, on its About page under "Our promises to you"; and the rule suggestions a new group's administrators may start from. They are the operator's, in a JSON file read by auth-service and served on GET /site (package auth-service/about).

Variable Value
SITE_RULES on unless off. On, everybody ticks the site's rules before using it, and again when their version is raised; off, nobody is asked. Whoever installs Anahita makes its creator's pledge either way (Registration). Read by auth-service and published as metadata.siteRules in NodeInfo; see Registration
SITE_DEFAULTS_PATH Where the file is. /app/defaults/site.json in the image, which holds auth-defaults/site.json; docker-compose mounts the working tree's copy there. On Kubernetes, mount a ConfigMap over that path to change it
{
  "rules": {
    "version": "1",
    "items": [
      { "title": { "en-CA": "No hate.", "fr-FR": "Pas de haine." },
        "body": { "en-CA": "Don't attack or put down people for …" } }
    ]
  },
  "promises": [
    { "title": { "en-CA": "Changed your mind?" },
      "body": { "en-CA": "You have {{days}} days to undo deleting your account. Just sign in again." } }
  ],
  "rule_suggestions": [
    { "title": { "en-CA": "Be kind and respectful" } }
  ]
}
  • Every text carries its own translations, by the web app's language names. A language left out falls back to another of its own (fr-CA to fr-FR), then to English, so a file may be written in one language.
  • Raising the rules' version asks everybody to agree again. A correction that leaves it as it is asks nobody. A version is digits and dots, up to three parts.
  • {{days}} is filled in with the days a deleted account can be brought back (30), so a promise never says what the site does not do.
  • At most ten rules and ten promises; "items": [] is a site with no rules.
  • It is read again when it changes, without a restart. What it leaves out is as it comes, and a file that cannot be understood is not taken: the built-in one is used, and auth-service says so in its log. With docker-compose, change the file in place: an editor that writes a new file in its place is not seen through a mount of one file until the container is started again.
  • The built-in file is auth-service/about/default.json, and auth-defaults/site.json is the same; a test keeps them so.

Registration

Read by auth-service.

Variable Value
REGISTRATION_MODE open, approval, invite or closed. Unset means closed: no self-serve sign-up, and outstanding invitations stop working
INVITES_FROM registered, administrators (default) or super-administrators — the minimum role that may invite. Each admits that role and every role above it. In approval mode, an invitation from somebody who is not an administrator goes to the approval queue
GROUPS_FROM registered, administrators (default) or super-administrators — the minimum role that may make a group. Read by actor-service, which enforces it, and by auth-service, which publishes it in NodeInfo as groupsFrom; give both the same value
WEBSITES_FROM registered, administrators (default) or super-administrators — the minimum role that may connect a website. Read by syndication-service, which enforces it, and by auth-service, which publishes it in NodeInfo as websitesFrom; give both the same value. See Websites, with WEBSITE_HOURLY_CAP and WEBSITE_READ_EVERY
LOCATIONS_FROM registered, administrators (default) or super-administrators — the minimum role that may add a location. Read by location-service, which enforces it, and by auth-service, which publishes it in NodeInfo as locationsFrom; give both the same value. Editing and deleting a location stay with administrators. See Hashtags and locations
EVENTS_FROM registered (default), administrators or super-administrators — the minimum role that may make an event of their own. Read by actor-service, which enforces it, and by auth-service, which publishes it in NodeInfo as eventsFrom; give both the same value. An event made for a group is for that group's administrators whatever this says
INVITE_PENDING_MAX Outstanding invitations one person may have at once. Default 5
INVITE_PENDING_MAX_ADMIN The same for administrators. Default 100
TOS_URL, PRIVACY_URL Where the Terms and Privacy Policy are read: a path on the web app or a full URL. Default /legal/tos and /legal/privacy. Their versions are held by the web app, not here

Privacy of the installation

Read by every service, the same way, so they are set once: in .env for docker-compose, in the shared config.env for Kubernetes. They take effect on a restart.

Variable Value
SITE_READ_ACCESS What somebody who is not signed in may read: public (the default), everything that is public; preview, only the start of it; registered, nothing, a members-only site
MIN_CONTENT_ACCESS The widest audience anything new may be given: public (the default) or registered

A value that is not understood is treated as registered, the strictest, and the log says so. A typo must not be what makes everything public again.

What each one does, how they combine, and how to check them is in Privacy.

First super administrator

Read by auth-service.

Variable Value
SUPER_ADMIN_EMAIL Seeds the first super administrator at boot, only when none exists — safe to leave set
SUPER_ADMIN_USERNAME Optional. Derived from the email when unset
SUPER_ADMIN_NAME Optional display name

OAuth signing and encryption

Read by auth-service.

Variable Value
OAUTH_SIGNER_BACKEND local (default) or kms
OAUTH_PRIVATE_KEY_PATH Signing key for local, mounted from ./oauth-signing.pem
OAUTH_KMS_KEY_ALIAS Signing key for kms
OAUTH_SEED_CLIENT_PATH First-party clients seeded at first boot. See Seeding clients
OAUTH_KEY_LIFETIME_DAYS Optional, not in docker-compose.yml. Signing key rotation, default 90
CRYPT_BACKEND Encryption of secrets at rest: local (default) or kms
CRYPT_LOCAL_KEY Key for local: exactly 32 characters. Keep it out of the database and its backups. activitypub-service keeps every person's signing key under it too, and atproto-service the Bluesky tokens of connected accounts, so both must be given the same value: see Federation
AWS_KMS_KEY_ID Key for kms
CRYPT_KMS_REGION Region of that KMS key. Optional: defaults to the AWS region in the environment (AWS_DEFAULT_REGION)
MAX_DB_CONNECTIONS Optional database pool size

What is encrypted at rest. Two-factor (TOTP) secrets, and the private half of a locally stored token signing key. Both use CRYPT_BACKEND. With the signing key sealed, reading the database is not enough to mint tokens: the reader also needs CRYPT_LOCAL_KEY (or access to the KMS key).

A signing key written by a version that stored it in the clear is sealed the first time auth-service starts, and the log says how many were.

If CRYPT_LOCAL_KEY changes or is lost, auth-service cannot open the signing key and refuses to start, and existing TOTP secrets become unreadable. To recover sign-in, delete the rows in oauth_signing_keys and restart: a new key is created, and everybody signs in again. People with two-factor turned on need it reset by an administrator.

Storage

Read by file-grpc-service. Uploaded files (avatars, covers, photos) go to an object store that speaks the S3 API: AWS S3, another provider's, or one you run yourself such as Garage (opens in a new tab). There is no option to store files on the local disk.

Variable Value
STORAGE_ENDPOINT Where file-grpc-service reaches the store, e.g. https://s3.gra.io.cloud.ovh.net or http://garage:3900. Leave empty for AWS S3, whose address follows from the region
STORAGE_PUBLIC_ENDPOINT The address written into the links browsers load files from. Set it when it differs from STORAGE_ENDPOINT, which it does whenever the store is reached by an internal name. Defaults to STORAGE_ENDPOINT
STORAGE_REGION The region requests are signed for; AWS_REGION or AWS_DEFAULT_REGION is read when it is left out. A store without regions still expects one: use whatever it is configured with (Garage: garage)
STORAGE_BUCKET The bucket. It must exist and be private
STORAGE_ACCESS_KEY, STORAGE_SECRET_KEY Credentials. Leave both empty on AWS to use a role or profile
STORAGE_PATH_STYLE true to address files as endpoint/bucket/key rather than bucket.endpoint/key. Self-hosted stores usually need it. Default false
STORAGE_SSE AES256 to ask the store to encrypt each file at rest, or none. Default AES256 on AWS, none when STORAGE_ENDPOINT is set
STORAGE_SEND_ACL true to mark each upload private with an ACL header. Default true on AWS, false when STORAGE_ENDPOINT is set. Set false for an AWS bucket with ACLs disabled, the default for new buckets: see A bucket on AWS S3
STORAGE_ALLOWED_TYPES Comma-separated content types that may be stored. Default image/jpeg,image/png

The two headers default to off for other stores because a store that does not implement one refuses the upload. Turn them on if yours supports them.

What may be stored. file-grpc-service looks at the bytes of every upload, ignores the type the caller declared, and stores only what is on the allowed list, under its real type. An HTML page or an SVG named photo.png is refused. A store serves a file back with the type it was given, and a browser acts on that type, so this is what keeps an upload from running script.

Links expire. Files are private. Each link handed to a browser is signed and valid for 15 minutes.

Earlier AWS names

Before the STORAGE_* names existed, the same settings were read from AWS_DEFAULT_REGION, AWS_FILES_S3_BUCKET, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. They still work when the STORAGE_* one is unset, so an installation on AWS S3 needs no change. AWS_FILES_S3_ASSETS_DIR was required but never used, and is no longer read.

AWS_PROFILE, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION are also read by auth-service when CRYPT_BACKEND or OAUTH_SIGNER_BACKEND is kms.

Tested stores

Store Settings beyond bucket and keys Result
AWS S3 none (STORAGE_ENDPOINT empty) In use by the reference installation
Garage 2.1 STORAGE_ENDPOINT, STORAGE_REGION=garage, STORAGE_PATH_STYLE=true Passes the storage test. Also accepts uploads sent with STORAGE_SSE=AES256 and STORAGE_SEND_ACL=true

To check another store, point the STORAGE_TEST_* variables at a bucket on it and run the test in file-grpc-service/data/services (see storage_integration_test.go for the variables):

cd file-grpc-service && STORAGE_TEST_ENDPOINT=https://… go test ./data/services/ -run Real -v

Locations

Read by location-service, and by actor-service for an event whose maker asked for a map of its address. An address is turned into coordinates by Photon first, then Nominatim, both open-source geocoders built on OpenStreetMap.

Variable Value
GEOCODER_PHOTON_URL Where Photon is reached. Default: the public https://photon.komoot.io. off leaves it out
GEOCODER_NOMINATIM_URL Where Nominatim is reached. Default: the public https://nominatim.openstreetmap.org. off leaves it out
GEOCODER_CONTACT An email address or URL for whoever runs this installation, sent to the geocoders in the User-Agent. The public Nominatim's usage policy asks for one

The public instances are shared services with strict limits, and they see every address a member types. Both can be self-hosted; point the variables at your own. At least one has to stay on for location-service, because a location cannot be saved without coordinates. actor-service starts without either, and events are then saved with their address and no map. An event's address is sent to a geocoder only when "Show a map" is ticked on its form.

Notifications

Read by notification-service. See Notifications.

Variable Value
NOTIFICATIONS_KEEP_DAYS Optional. How many days a notification is kept after it was read. 90 when unset; 0 keeps them for good. An unread one is kept until it is read

Read by hashtag-service and location-service: over what span of time the hashtags and locations trending are counted. A hashtag or a location trends by how many profiles used it in that time, each once: the writers of public posts carrying it, by when the posts were written, and the public profiles tagged with it, by when they were tagged. See Hashtags and locations.

Variable Value
TRENDING_WINDOW Optional. daily, weekly, monthly or yearly. Left out, daily when FEDERATION_PROTOCOLS names a network, where the fediverse brings enough every day, and monthly on an installation only its own members post to

Whatever it starts from, a window that holds fewer than ten is widened to the next, a week, a month, then a year, so the list is not empty on a quiet day; the answer says which window it is of, and the web app shows it. A client may still ask for a window (time_window). See Hashtags and locations.

Mail

Read by mail-grpc-service.

Variable Value
SMTP_HOST, SMTP_PORT The SMTP server
SMTP_USER, SMTP_PASSWORD Optional. Leave both empty for a relay that needs no authentication
SMTP_SECURE true for implicit TLS (port 465); false (default) for STARTTLS (port 587)
DEVELOPER_EMAIL Where every message goes when ENV=dev. Required in development — the service refuses to start without it

Mail goes out over SMTP and nothing else, so any provider works — Mailgun's or SES's relay, Postmark, an internal MTA, or a local sink such as mailpit (opens in a new tab). Message templates live in mail-grpc-service/templates/.

Abuse reports

Read by abuse-report-service.

Variable Value
REASONS_PATH An installation's own list of reasons people can give when they report something. Optional: unset, the list built into the service is used. See Using your own reasons
SPAM_REMOVAL_MAX_DAYS How many days after it was made an account may still be removed as spam. 30 unless said. Read by graph-grpc-service, which refuses an older one, and auth-service, which publishes it as metadata.spamRemovalMaxDays. See Removing as spam

Somebody may file at most 20 reports an hour. That limit, like the ones on signing in, is counted in Redis by the shared limiter in anahita-libs/ratelimit, and is not enforced while Redis cannot be reached.

Account cleanup

Read by auth-service, for the accounts list a super administrator uses to find accounts nobody uses.

Variable Value
ACCOUNT_DORMANT_DAYS How many days without activity make an account dormant in that list. Optional: unset, 730 (two years). It only sorts accounts into tiers; nothing is removed because of it
ACCOUNT_GRACE_DAYS How many days a deleted profile can be brought back before it is purged: a person by signing in again, a group or an event by an administrator. 30 unless said. Read by auth-service (the dates it tells people, metadata.accountGraceDays in NodeInfo, the site's promise), graph-grpc-service and the account sweeper (when it purges); all three must have the same. Not kept per profile: changing it moves the date of every profile already waiting

Two things about purging are fixed in the code and are not settings: one purge request names at most 500 accounts, and a purge works through an account 500 posts at a time. How often the sweeper runs is its CronJob's schedule, in k8s/base/jobs/sweeper-cronjob.yml.

graph-grpc-service and the sweeper need FILE_SERVICE_ADDR to remove a purged account's uploaded files. Both have it in docker-compose.yml and in the Kubernetes manifests.

Service addresses (MAIL_SERVICE_ADDR, FILE_SERVICE_ADDR, GRAPH_SERVICE_ADDR) and the defaults directories (DEFAULTS_PATH, ACTOR_FEATURES_PATH, SEARCH_RANKING_PATH) are set in docker-compose.yml itself and need no entry in .env. They default to /app/defaults, /app/defaults/actor_features and /app/defaults/search_ranking.json, where the images keep their copies; docker-compose points them at the working tree so an edit shows without a rebuild. The last is the weights search orders "Most relevant" by; see Search.

Federation

Read by activitypub-service and atproto-service, the two services that speak to other networks. How to set either up, in order, is in Federation and, for Bluesky, Running it.

Variable Value
FEDERATION_PROTOCOLS Read by auth-service: the networks to announce in NodeInfo, activitypub, atproto, or both with a comma. Left out, none
ACTIVITYPUB_LAB Development only, for the federation lab: lets activitypub-service reach the lab's private addresses. Never on a live site
ATPROTO_APPVIEW_URL, ATPROTO_JETSTREAM_URL, ATPROTO_CDN_URL, ATPROTO_PLC_URL Where atproto-service reads Bluesky from; all optional, Bluesky's own when left out. See Running it
FEDERATION_LOCATIONS Read by activitypub-service. Whether a post from another server is tagged with the places it names, Pixelfed's location above all: each matched to a location here, or made. on unless off. See Federation
LINK_PREVIEW_URL Where link-preview-service is, for the card of a link in a post from elsewhere. Left out, such posts have no card
REMOTE_MEDIA_DAYS Optional, read by activitypub-service and atproto-service. How many days the pictures of a post from another server are kept in storage after the post was last touched; then they are removed, and copied again when somebody asks to see them. Thirty when left out; 0 keeps them for good. See Pictures that are removed
REMOTE_UNFOLLOWED_DAYS Optional, read by the same two. How many days the posts of somebody on another server are kept once nobody here follows them and the posts have been left alone; then they are removed, and after them the person, when nothing here refers to them. Thirty when left out; 0 keeps them for good. See Posts and people that are removed
REMOTE_POSTS_DAYS Optional, read by the same two, and off when left out. Any post from another server that has been left alone this many days is removed, whoever wrote it and whoever follows them. What it removes is not got back by asking
REMOTE_POSTS_KEPT Optional, read by the same two, and off when left out. The most posts kept of one person on another server, the newest. What it removes is not got back by asking

Writing to Bluesky also needs CRYPT_BACKEND and CRYPT_LOCAL_KEY (or AWS_KMS_KEY_ID), the same as auth-service's, and a site open to read (SITE_READ_ACCESS).

The web shell

Only for an installation that sends the web app through web-shell; see The web shell.

Variable Value
PUBLIC_WEB_URL The address people reach the web app at, such as https://www.example.org. Left out, the host each request came to is used
WEB_BUILD_PATH docker-compose only: the folder on the host with the web app's build in it. Defaults to web-shell/web
WEB_BUILD_DIR Where web-shell looks for the build inside its container. Defaults to /app/web; change it only with a build put elsewhere

Photos

Variable Value
PHOTO_MAX_FILES How many images one photo post may hold, from 1 to 20. 20 unless said. Read by auth-service (which tells the web app), photo-service and the two federation services (which keep no more of a post from elsewhere). Posts made before it was lowered keep their images. See Photo posts

Video

Only where videos may be posted. What each does, and how they go together, is in Videos.

Variable Value
VIDEO_UPLOADS on to let people post videos. Off unless said
VIDEO_MAX_BYTES The largest file taken. 500 MB unless said
VIDEO_MAX_SECONDS The longest video taken. Ten minutes unless said
VIDEO_RENDITIONS The sizes made, by height: 1080,720,360 unless said, up to 2160
VIDEO_DAILY_UPLOADS How many videos one person may send in a day. 20 unless said
ANIMATION_MAX_BYTES The largest GIF or animated PNG taken, posted as a video that loops. 25 MB unless said
ANIMATION_MAX_SECONDS The longest one. 30 seconds unless said

The bucket needs a CORS rule letting the web app's address PUT to it: see Videos.

Download your data

Only where export-service and its worker run. See Download your data.

Variable Value
EXPORT_EVERY_DAYS Days between one export and the next. 7 unless said; 0 for no wait
EXPORT_KEEP_DAYS Days an archive is kept. 7 unless said
SITE_NAME The site's name, for the heading of an archive's page

From anahitasocial/anahita-services, 2026-10-27.

Written by Claude, reviewed by Rastin Mehr · About this