What the Docker install runs
Appwrite is a backend platform: auth, databases, storage, functions, sites, messaging and realtime behind one API and a web console. The Docker install is a Compose project: by our count of the 2.6.2 file, 18 containers start in the default PostgreSQL layout with combined workers. Traefik 3.6 takes ports 80 and 443 and, since 2.3.0, 8883 and 8084 for the MQTT broker that delivers push notifications to devices. Behind it: the API, console, realtime server, worker, schedulers, Redis, ClickHouse for usage data and the database.
The 2.6.2 wizard offers PostgreSQL (the default), MariaDB or MongoDB. Functions and sites run through openruntimes-executor and an orchestrator, and both mount /var/run/docker.sock: they start runtime containers on the host Docker. Anything with that socket is root on the box, which is one reason this stack belongs on hardware nobody else shares.
Sizing the machine
| Item | Figure | Source and scope |
|---|---|---|
| CPU | 2 cores | Installation page, minimum |
| Memory | 4 GB RAM plus 2 GB swap | Installation page, minimum |
| Compose | Docker Compose version 2 | Installation page |
| Workers per core | 6 (default of _APP_WORKER_PER_CORE) | Scaling page; affects the API, realtime and executor |
| Per-function limits | none unless _APP_COMPUTE_CPUS / _APP_COMPUTE_MEMORY are set | Environment variables page |
Above that floor the load is yours to measure: every enabled runtime is an image on disk, every build and execution a container, and uploads land in the appwrite-uploads volume unless storage points at S3. Enable only the runtimes you use in _APP_FUNCTIONS_RUNTIMES and set the two compute limits so one runaway function cannot take every core. The migration command is multi-threaded, so cores also shorten upgrades.
Which of our machines fit
Any x86 line on the dedicated server range clears the minimum. A single-socket rack server is the plain choice for one production instance; a dual-socket machine (two Xeon E5 or two Gold 6130 chips) makes sense when functions and builds are busy or when staging runs beside production. Every racked single-, dual- and quad-socket server has an out-of-band console (iLO, iDRAC or a Supermicro BMC), which matters the first time a bad ufw rule locks out SSH. Of the Moonshot cartridges only the m510 and m710x have a console; the others have power control only.
Leave out the X-Gene ARM cartridge: it boots Ubuntu 16.04 and 18.04 only, and Docker's apt repository supports Ubuntu 22.04 and newer. Install Ubuntu 24.04 or 26.04 LTS from the control panel; the Linux server page covers the Linux installs. A racked server is handed to you in about 30 minutes, counted from payment approval; a build to order takes 4 to 24 hours. Unmetered ports have no transfer allowance, so storage downloads never become an overage line.
Installing with the wizard
- Put Docker Engine plus the Compose v2 plugin on the box with Docker's apt packages, as the docs.docker.com Ubuntu page shows. That page warns that published container ports bypass ufw.
- Point an A record for your Appwrite hostname at the server. Publish AAAA as well only when you took IPv6 at checkout (no default build includes it) and configured it, or Let's Encrypt may try a dead address.
- Connect with
ssh -L 20080:127.0.0.1:20080 you@server, runsudo -iand paste this block. It halts unless Docker Engine reports version 28+, will not touch an existing install, opens SSH, 80 and 443 in ufw before Traefik asks for a certificate, adds 2 GB of swap if there is none, and starts the installer.
v=$(docker version --format '{{.Server.Version}}' 2>/dev/null)
if ! [ "${v%%.*}" -ge 28 ] 2>/dev/null || ! docker compose version >/dev/null 2>&1; then
echo "STOP: needs Docker Engine 28+ and the Compose v2 plugin (found: ${v:-none})"
elif [ -e /opt/appwrite/.env ]; then
echo "STOP: /opt/appwrite already holds an installation"
else
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw &&
ufw allow OpenSSH && ufw --force enable && ufw allow 80,443/tcp &&
{ [ -n "$(swapon --noheadings)" ] || { fallocate -l 2G /swapfile && chmod 600 /swapfile &&
mkswap /swapfile && swapon /swapfile && echo '/swapfile none swap sw 0 0' >> /etc/fstab; }; } &&
cd /opt && docker run -it --rm \
-e 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: a setup step (ufw, swap) or the installer failed; fix it and paste again"
fi
Our changes to the documented command, all of them: the wizard is published on 127.0.0.1:20080 instead of all interfaces (the docs say to open 20080 in the firewall; on Engine 28+ a loopback binding stays local, so the SSH tunnel replaces that), DO_NOT_TRACK=1 is set, and the run happens in /opt so the files land in /opt/appwrite. Keep the folder named appwrite: the upgrade guide warns that another name breaks the Docker project name.
The installer prints an address of the form http://localhost:20080/?secret=...; open that exact address, secret included, in your local browser (the tunnel carries it). Without the secret the wizard answers only with an installer-secret error. Step 1 takes the hostname, the HTTPS switch and the database; under Advanced settings set the certificate email to your own. Step 2 shows the generated secret key once: save it outside the server before going on. Step 3 sets your console email and password, and step 4 installs. The installer creates that account itself, after the stack is healthy.
Traefik publishes 80 and 443 on all addresses as soon as the stack starts, and the installer creates your account only once the API reports healthy, which on a first boot can take a minute or more. If the wizard reports that account creation failed, assume someone else signed up first. Wipe and clear the folder, since the block refuses to run over an existing .env: docker compose -f /opt/appwrite/docker-compose.yml down -v && mv /opt/appwrite /opt/appwrite.failed-$(date +%F-%H%M%S) (down -v deletes all Appwrite data, which is nothing yet), then paste the block again.
DO_NOT_TRACK=1 covers one thing: the report that Install.php sends to cloud.appwrite.io after a fresh production install (Report.php never sends one for an upgrade). It carries the version, domain, database, host IP, OS, architecture, CPU count and RAM, plus the admin name and email unless the hostname is localhost or 127.x. It changes nothing in the running server.
Before anyone else uses it
- Run
chmod 600 /opt/appwrite/.env. It holds_APP_OPENSSL_KEY_V1; the security page warns that changing that key loses existing passwords, OAuth secrets and API keys. - Only the first user can register on the console; the rest need an invitation (
_APP_CONSOLE_WHITELIST_ROOT, enabled by default). The console sets up the instance's one organization for you, and teammates join it by invitation. - Appwrite issues a Let's Encrypt certificate for
_APP_DOMAINon the first visit, using the HTTP challenge on port 80, and renews in the maintenance task (every 24 hours by default). Function domains need a wildcard via a DNS challenge, ordocker compose exec appwrite sslper domain. - If you do not send push over MQTT, set
_APP_MQTT_PORT=127.0.0.1:8883and_APP_MQTT_WSS_PORT=127.0.0.1:8084in.env(our change; the variables name host ports) so those two stay local. Docker-published ports skip ufw on IPv4 (and on IPv6 when Docker's own IPv6 networking is on), so ufw alone does not close them. - Apply any
.envedit withdocker compose -f /opt/appwrite/docker-compose.yml up -d --force-recreateand check it with... exec appwrite vars. Configure SMTP too, or verification and recovery mail goes nowhere.
Backups and a restore you can trust
The backup page lists the database, the storage volumes and .env, and says to shut Appwrite down before copying volumes. It gives dump commands for MariaDB and MongoDB but none yet for the PostgreSQL default, so this script, ours, copies every volume with appwrite in its name (database, uploads, functions, sites, builds, certificates, config, ClickHouse, Redis) plus the whole /opt/appwrite folder, with the stack stopped for the minutes it takes. As root:
mkdir -m 700 -p /var/backups/appwrite /var/log/appwrite-backup
cat > /root/appwrite-backup.sh <<'EOF'
umask 077; D=/opt/appwrite; C="docker compose -f $D/docker-compose.yml"
B=/var/backups/appwrite/$(date +%F-%H%M%S); mkdir -p "$B" || exit 1
vols=$(docker volume ls -q --filter name=appwrite)
[ -n "$vols" ] || { echo "no appwrite volumes found"; exit 1; }
$C stop || exit 1
ok=1; tar -czf "$B/config.tar.gz" -C /opt appwrite || ok=0
for v in $vols; do
docker run --rm -v "$v":/data:ro -v "$B":/backup ubuntu \
tar -czf "/backup/$v.tar.gz" -C /data . || ok=0
done
$C start
for f in "$B"/*.tar.gz; do tar -tzf "$f" >/dev/null || ok=0; done
[ $ok = 1 ] || { echo "backup FAILED, see $B"; exit 1; }
echo "$vols" > "$B/volumes.txt" && touch "$B/COMPLETE" && echo "backup ok: $B"
[ -e "$B/COMPLETE" ] && [ -z "$NOPRUNE" ] && find /var/backups/appwrite -mindepth 1 -maxdepth 1 -type d -mtime +7 -exec rm -rf {} +
EOF
printf '%s\n' '30 3 * * * root bash /root/appwrite-backup.sh > /var/log/appwrite-backup/last.log 2>&1' > /etc/cron.d/appwrite-backup
Each set is a folder named to the second, marked COMPLETE only when every archive passed tar -tzf; week-old sets are pruned only after a good run. Copy /var/backups/appwrite to another machine yourself using rsync -a without --delete, and expire old sets on that side. The archives contain the encryption key: store them like a password.
To restore, as root, set S to a set folder and paste the block. On the same box it first takes a fresh safety backup and stops if that fails, then moves the current /opt/appwrite aside under a timestamped name, so a rollback never trips over an earlier one. On a fresh box (Docker and ufw done as above, set copied into /var/backups/appwrite) it restores directly. Config comes back owned by root with .env at 600; volume files keep their numeric owners.
S=/var/backups/appwrite/CHANGE-ME; D=/opt/appwrite; ok=0
[ -e "$S/COMPLETE" ] && ( for f in "$S"/*.tar.gz; do tar -tzf "$f" >/dev/null || exit 1; done ) && ok=1
if [ $ok = 1 ] && [ -e "$D/.env" ]; then
ok=0; NOPRUNE=1 bash /root/appwrite-backup.sh | tee /root/pre-restore.log | grep -q '^backup ok:' &&
docker compose -f "$D/docker-compose.yml" down &&
mv "$D" "$D.previous-$(date +%F-%H%M%S)" && ok=1
fi
if [ $ok = 1 ] && [ ! -e "$D" ]; then
[ -e "$S/COMPLETE" ] && tar -xzpf "$S/config.tar.gz" -C /opt && chown -R root:root "$D" && chmod 600 "$D/.env" &&
docker compose -f "$D/docker-compose.yml" up --no-start &&
( for v in $(cat "$S/volumes.txt"); do docker volume create "$v" >/dev/null &&
docker run --rm -e V="$v" -v "$v":/data -v "$S":/backup:ro ubuntu bash -c \
'find /data -mindepth 1 -delete && tar -xzpf "/backup/$V.tar.gz" -C /data' || exit 1
done ) &&
docker compose -f "$D/docker-compose.yml" up -d && echo "restore ok" ||
echo "RESTORE FAILED part-way: roll back with the set in /root/pre-restore.log"
else
echo "STOP: nothing restored (bad set, failed safety backup, or $D in the way)"
fi
To roll back, run the same block with S set to the safety set named in /root/pre-restore.log. If a restore died after unpacking the config, move the half-restored /opt/appwrite aside by hand first.
Upgrades without skipping migrations
- Read the release notes of the target and of every release you skip; 2.6.2, for example, now refuses an empty or placeholder
_APP_JOBS_SECRET. - Back up and stop on failure:
bash /root/appwrite-backup.sh | grep '^backup ok:' && echo go. - With the tunnel open, from
/opt, run the install block'sdocker runline with--entrypoint="upgrade"and the new tag (neverlatest), open the?secret=address it prints, and finish the wizard. - Check
docker ps | grep appwrite/appwriteshows one version everywhere, then rundocker compose -f /opt/appwrite/docker-compose.yml exec appwrite migrate.
Patch releases skip the migration unless their notes ask for it. 2.6.2 upgrades from 1.9.6 or later in one go; 1.8.x goes 1.9.5, then 1.9.10, then 2.6.2. Rehearse on a copy built with the restore block on a second machine.
Where this is the wrong setup
Appwrite's own docs recommend their managed cloud to teams without DevOps experience and warn that self-hosting often costs more once time is counted. One Compose host is one failure domain: for replicas, Appwrite ships a Helm chart, and Kubernetes on bare metal is where that runs. If you want a Postgres-first stack with SQL at the centre, compare self-hosted Supabase. Appwrite is BSD 3-Clause; our general reading, not legal advice: redistributing it means keeping the copyright notice and licence text, with no duty to publish your source.
Questions
What hardware does a self-hosted Appwrite need?
The installation page sets the minimum at 2 CPU cores, 4 GB of RAM and 2 GB of swap on a system that runs Docker with Compose v2. Functions, builds and stored files add CPU, memory and disk on top of that.
Which database does the installer use now?
Appwrite 2.6.2's wizard selects PostgreSQL by default and also offers MariaDB and MongoDB. The volume backup on this page works for all three, because it copies the database files with the stack stopped.
Can I change the encryption key later?
Not safely. _APP_OPENSSL_KEY_V1 encrypts files and secrets, and the security page warns that changing it loses existing passwords, OAuth secrets and API keys. Keep the key the wizard showed you, and restore .env with the data so the two always match.
What does an Appwrite version upgrade involve?
Back up, run the installer image with the upgrade entrypoint and the exact new tag, confirm every Appwrite container reports it, then run migrate. Releases older than 1.9.6 step through the versions the upgrade guide lists.
Do I need my own reverse proxy?
No. Traefik in the stack terminates TLS on 443 with Let's Encrypt certificates for your main domain. Use your own proxy only if 80 and 443 are already taken; the TLS page notes the HTTP challenge forces certificates onto those two ports.