The gaps between the quickstart and production: query indexes, storage permissions, function cold starts, and the self-hosting gotcha everyone hits.
Appwrite gives you a complete backend in one platform — authentication, databases, file storage, serverless functions, real-time subscriptions, and messaging. One SDK. One dashboard. No stitching together five different services.
This post covers building a real full-stack app with Appwrite from scratch — and the production gaps the quickstart skips.
What Appwrite Actually Gives You
Before touching code, understand what you get out of the box:
- Auth — email/password, OAuth (Google, GitHub, Discord, 30+ providers), magic links, phone OTP, anonymous sessions
- Databases — document-based with relations, indexes, and real-time subscriptions
- Storage — file buckets with permission control, image transformations, virus scanning
- Functions — serverless functions triggered by events, schedules, or HTTP
- Messaging — push notifications, email, SMS through one API
- Realtime — subscribe to any database or storage change over WebSocket
All of this is self-hostable via Docker Compose, or available on Appwrite Cloud.
Setup
# Install the Appwrite CLI
npm install -g appwrite-cli
# Log in to your Appwrite project
appwrite login
# Or use the SDK directly in your project
npm install appwrite # Browser/React/Vue/Svelte
npm install node-appwrite # Node.js server-side
Initialize your client:
// lib/appwrite.js
import { Client, Account, Databases, Storage } from "appwrite";
const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT) // 'https://cloud.appwrite.io/v1' or your self-hosted URL
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID);
export const account = new Account(client);
export const databases = new Databases(client);
export const storage = new Storage(client);
Authentication — The Right Way
// Sign up
async function signUp(email, password, name) {
const user = await account.create(
ID.unique(), // Auto-generate user ID
email,
password,
name
);
return user;
}
// Sign in
async function signIn(email, password) {
const session = await account.createEmailPasswordSession(email, password);
return session;
}
// Get current user
async function getCurrentUser() {
try {
return await account.get();
} catch {
return null; // No active session
}
}
// Sign out
async function signOut() {
await account.deleteSession('current');
}
OAuth (Google example):
async function signInWithGoogle() {
account.createOAuth2Session(
OAuthProvider.Google,
'https://yourapp.com/auth/callback', // Success redirect
'https://yourapp.com/auth/failure' // Failure redirect
);
// Redirects the user — no return value
}
Databases — Queries, Indexes, and the Performance Trap
Appwrite databases are document-based with attribute-level querying. Here’s the critical thing most developers miss: queries without indexes do full collection scans.
Create a collection and add indexes via the dashboard or CLI — not just the SDK.
import { Databases, ID, Query } from "appwrite";
const DATABASE_ID = 'main';
const COLLECTION_ID = 'posts';
// Create a document
async function createPost(title, content, authorId) {
return databases.createDocument(
DATABASE_ID,
COLLECTION_ID,
ID.unique(),
{
title,
content,
authorId,
createdAt: new Date().toISOString(),
published: false,
}
);
}
// Query documents — always use indexed attributes
async function getPostsByAuthor(authorId) {
return databases.listDocuments(DATABASE_ID, COLLECTION_ID, [
Query.equal('authorId', authorId), // Index this attribute
Query.equal('published', true), // Index this attribute
Query.orderDesc('createdAt'), // Index this attribute
Query.limit(20),
]);
}
Add indexes in the Appwrite Console:
Go to your collection → Indexes tab → Add Index for every attribute you query on. Without indexes, queries slow down dramatically as your collection grows.
Relationships:
// Get posts with author data in one query
async function getPostWithAuthor(postId) {
return databases.getDocument(DATABASE_ID, COLLECTION_ID, postId, [
Query.select(['$id', 'title', 'content', 'createdAt', 'author.*'])
]);
}
Storage — Permissions Are Not Optional
Appwrite storage uses bucket-level and file-level permissions. The default: no one can access anything. You must set permissions explicitly.
import { Storage, ID, Permission, Role } from "appwrite";
// Upload a file with permissions
async function uploadAvatar(file, userId) {
return storage.createFile(
'avatars', // Bucket ID
ID.unique(),
file,
[
Permission.read(Role.any()), // Anyone can view avatars
Permission.update(Role.user(userId)), // Only the owner can update
Permission.delete(Role.user(userId)), // Only the owner can delete
]
);
}
// Get a file preview URL (with transformations)
function getAvatarUrl(fileId) {
return storage.getFilePreview(
'avatars',
fileId,
200, // Width
200, // Height
'center', // Gravity
90 // Quality
);
}
Common permission patterns:
// Public read, authenticated write
Permission.read(Role.any())
Permission.write(Role.users())
// Owner-only access
Permission.read(Role.user(userId))
Permission.write(Role.user(userId))
// Team-based access
Permission.read(Role.team('admins'))
Permission.write(Role.team('admins'))
Functions — Cold Starts and the Right Use Cases
Appwrite Functions are serverless — they spin up on demand. Cold start time is typically 200-800ms depending on your runtime and function size. Here’s what to know:
Supported runtimes: Node.js, Python, PHP, Ruby, Dart, Swift, Kotlin, Java, .NET, C++
// functions/send-welcome-email/src/main.js
import { Client, Users } from 'node-appwrite';
export default async ({ req, res, log, error }) => {
// req.body contains the trigger payload
// For event triggers: req.body is the event data
const client = new Client()
.setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT)
.setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID)
.setKey(process.env.APPWRITE_API_KEY);
const users = new Users(client);
try {
// Get the user who triggered the event
const userId = req.body.$id;
const user = await users.get(userId);
// Send welcome email via your email provider
await sendEmail({
to: user.email,
subject: 'Welcome!',
template: 'welcome'
});
return res.json({ success: true });
} catch (err) {
error(`Failed to send welcome email: ${err.message}`);
return res.json({ success: false }, 500);
}
};
Trigger a function on user creation (in appwrite.json):
{
"functions": [{
"name": "send-welcome-email",
"runtime": "node-18.0",
"events": ["users.*.create"],
"execute": ["any"]
}]
}
Reducing cold starts:
- Keep function bundles small — don’t import unnecessary packages
- Use
node-appwritenotappwrite(server SDK is smaller) - Enable function warm instances on Appwrite Cloud (Pro plan)
Real-Time Subscriptions
Appwrite’s realtime lets you subscribe to any database or storage change:
import { Client, RealtimeResponseEvent } from "appwrite";
const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID);
// Subscribe to all changes in a collection
const unsubscribe = client.subscribe(
`databases.${DATABASE_ID}.collections.${COLLECTION_ID}.documents`,
(response: RealtimeResponseEvent<any>) => {
if (response.events.includes('databases.*.collections.*.documents.*.create')) {
console.log('New document:', response.payload);
}
if (response.events.includes('databases.*.collections.*.documents.*.update')) {
console.log('Updated document:', response.payload);
}
}
);
// Unsubscribe when component unmounts
// unsubscribe();
Self-Hosting — The Docker Gotcha
The most common self-hosting issue: environment variables not set before first run. Appwrite generates secrets on first startup — if you change them after data exists, you’ll lose access to encrypted data.
Before running docker compose up for the first time:
# Copy the example env file
cp .env.example .env
# Set these before first run — don't change after
APPWRITE_SECRET=<random-64-char-string>
_APP_OPENSSL_KEY_V1=<random-32-char-string>
# Set your domain
_APP_DOMAIN=appwrite.yourdomain.com
_APP_DOMAIN_TARGET=appwrite.yourdomain.com
# Email (required for auth emails)
_APP_SMTP_HOST=smtp.resend.com
_APP_SMTP_PORT=587
_APP_SMTP_USERNAME=resend
_APP_SMTP_PASSWORD=<your-resend-api-key>
_APP_SYSTEM_EMAIL_ADDRESS=noreply@yourdomain.com
Start the stack:
docker compose up -d
First run takes 2-3 minutes as Appwrite initializes the database schema and generates SSL certificates. Don’t interrupt it.
Production Checklist
- [ ] Indexes added for every attribute used in queries
- [ ] Bucket permissions configured — default is deny all
- [ ] Document permissions set — default is deny all
- [ ] Function bundle size minimized to reduce cold starts
- [ ] Self-hosted: env vars set before first
docker compose up - [ ] Self-hosted: SMTP configured before testing auth emails
- [ ] Self-hosted:
_APP_OPENSSL_KEY_V1stored securely — losing it means losing encrypted data - [ ] Rate limits configured for auth endpoints to prevent abuse
If you’re building on Appwrite and hitting issues — query performance, permission configuration, function cold starts, self-hosting setup — drop a comment. I’ll answer.
Disclosure: This post was produced by AXIOM, an agentic developer advocacy workflow powered by Anthropic’s Claude, operated by Jordan Sterchele. Human-reviewed before publication.
Top comments (0)