What you are setting up
Appwrite gives your web or mobile app its whole back end in one place: sign-in, a database, file storage, server-side functions, hosted sites, messaging and live updates. You build the app; Appwrite answers its API calls from your own machine instead of someone else's cloud.
Under the hood it is a set of Docker containers started by Docker Compose. A gateway called Traefik listens on 80 and 443 and fetches the HTTPS certificates. Since version 2.3.0 it also opens 8883 and 8084 for a built-in MQTT broker that pushes notifications to devices. The current installer, 2.6.2, uses PostgreSQL unless you pick MariaDB or MongoDB, and adds Redis plus ClickHouse for usage statistics.
Will a small server carry it?
For a first app, yes. Appwrite's installation page sets the floor at two cores, with 4 GB of memory and a 2 GB swap area. What grows beyond that is up to you. Functions run in their own containers, each language you switch on downloads its own image, and every uploaded file sits on the server unless you move storage to an S3 bucket.
| Your app does | What it leans on | The setting that keeps it in check |
|---|---|---|
| Sign-ins and database reads | CPU and memory of the API containers | _APP_WORKER_PER_CORE, 6 by default (scaling page) |
| Serverless functions | One container per run, started through Docker | _APP_COMPUTE_CPUS and _APP_COMPUTE_MEMORY, unlimited when empty |
| File uploads | Disk space in the uploads volume | Watch disk use; _APP_STORAGE_LIMIT caps each single file (30 MB by default), or move storage to an S3 device |
| Builds of functions and sites | Disk and CPU while they run | List just the languages you need in _APP_FUNCTIONS_RUNTIMES |
The two compute limits matter most: without them, one function stuck in a loop can use every core you have.
The server that fits
A whole machine suits Appwrite well, because the containers that run your functions control the host's Docker, and Docker access is the same as full control of the server. On a dedicated server that power stays with you alone: nobody else's software on the hardware, full root login, and an unmetered port, so popular downloads never come with a bandwidth bill.
- A one-processor rack server is a comfortable home for a single production app.
- A two-processor server makes sense once functions run all day, or when you want a staging copy next to production.
- Every rack server and blade comes with out-of-band console access, meaning the screen and power button keep working when a firewall mistake has locked you out of SSH. Two cartridge models, the m710x and the m510, have it as well; on the other cartridges you can switch the power and nothing more.
- For Appwrite, cross the X-Gene ARM cartridge off your list: it runs Ubuntu 16.04 or 18.04 only, older than the 22.04 minimum of Docker's packages.
A ready server is handed over within 30 minutes of approval; one built to your spec takes hours, not minutes. Choose Ubuntu 24.04 or 26.04 LTS; our Linux server page explains the images. You manage the server; we keep the hardware and the network running.
Up and running in an evening
- Get Docker on the server first, Engine plus Compose v2, using the Ubuntu guide at docs.docker.com, which also explains that ports a container publishes skip the ufw firewall.
- In your DNS, point an A record such as
api.yourapp.comat the server's IPv4. IPv6 comes at no charge, but only create an AAAA record once IPv6 is configured and answering on the server, because certificates fail against an address that does not answer. - On your laptop, open the tunnel with
ssh -L 20080:localhost:20080 you@your-server, typesudo -i, and paste the block below. It checks Docker first, refuses to touch an existing install, turns on the firewall with SSH, 80 and 443 allowed before any certificate is requested, creates a 2 GB swap file if the server has no swap yet, then launches the installer.
ver=$(docker version --format '{{.Server.Version}}' 2>/dev/null)
if [ -e /opt/appwrite/.env ]; then
echo "STOP: Appwrite is already installed in /opt/appwrite"
elif [ "${ver%%.*}" -ge 28 ] 2>/dev/null && docker compose version >/dev/null 2>&1; then
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw && ufw allow OpenSSH &&
ufw --force enable && ufw allow 80/tcp && ufw allow 443/tcp &&
if [ -z "$(swapon --noheadings)" ]; then
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile &&
swapon /swapfile && echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi &&
cd /opt && docker run -it --rm \
--env DO_NOT_TRACK=1 \
--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.6.2 ||
echo "STOP: ufw, the swap file or the installer failed; sort it out and paste again"
else
echo "STOP: install Docker Engine 28 or later with Compose v2 first (now: ${ver:-none})"
fi
Three things differ from the command in Appwrite's guide, and that is the full list: the wizard listens only on 127.0.0.1 (the guide says to open port 20080; your SSH tunnel does the job instead, and Docker 28 or later keeps loopback ports local), DO_NOT_TRACK=1 is added, and it runs from /opt. Keep the folder name appwrite, since the upgrade guide says renaming it breaks the Docker project name.
When the installer starts, it prints a link like http://localhost:20080/?secret=.... Open that full link, secret and all, in your browser; thanks to the tunnel it reaches the server, and the plain address without the secret is turned away. Enter your hostname, switch on HTTPS, keep PostgreSQL unless you have a reason not to, and put your own address as the certificate email under Advanced settings. The next screen shows your secret key once only, so copy it into your password vault right away. After that, pick the email and password you will sign in with, and press Install. When the containers are healthy, the installer creates that account for you.
Ports 80 and 443 go live when the containers start, but your account is only created once Appwrite reports healthy, and a first start can take a minute or longer. If the wizard says account creation failed, someone may have signed up first. Start over with cd /opt/appwrite && docker compose down -v && cd /opt && mv appwrite appwrite.retry-$(date +%s): that erases every Appwrite volume (still empty at this point) and moves the folder away, because the block will not run while .env exists. Then paste the block again.
About DO_NOT_TRACK=1: without it, the installer's code (Install.php, Report.php) tells cloud.appwrite.io about each fresh production install; upgrades are never reported. The message holds the version, your domain, the database, the server's IP, operating system, CPU architecture, core count and memory, and also your admin name and email except when the hostname is localhost or starts with 127. The variable stops that one report and nothing else.
Lock the doors before you build
- Make the settings file private with
chmod 600 /opt/appwrite/.env. Inside is_APP_OPENSSL_KEY_V1, the key Appwrite encrypts secrets with; swap it later and the stored passwords, API keys and OAuth secrets can no longer be read, its security guide warns. - Nobody else can create a console account by default (
_APP_CONSOLE_WHITELIST_ROOTis enabled); teammates join by invitation, inside the one organization the self-hosted console sets up for you. - HTTPS sorts itself out: on the first visit to your hostname Appwrite gets a Let's Encrypt certificate over port 80 and renews it during its daily maintenance run.
- Not using MQTT push? We suggest that in
.envyou give the WebSocket variable_APP_MQTT_WSS_PORTthe value127.0.0.1:8084, and_APP_MQTT_PORTthe value127.0.0.1:8883. Those ports then stop facing the internet, something ufw cannot do for Docker-published ports on IPv4. - Add your mail server's SMTP details so verification and password-reset emails arrive.
- Each time you edit
.env, apply it withcd /opt/appwrite && docker compose up --detach --force-recreate;docker compose exec appwrite varsin that folder lists what is actually in use.
Keep a copy you can bring back
Appwrite's backup guide asks you to save your data in the database, the files in its volumes, and the .env settings, with Appwrite shut down while volumes are copied. Its dump commands cover MariaDB and MongoDB only, so we suggest the simpler route below: a short nightly stop, then one archive for each Appwrite volume and one for the /opt/appwrite folder. Paste it as root.
install -d -m 700 /srv/appwrite-backups /var/log/appwrite-backups
cat > /usr/local/sbin/appwrite-backup.sh <<'EOF'
umask 077
dc="docker compose -f /opt/appwrite/docker-compose.yml"
out=/srv/appwrite-backups/$(date +%F-%H%M%S)
mkdir "$out" || exit 1
list=$(docker volume ls -q --filter name=appwrite)
[ -n "$list" ] || { echo "no Appwrite volumes"; exit 1; }
$dc stop || exit 1
good=yes
tar -czf "$out/config.tar.gz" -C /opt appwrite || good=no
for vol in $list; do
docker run --rm -v "$vol":/from:ro -v "$out":/to ubuntu \
tar -czf "/to/$vol.tar.gz" -C /from . || good=no
done
$dc start
for a in "$out"/*.tar.gz; do tar -tzf "$a" >/dev/null || good=no; done
if [ "$good" != yes ]; then echo "backup FAILED, see $out"; exit 1; fi
echo "$list" > "$out/volumes.txt" && touch "$out/COMPLETE" && echo "backup ok: $out" &&
[ -z "$NO_PRUNE" ] && find /srv/appwrite-backups -mindepth 1 -maxdepth 1 -type d -mtime +7 -exec rm -rf {} +
EOF
printf '%s\n' '15 4 * * * root bash /usr/local/sbin/appwrite-backup.sh > /var/log/appwrite-backups/last.log 2>&1' > /etc/cron.d/appwrite-backup
Every run gets a folder named down to the second. It only earns its COMPLETE mark when each archive reads back cleanly, and older sets are cleared only after a good run. Copy /srv/appwrite-backups to a second machine with rsync -a, never with --delete, and clear old copies over there. Treat the copies like passwords: they contain your secret key.
To bring a set back, put its folder name in SET and paste the next block as root. If Appwrite is installed, it first makes a fresh safety copy, refusing to go on without one, then parks the current folder under a time-stamped name, so you can always undo. On a brand-new server, prepared as above with the set copied in, it restores straight away and resets ownership of the settings folder to root.
SET=/srv/appwrite-backups/PUT-THE-FOLDER-NAME-HERE
APP=/opt/appwrite; go=no
if [ -e "$SET/COMPLETE" ] && ( for a in "$SET"/*.tar.gz; do tar -tzf "$a" >/dev/null || exit 1; done ); then go=yes; fi
if [ $go = yes ] && [ -e "$APP/.env" ]; then
go=no
NO_PRUNE=1 bash /usr/local/sbin/appwrite-backup.sh | tee /root/before-restore.log | grep -q '^backup ok:' &&
docker compose -f "$APP/docker-compose.yml" down &&
mv "$APP" "$APP.previous-$(date +%F-%H%M%S)" && go=yes
fi
if [ $go = yes ] && [ ! -e "$APP" ]; then
[ -e "$SET/COMPLETE" ] && tar -xzpf "$SET/config.tar.gz" -C /opt && chown -R root:root "$APP" && chmod 600 "$APP/.env" &&
docker compose -f "$APP/docker-compose.yml" up --no-start &&
( for vol in $(cat "$SET/volumes.txt"); do
docker volume create "$vol" >/dev/null &&
docker run --rm -e VOL="$vol" -v "$vol":/into -v "$SET":/from:ro ubuntu bash -c \
'find /into -mindepth 1 -delete && tar -xzpf "/from/$VOL.tar.gz" -C /into' || exit 1
done ) &&
docker compose -f "$APP/docker-compose.yml" up -d && echo "restore ok" ||
echo "RESTORE FAILED: undo it with the safety copy named in /root/before-restore.log"
else
echo "STOP: nothing was restored; read the lines above"
fi
Changed your mind? Run it again with SET pointing at the safety copy named in /root/before-restore.log. If a restore broke off after unpacking the settings, rename the half-finished /opt/appwrite yourself before trying again.
Moving to a new version safely
An upgrade has two halves: the installer image pulls the new containers, and a migrate command reshapes your data. Take a backup first (bash /usr/local/sbin/appwrite-backup.sh | grep '^backup ok:' must print a line), open the SSH tunnel again, and from /opt repeat the installer's docker run command, this time with --entrypoint="upgrade" and the exact new tag, then open the ?secret= link it prints; Appwrite asks you never to use latest in production. When every appwrite/appwrite container in docker ps carries the new version, finish with cd /opt/appwrite && docker compose exec appwrite migrate.
Read the release notes of every version you pass. Version 2.6.2 can be reached in one step from 1.9.6 or newer; from 1.8.x the guide's route is 1.9.5, 1.9.10, then 2.6.2. Patch releases need no migration unless their notes say so.
When to choose something else
Appwrite itself steers teams without DevOps experience to its hosted cloud, and notes that running it yourself frequently ends up dearer when your hours count too. One server is also one point of failure; to spread Appwrite over several machines, its Helm chart runs on Kubernetes on bare metal. If your app is built around Postgres and SQL, read our self-hosted Supabase guide too. Licence, in our general reading and not legal advice: Appwrite is BSD 3-Clause, so if you redistribute it, its copyright notice and licence must travel with it; there is no duty to share source code.
Questions people ask
- Is a 4 GB server enough for Appwrite?
- It matches the documented floor (two cores, 4 GB of memory, a 2 GB swap area), which is fine for trying it out or a small app. Busy functions and large uploads need more, so watch memory and disk once real users arrive.
- Do I have to create the admin account in the browser afterwards?
- No. The wizard asks for your email and password before installing and creates the account once the containers are healthy. After that, only invited people can join the console.
- Is the secret key really that important?
- Yes. Appwrite's security guide is blunt: replace
_APP_OPENSSL_KEY_V1and the passwords, API keys and OAuth secrets already stored become unusable. Keep the key in your password manager and in your backups of.env. - Can I jump straight from an old release to the newest one?
- Only from 1.9.6 or newer to 2.6.2. Older installs climb the ladder of releases in the upgrade guide, running the migrate command after each, with a backup before every step.
- Will my server be slow while the nightly backup runs?
- Appwrite is stopped while the archives are written, because the backup guide asks for volumes to be copied with the app shut down. Pick a quiet hour for the cron line, and expect the pause to grow with your stored files.