Skip to content
Kevo Rojas

Self-hosted Appwrite with Docker: setup, permissions and a real-time app

KR
Kevo Rojas
Oct 15, 2026 · 17 min read
Tutorial video

Playing loads YouTube (Google), which may store data in your browser and log the playback. PrivacyWatch on YouTube (opens in a new tab)

I install Appwrite with one Docker command and build a real-time wall with login, photos and Sites. Permissions explained, plus the common errors.

In short: Appwrite is an open source backend you can install yourself: login, database, files, real-time, functions and hosting in one place. I see it as an open source alternative to Firebase. In this guide I install it self-hosted with one Docker command on my computer, set up the backend from the console and connect an example app: a message wall built with React and Vite where two users post text and photos that show up instantly, which I then publish with Appwrite Sites. The most important part is permissions: I explain how to set them up right.

This article goes with the video above (in Spanish). Everything is here step by step, with commands and code ready to copy. The example app is in the repository: kevorojas/appwrite-muro.

Tested versions: Appwrite 2.4.0 self-hosted, appwrite web SDK 28, React 19 and Vite 8, on macOS with Docker Desktop. The official installation guide already shows 2.5.0: the process should be the same, but I didn't test it, and some console labels may change.

What is Appwrite?

Appwrite is an open source backend platform. It gives you the pieces almost every app needs and nobody wants to write from scratch:

  • Auth: sign-up, login and sessions (email and password, and other methods).
  • Databases: databases with tables, rows and columns.
  • Storage: buckets for files, like photos.
  • Realtime: tells your app instantly when something changes.
  • Functions: your own code running on the server.
  • Messaging: emails, SMS and push notifications.
  • Sites: frontend hosting, similar to Vercel or Netlify.

Its official tagline is "The open-source cloud for agents & developers". The Firebase comparison is mine, because it covers many of the same needs. The difference I care about: you can install Appwrite on your own server or on your computer. If you'd rather not run anything, there's also Appwrite Cloud.

It also ships an MCP server, so an AI agent like Claude can work with your project. That's for another guide.

What you need before starting

  • Docker (on Mac, Docker Desktop) with Docker Compose v2.
  • Node.js 20 or later, for the example app.
  • Memory: the docs ask for 2 CPU cores, 4 GB of RAM and 2 GB of swap. On my Mac, idle Appwrite uses about 2 GB of memory and around 5% CPU.
  • Ports 80 and 443 free. Make sure no other app is using them before you install.
  • A browser with an incognito window, to test with two users at once.

Operating system: I tested everything on macOS. Linux should be the same; on Windows the install command is different (the official docs have the PowerShell version). I didn't test outside the Mac.

Installing self-hosted Appwrite with Docker

Create a working folder and run the installer. This is the command I used, pinned to 2.4.0:

mkdir -p appwrite-prueba && cd appwrite-prueba

docker run -it --rm \
    --publish 127.0.0.1:20080:20080 \
    --volume /var/run/docker.sock:/var/run/docker.sock \
    --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
    --entrypoint="install" \
    appwrite/appwrite:2.4.0

What each part does:

  • --publish 127.0.0.1:20080:20080: the setup wizard stays on your computer, on port 20080, and isn't exposed to your network.
  • /var/run/docker.sock: the installer needs to talk to your Docker to start the containers.
  • "$(pwd)"/appwrite: this is where your installation's docker-compose.yml and .env end up. You'll need them if you ever change the port.

For the latest version, replace 2.4.0 with the one in the official guide (today, 2.5.0).

The web wizard

The terminal prints an address like http://localhost:20080/?secret=.... Open it in the browser. That secret is private: don't share it or show it on screen.

I left almost everything at the defaults:

  1. Hostname: localhost, no HTTPS (it's a local install).
  2. Database: I picked Postgres.
  3. Advanced settings: defaults (HTTP on 80, HTTPS on 443).
  4. Secret key: keep it somewhere safe. It's shown only once.
  5. Admin account: your email and a password.

When you confirm, the installer starts 17 containers (on 2.4.0; other versions may differ).

The first time is slow. Docker has to download every image: for me it took about 20 minutes. In the video it takes seconds because I already had them.

What the 17 containers are

You don't need to know them one by one. This is the idea:

  • Traefik is the front door: it takes everything that reaches port 80 and routes it.
  • Appwrite's API serves your app and the console.
  • Postgres stores the data and Redis works as cache and queue.
  • Realtime keeps the WebSockets that announce changes.
  • Several workers do background work (emails, deletes, stats, builds).
  • The executor runs your functions and builds your sites.

To see them:

docker ps --format "table {{.Names}}\t{{.Status}}"

The console and the project

Open http://localhost and sign in with the account from the wizard. The installation comes with a sample project. I renamed it to Muro ("wall" in Spanish) in Settings; you can also create a new one.

The left menu has the sections you'll use: Auth, Databases, Storage and Sites. There's also Functions, Messaging and more.

Copy the Project ID (it's in Overview and in Settings). You'll need it in the app's .env.

Setting up the wall's backend

The example app expects these names (they're in Spanish because the app is). If you use other IDs, change them in the .env.

1. Email and password login

Auth → Settings: check that Email/Password is enabled. It is by default.

2. Register the Web platform

Appwrite only accepts requests from origins you've registered. Skip this step and the browser blocks the calls, and the app doesn't work.

Overview → Add platform → Web:

  • Framework: React
  • Name: Muro local
  • Hostname: localhost (covers http://localhost:5173, where Vite runs)

The wizard then offers a prompt for your AI agent and the steps to connect a new app. This app doesn't need them: it's already built.

3. Database and mensajes table

  1. Databases → Create database. Name: Muro · Database ID: muro (type it instead of keeping the generated one).
  2. Create table. Name: Mensajes · Table ID: mensajes ("messages").
  3. Under Columns, create these four. For Type, pick Varchar: the default type is Text, which has no size.
ColumnTypeSizeRequiredWhat for
textoVarchar500YesThe message text
autorIdVarchar36YesID of the user who wrote it
autorNombreVarchar128YesName shown on the wall
fotoIdVarchar36NoID of the photo in the bucket, if any

You don't need a date column: Appwrite adds $createdAt to every row.

4. The fotos bucket

  1. Storage → Create bucket. Name: Fotos · Bucket ID: fotos ("photos").
  2. In the bucket's Settings:
    • Maximum file size: 5 MB (the app also checks it before uploading).
    • Allowed file extensions: jpg, jpeg, png, webp, gif.

Table and bucket permissions go in the next section, because they're the easiest part to get wrong.

Appwrite permissions: how to set them up right

The rule to understand: a table's permissions apply to all its rows, and a bucket's to all its files. If you give Delete to all users on the table, any user can delete any message, even if the app hides the button.

For a wall, this is what you want:

  • Any signed-in user can post and can read every message and photo.
  • Only the author can edit or delete their own.

The way to get there: the table and the bucket only allow creating, and each row and each file carries its own permissions.

The mensajes table

In the table's Settings (or Security) tab:

  1. Add role → All users and check only Create. Save with that section's Update button.
  2. Turn on Row level security and save with its own Update button.
  3. Check that both Update buttons are grey. White means unsaved changes.

The fotos bucket

Same thing, in the bucket's Settings:

  1. All users with only Create → Update.
  2. File level security on → Update.
  3. Both buttons grey. If in doubt, refresh the page and check the toggle is still on.

Don't add Read or Delete to the bucket. The console text suggests that with File security you need permissions on both the bucket and the file. Following that, in one test I gave the bucket Read and Delete, but the File security toggle hadn't been saved. Result: any user could delete other people's photos, and I deleted a real test photo with a different user. With only Create on the bucket and File security saved, each file's permissions are enough.

The app sets each row's and file's permissions

With Row level security and File level security on, Appwrite allows an action if the table (or bucket) or the row (or file) allows it. The app creates every message and photo with these permissions, in src/lib/appwrite.js:

const permisos = [
  Permission.read(Role.users()),               // any signed-in user can read it
  Permission.update(Role.user(usuario.$id)),   // only the author can edit it
  Permission.delete(Role.user(usuario.$id)),   // only the author can delete it
]

What about Read on the table? You don't need it, because every row already carries read(users). If you add it, this wall still works the same, but reading stops depending on the row: the day you want private messages, that table permission would make them visible to everyone.

A note on "All users": according to Appwrite's docs, Role.users() also includes anonymous sessions. This app doesn't use them. If you don't either, check that the Anonymous method is off under Auth → Settings.

Test it with the API, not the app

The app hides the delete button on other people's messages, but that's convenience: security comes from the permissions. To check, create a third user from the terminal and try to delete someone else's message. Replace PROJECT_ID and ID_DEL_MENSAJE (copy it from the table in the console):

P=PROJECT_ID
curl -s -c /tmp/intruso -H "X-Appwrite-Project: $P" -H "Content-Type: application/json" \
  -X POST http://localhost/v1/account \
  -d '{"userId":"unique()","email":"intruso@prueba.com","password":"intruso-2026","name":"Intruso"}' > /dev/null

curl -s -b /tmp/intruso -c /tmp/intruso -H "X-Appwrite-Project: $P" -H "Content-Type: application/json" \
  -X POST http://localhost/v1/account/sessions/email \
  -d '{"email":"intruso@prueba.com","password":"intruso-2026"}' > /dev/null

curl -s -b /tmp/intruso -H "X-Appwrite-Project: $P" \
  -X DELETE http://localhost/v1/tablesdb/muro/tables/mensajes/rows/ID_DEL_MENSAJE

With the permissions set right, the answer is a 401: "The current user is not authorized to perform the requested action.". That's how I tested it before recording: every signed-in user sees messages and photos, nobody sees anything without a session, nobody deletes other people's content and the author can delete their own.

If you followed the video

In the recording, the table and bucket permissions ended up different. These are the right ones:

  • mensajes table: All users with Create only, and Row level security on.
  • fotos bucket: All users with Create only, and File level security on.

Each message and each photo carries its own permissions, set by the app when it creates them: read for signed-in users, and update and delete only for the author.

Connecting the app

Clone the repo and create the .env:

git clone [kevorojas/appwrite-muro](https://github.com/kevorojas/appwrite-muro)
cd appwrite-muro
cp .env.example .env

Fill in the .env with the Project ID you copied:

VITE_APPWRITE_ENDPOINT=http://localhost/v1
VITE_APPWRITE_PROJECT_ID=<your-project-id>
VITE_APPWRITE_DATABASE_ID=muro
VITE_APPWRITE_TABLE_ID=mensajes
VITE_APPWRITE_BUCKET_ID=fotos

These IDs aren't secrets: they end up in the JavaScript the browser downloads. What protects the data is the permissions. The .env stays out of Git anyway.

npm install
npm run dev

The app runs on http://localhost:5173.

The lines that talk to Appwrite

Everything that touches Appwrite is in src/lib/appwrite.js. The React components only call its functions. The essentials:

The client, which knows which server and project to talk to:

const client = new Client()
client.setEndpoint(ENDPOINT).setProject(PROJECT_ID)

const account = new Account(client)    // users and sessions
const tablesDB = new TablesDB(client)  // tables and rows
const storage = new Storage(client)    // files
const realtime = new Realtime(client)  // live events

Posting: first upload the photo (if there is one), then create the row, both with the permisos from the previous section:

const archivo = await storage.createFile({
  bucketId: BUCKET_ID,
  fileId: ID.unique(),
  file: foto,
  permissions: permisos,
})

await tablesDB.createRow({
  databaseId: DATABASE_ID,
  tableId: TABLE_ID,
  rowId: ID.unique(),
  data: { texto, autorId: usuario.$id, autorNombre: usuario.name, fotoId: archivo.$id },
  permissions: permisos,
})

Real-time: the app subscribes to the table's rows channel and reacts when someone posts or deletes:

const canalMensajes = Channel.tablesdb(DATABASE_ID).table(TABLE_ID).row()

realtime.subscribe(canalMensajes, (evento) => {
  const fila = evento.payload
  if (evento.events.some((e) => e.endsWith('.create'))) alCrear(fila)
  if (evento.events.some((e) => e.endsWith('.delete'))) alBorrar(fila)
})

Appwrite only sends events for rows the user is allowed to read. One more reason to get permissions right.

Since Appwrite 1.8 the database uses the TablesDB API: tables, rows and columns. Before, they were collections, documents and attributes. If you find tutorials using databases.createDocument, they predate the change.

Testing it with two users

  1. Open http://localhost:5173 and create a user.
  2. Open an incognito window (or another browser), create a second user and put both windows side by side.
  3. Post from one: the message shows up instantly in the other, without reloading.
  4. Post with a photo.
  5. Notice the Borrar (delete) button only shows on your own messages. If you delete one, it disappears from both windows.

The console shows what happened: both users under Auth → Users, the messages in Databases → muro → mensajes and the photos in Storage → fotos. The authentication backend, the database and the file storage were already built: you only connected them.

Publishing the app with Appwrite Sites

Appwrite also hosts the frontend. The usual way is to connect a GitHub repository, but GitHub can't reach an Appwrite running on your computer. So I upload it already built:

  1. Build and package it. The script uses the values in your .env, because Vite bakes them into the JavaScript at build time:

    npm run empaquetar   # npm run build + tar -czf sitio.tar.gz -C dist .
    
  2. Sites → Create site and pick the manual upload (don't connect GitHub):

    • Site name: Muro
    • Framework: Other
    • Install command and Build command: empty (the app is already built)
    • Output directory: ./
    • Fallback file: index.html
    • Environment variables: none
  3. Upload sitio.tar.gz and deploy.

  4. Register the site's domain as another Web platform (Overview → Add platform → Web), with hostname muro.sites.localhost. I didn't check whether Appwrite already accepts its own Sites domain without this step; adding it doesn't hurt.

  5. Open http://muro.sites.localhost. Chrome and Firefox resolve *.localhost on their own.

On the site's page you can also add a custom domain, see request logs and change the build settings.

Common problems

The most common errors and how to fix them:

SymptomCauseFix
The console doesn't open on http://localhost, or something else opensAnother app is using port 80 or 443Make sure those ports are free before installing Appwrite
The app says "Falta registrar la plataforma web" or "No hay conexión con Appwrite" (missing web platform / no connection); the browser console shows a CORS errorYou didn't register the Web platform with the app's hostnameOverview → Add platform → Web with localhost (and later with the Sites domain)
The install sits on "Starting Docker containers" for a long timeThe first time it downloads every imageWait: about 20 minutes, in my case
A permission or toggle "goes back" to how it wasEach Security section has its own Update buttonSave each section and check the button turns grey
Photos upload but don't showFile level security off and no Read on the bucketTurn File security on and save it (see the permissions section)

Appwrite's ports

Appwrite uses ports 80 and 443. Before installing it, make sure they're free: if another app is using them, the console doesn't open or something else opens.

The Update button that doesn't save

Each Security section (the permissions and the Row or File security toggle) has its own Update button. The only signal is the color: white means unsaved changes; grey, saved. The video shows the bucket case: the File security toggle didn't save on the first try. After setting permissions, refresh the page and check everything is still as you left it.

Stopping or removing the installation

To stop Appwrite without losing anything:

cd appwrite-prueba/appwrite
docker compose stop

To remove everything (account, projects, data and files):

cd appwrite-prueba/appwrite
docker compose down -v

Careful: -v deletes the volumes, and all your data with them. The Docker images stay, so a reinstall doesn't take 20 minutes again. If you won't use it anymore, remove them from Docker Desktop or with docker image rm.

Frequently asked questions

How much memory does Appwrite need? The docs ask for 2 CPU cores, 4 GB of RAM and 2 GB of swap. On my Mac, idle, it uses about 2 GB of memory and around 5% CPU.

Is self-hosted Appwrite production-ready? It can be, but this guide is about a local install. For production you need at least: a domain with HTTPS, backups of the data and files, a plan for upgrading versions, and not leaving the installer or the console exposed. I don't run Appwrite in production.

Self-hosted or Appwrite Cloud? Self-hosted if you want to learn, test locally or keep your data on your server, and you're willing to maintain it. Cloud if you want to use it without managing servers. The app code is the same: only the endpoint in the .env changes.

Do I need to know React to follow the guide? No. The app is already built: you only fill in the .env. What matters is the Appwrite setup, and that's the same for any framework.

Next steps

With this you have a full backend running on your computer: login, database, files, real-time and hosting. What I still want to explore is Functions, Messaging and the MCP server, so an AI agent can work directly with the project.

If you like the idea of practicing on your computer before going to the cloud, in this guide I use Floci to emulate Azure with Terraform, also with Docker.

The app's code is at kevorojas/appwrite-muro. And if you'd like to see Appwrite published on a real server, leave it in the video's comments.

Next step

What I recommend reading after this one.

Azure

Local Azure emulator: how to use Floci with Terraform (what works and what doesn't)

Floci emulates Azure on your computer. I use it with Terraform: a real PostgreSQL, a VM that doesn't exist and AKS on k3s. No Azure account or card.

23 min read
Follow me

If this helped, help me get to 500 YouTube subscribers

I upload tutorials on DevOps tools as I try them.

Watch this tutorial's video on YouTube(opens in a new tab)

Prefer email?

Get the next tutorial in your inbox when it's out.

I only use your email for the newsletter. Privacy policy