When running Appwrite in production (especially from Digital Ocean Marketplace or other pre-built installations), you might want to customize email templates to match your brand. This guide shows you how to do it properly using volume mounts to ensure your changes persist across updates.
\
Appwrite’s email system consists of two main components:
.tpl): HTML structure of emails located in /usr/src/code/app/config/locale/templates/.json): Text content for different languages in /usr/src/code/app/config/locale/translations/Available email templates include:
email-magic-url.tpl – Passwordless login emails
email-inner-base.tpl – Password recovery emails
email-otp.tpl – One-time password emails
email-mfa-challenge.tpl – Multi-factor authentication
email-session-alert.tpl – New session notifications
\
When using pre-built Appwrite images (like from Digital Ocean Marketplace), the files exist inside Docker containers. If you edit them directly in the container, your changes will be lost when you:
Update Appwrite to a new version
Recreate containers
Scale your deployment
\
Volume mounts let you replace container files with your own custom versions that persist on the host filesystem.
\
ssh root@your-server-ip cd ~/appwrite # or wherever your docker-compose.yml is located
\
Since the files are inside the Docker image, we need to copy them out first:
# Create directories for custom files mkdir -p custom-templates mkdir -p custom-translations # Copy all template files from container docker cp appwrite:/usr/src/code/app/config/locale/templates/. ./custom-templates/ # Copy all translation files from container docker cp appwrite:/usr/src/code/app/config/locale/translations/. ./custom-translations/
\ Important: You must copy ALL translation files, not just the ones you want to edit. When you mount a directory, it replaces the entire directory in the container.
\ And you should end up like the following:
\
Now edit the files you want to customize:
# Edit the English translations vim custom-translations/en.json
\ You insert button, then when finished, insert again, escape then :wq to write and close. Example customization for magic URL email:
{ "emails.magicSession.subject": "Sign In to {{project}}", "emails.magicSession.hello": "Welcome back, {{user}}!", "emails.magicSession.buttonText": "Access My Account", "emails.magicSession.signature": "The {{project}} Team", "emails.magicSession.optionButton": "Click below to securely sign in to your {{b}}{{project}}{{/b}} account. This link expires in 1 hour." }
\
\ Or edit template structure:
vim custom-templates/email-magic-url.tpl
\
Add volume mounts to your docker-compose.yml under the appwrite service:
appwrite: image: appwrite/appwrite:1.8.0 container_name: appwrite restart: unless-stopped volumes: # Add these two lines for custom email templates - ./custom-templates:/usr/src/code/app/config/locale/templates:ro - ./custom-translations:/usr/src/code/app/config/locale/translations:ro # ... other existing volumes ... - appwrite-uploads:/storage/uploads:rw - appwrite-cache:/storage/cache:rw
The :ro flag makes them read-only for security.
\
# Recreate the appwrite container with new volumes docker compose up -d appwrite # Restart the mail worker to reload translations docker restart appwrite-worker-mails
\
Trigger a test email through the API:
curl --location 'https://your-domain.com/v1/account/sessions/magic-url' \ --header 'Content-Type: application/json' \ --header 'X-Appwrite-Project: your-project-id' \ --data '{ "userId": "unique()", "email": "test@example.com", "url": "https://your-domain.com/auth" }'
\ Check your email to see the customized template! Here is the email I received, where you can see the new updates:
Translation files use placeholders that get replaced with dynamic values:
{{project}} – Your project name{{user}} – User’s name or email{{b}}...{{/b}} – Bold text markers{{redirect}} – The action URL{{agentClient}} – Browser/client info{{agentDevice}} – Device type{{agentOs}} – Operating system{{phrase}} – Security phrase for verificationemail-magic-url.tplemails.magicSession.*email-inner-base.tplemails.recovery.*email-otp.tplemails.verification.*Problem: You updated the files, but emails still show old content.
Solution: Translations are cached in memory. Always restart both containers:
docker restart appwrite sleep 5 docker restart appwrite-worker-mails # If that still doesn’t work, do a full restart: docker compose down docker compose up -d
\
Problem: Server returns 500 error after modifying translation files.
Solution: This usually means JSON syntax error. Validate your JSON:
cat custom-translations/en.json | python3 -m json.tool
Common mistakes:
Problem: Error saying translation file not found.
Solution: Ensure you copied ALL translation files, not just the ones you modified:
# Re-copy all files docker cp appwrite:/usr/src/code/app/config/locale/translations/. ./custom-translations/
Problem: Updated Appwrite and customizations disappeared.
Solution: This shouldn’t happen with volume mounts. Verify your docker-compose.yml still has the volume mounts after the update.
For local development with the full Appwrite source code:
./app:/usr/src/code/apphttp://localhost:9503For production:
custom-templates and custom-translations directories in git\


