$ aoox

DocsStart here

Start from a fresh VPS

Every step from a brand-new VPS to the aoox panel reachable on your own domain, copy-pasteable and in order.

Time
± 30 minutes (DNS may take longer)
Needs
A fresh Linux VPS (amd64 or arm64) and, for HTTPS, a domain
Result
The panel on https://panel.example.com

Placeholders used below: SERVER_IP is your VPS's public IP, your-user is the login you create in step 4, and panel.example.com / api.panel.example.com are your own domain names. Already have a server with Docker and just want the install? See Installation.

1. Choosing a VPS

MinimumRecommended
CPU1 vCPU2 vCPU or more
RAM2 GB (add swap, step 5)4 GB or more
Disk25 GB SSD40 GB SSD or more

2 GB with swap is the tested minimum. On a 1 vCPU / 2 GB Ubuntu VPS with swap, the panel, proxy, registry, Dockerfile, static and Nixpacks builds, databases, backups, remote servers and single-node Swarm all worked; a cold Nixpacks build took about 5 minutes (a warm one 12 seconds), while a cold Railpack build can be killed for lack of memory (exit code 137). Use 4 GB or more for Railpack or several databases. The panel itself is three containers (PostgreSQL, the API and the web app); the reverse proxy and the local registry are added when you provision them; and every build runs on the same machine. On a 1 vCPU / 2 GB VPS the idle panel stack takes roughly 600-900 MB before any application, and a build (for example next build, Nixpacks or Railpack) needs 1-2 GB more and can touch swap even then. Builds and your own applications are what use the memory and disk: the Nixpacks helper image alone is roughly 350 MB, old images pile up, and a build that runs out of memory simply fails. 1 GB is only enough for a remote server (no panel, just Docker, the proxy and your applications), with swap.

  • Architecture: x86_64 (amd64) or arm64. The hideandseeklab/aoox-api and hideandseeklab/aoox-web images are published for both from 0.1.0-alpha.13 on. 0.1.0-alpha.12 and earlier images are amd64 only, so an ARM VPS (Ampere, Graviton) needs 0.1.0-alpha.13 or later; the aoox CLI already has an arm64 build.
  • Operating system: a mainstream Linux with systemd, such as Ubuntu 24.04 / 22.04 LTS or Debian 12. The installer only checks that it is running on Linux; the real requirement is that Docker's install script (get.docker.com) supports your distribution. The commands below are written for Ubuntu and Debian. Alpine (musl) is not supported by the prebuilt CLI.
  • Provider firewall / security group. If your provider has one, allow inbound 22 (SSH), 80 and 443 (HTTPS and certificates), plus 3000 and 3001 until the domain works (80/443 only answer once the first panel domain is saved, step 10). Do not open 5000 (the local registry, step 11) unless you use Swarm nodes.

2. First SSH login

Your provider shows the IP and a first user (root on most, ubuntu on some cloud images) and either a password or a key you chose when creating the server.

Windows (PowerShell), macOS, Linux
ssh root@SERVER_IP
Ubuntu cloud images: the user is ubuntu, not root
Many Ubuntu cloud images (AWS, Oracle, Tencent and others) disable root login and give you a sudo user called ubuntu. Then connect with ssh ubuntu@SERVER_IP and put sudo in front of the commands below that are written without it (steps 3 and 4). You can skip creating a new user in step 4 and use ubuntu as your-user.

Windows 10/11 include the OpenSSH client, so the same command works in PowerShell. The first time, SSH shows the server's fingerprint and asks Are you sure you want to continue connecting. If your provider shows the fingerprint in its console, compare it; then type yes. If you rebuild the server later and SSH complains that the host identification changed, remove the old entry with ssh-keygen -R SERVER_IP.

3. Update the system

sudo apt update && sudo apt upgrade -y

On Ubuntu, a file appears when a reboot is needed (for example after a kernel update). On Debian, reboot after a kernel upgrade.

[ -f /var/run/reboot-required ] && sudo reboot

Reconnect with the same ssh command after about a minute.

4. A sudo user and SSH key login

Do not run day-to-day work as root. This step also matters for aoox: when you install as a sudo user, aoox uses that user for the web terminal. A new install writes TERMINAL_SSH_USER as the user who ran sudo and authorizes the terminal's key for it; if you install as root directly, the terminal user is root, which most cloud images refuse over SSH. (If your image already gave you a sudo user such as ubuntu, you can skip creating one and use it.)

  1. Create the user (on the server)

    adduser your-user
    usermod -aG sudo your-user

    adduser asks for a password and a few optional details. Keep the password: sudo asks for it.

  2. Create a key on your laptop (skip if you already have one)

    ssh-keygen -t ed25519 -C "you@laptop"

    It saves ~/.ssh/id_ed25519 (private, never share) and ~/.ssh/id_ed25519.pub (public). A passphrase is a good idea.

  3. Install the public key on the server

    macOS / Linux, from your laptop:

    ssh-copy-id your-user@SERVER_IP

    Windows PowerShell has no ssh-copy-id; this does the same (it asks for the password you just set):

    Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub | ssh your-user@SERVER_IP "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
  4. Test in a second terminal

    Keep the root session open and, in a new terminal window:

    ssh your-user@SERVER_IP
    sudo -v

    You should get in with the key (no server password; a key passphrase prompt is fine), and sudo -v should accept your password. Only continue when both work.

Optional: turn off password and root login

You can lock yourself out
Do this only after step 4 worked in a second terminal, and keep that working session open until you have tested a fresh login afterwards. If something is wrong you may be locked out and need your provider's web console to recover.

Ubuntu and Debian read extra files from /etc/ssh/sshd_config.d/ first, and for each setting the first value found wins, so a cloud image's own file (often 50-cloud-init.conf with PasswordAuthentication yes) would override an edit to the main sshd_config. A file named 10-… is read before it:

sudo tee /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF'
PasswordAuthentication no
PermitRootLogin no
EOF
sudo sshd -t && sudo systemctl reload ssh

sshd -t prints nothing when the configuration is valid. Then open a third terminal and confirm ssh your-user@SERVER_IP still works. Two aoox-related notes: the web terminal logs in over SSH with a key, so it works with PasswordAuthentication no; but it fails if TERMINAL_SSH_USER is root and root login is off, and if you restrict SSH with AllowUsers the terminal user must be listed. See SSH user on a cloud VPS.

From here on, log in as your-user.

5. Basics: timezone, swap, firewall

Timezone

timedatectl list-timezones | grep -i jakarta
sudo timedatectl set-timezone Asia/Jakarta

Pick your own zone from the list. This sets the host's clock for logs and host cron jobs only. aoox's own schedules (job, backup, the nightly cleanup) run inside the API container, which uses UTC unless you set TZ. To run them in your zone, add it to .env.dist and recreate the API container. Do this after step 8 (the install): /opt/aoox does not exist before it, so the command below fails if you run it now.

echo 'TZ=Asia/Jakarta' | sudo tee -a /opt/aoox/.env.dist
cd /opt/aoox && sudo docker compose -f docker-compose.dist.yml --env-file .env.dist up -d

The dashboard shows the zone in use next to every schedule field. An install made before this option existed needs aoox reinstall first so its compose file passes TZ to the API. Existing cron expressions keep their digits, so re-check them after changing the zone.

Swap (recommended below 4 GB RAM)

A swap file lets a build survive a short memory spike instead of being killed.

Check first: many images already come with swap. If either command below shows a swap line with a size, skip this section (adding a second swap file would only waste disk).

swapon --show
free -h
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h

free -h should show 2.0Gi under Swap. Some providers already add swap or use a filesystem where fallocate files cannot be swap files; if swapon complains, check swapon --show first.

Firewall (ufw)

Allow SSH first
Add the SSH rule before enabling ufw, otherwise you cut your own session. If SSH listens on a port other than 22, use that port.
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 3000/tcp
sudo ufw allow 3001/tcp
sudo ufw --force enable
sudo ufw status verbose

--force matters when you paste the whole block: a plain ufw enable stops and asks Command may disrupt existing ssh connections. Proceed with operation (y|n)?, and the rest of the pasted lines are then read as the answer, so ufw is left disabled. The SSH rule was added first, so enabling it will not cut your session.

Ports 3000/3001 are only needed until the domain works (step 10). Keep the 22 rule open to any source: the web terminal connects from the API container to the host's SSH port, so a rule limited to your own IP would block it.

ufw does not protect Docker-published ports
Docker publishes container ports by routing them in its own firewall tables, and its documentation states that this traffic is diverted before it reaches the chains ufw uses, so ufw rules are effectively skipped for published ports. Consequences here: ports 3000 and 3001 stay reachable from the internet even with ufw deny 3000, and the same applies to the registry on port 5000 once you provision it (it is password protected, but still exposed) and to any host port you give an application. To really close them use your provider's firewall (it filters before traffic reaches the VM), and for the panel the Only allow access through the domain switch, which makes ports 3000/3001 listen on 127.0.0.1 only. ufw is still worth having for everything that is not published by Docker (SSH and other host services). This was checked on a real Ubuntu 24.04 VPS: a port denied by ufw was still reachable from outside. aoox helps in two places: a database or database admin app you publish is bound to 127.0.0.1 by default, and every other host port (applications, stacks) has a Listens on choice, Public or This server only (127.0.0.1). The Open ports tab on the Docker page (owner and admin) lists every published port with its owner and whether it is open to the internet.

6. Install the aoox CLI (Node.js not required)

aoox install is a command of the aoox CLI. The standalone CLI tarball bundles its own Node.js, so you do not install Node.js on the server. Run it with sudo so it lands in /usr/local/bin:

curl -fsSL https://aoox.dev/install-cli.sh | sudo sh
aoox --version
Why sudo here
Without root or passwordless sudo the script falls back to ~/.local/bin, and then sudo aoox install in the next step says command not found (sudo does not search that folder). Supported: Linux with glibc (x64 or arm64) and macOS.

Alternative: npm (needs Node.js 22 or newer)

The CLI declares node >= 22. On Ubuntu/Debian, Node 22 from NodeSource:

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
sudo npm install -g @hideandseeklab/aoox

7. Docker

You do not have to install it yourself. aoox install checks for a working docker info; if Docker is missing or not running it offers to run Docker's official script (curl -fsSL https://get.docker.com | sh) and asks for confirmation (--yes skips the question). After that it checks Docker works and stops with a hint (systemctl status docker) if it does not.

If you prefer to install Docker yourself from its apt repository first (Ubuntu; the install needs the Compose plugin, v2):

sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

On Debian, replace ubuntu with debian in both the key URL and the URIs: line. Check with sudo docker compose version.

The docker group is optional
aoox install runs as root and does not need your user in the docker group. Adding it (sudo usermod -aG docker your-user, then log in again) only saves typing sudo before docker, and it is effectively root access to the server: anyone who can run docker can mount the whole filesystem. Skip it unless you want that convenience.

8. Install aoox

Run as your-user (not logged in as root), so the sudo user becomes the terminal user:

sudo aoox install

It asks at most two questions, and none with --yes:

  • Install Docker now via curl -fsSL https://get.docker.com | sh? only if Docker is missing.
  • Install aoox in /opt/aoox and run docker compose up -d? (Y/n)

What it then does, in order:

  • Creates /opt/aoox (change with --dir) with docker-compose.dist.yml and a secrets/ folder owned by uid 1000 (the API container's user).
  • Detects the public IP through api.ipify.org and writes /opt/aoox/.env.dist (mode 0600) with freshly generated POSTGRES_PASSWORD, JWT_SECRET and ENCRYPTION_KEY, INSTALL_DIR=/opt/aoox (so the dashboard can later change the panel domain without further setup), and TERMINAL_SSH_USER=your-user.
  • Creates the terminal's SSH key in secrets/ and adds it to the ~/.ssh/authorized_keys of your-user. A problem here is only a warning (--no-terminal-authorize skips it).
  • Runs docker compose up -d, which pulls PostgreSQL 16 and the two aoox images, then waits up to two minutes for the API (it runs database migrations on first start).

Expect a few minutes, mostly image downloads. It ends with:

aoox is installed in /opt/aoox.
Open: http://SERVER_IP:3000
Create the first owner account at /setup.
Ports 80 and 443 are closed right after the install
This is expected, not a provider block: the reverse proxy that listens on 80/443 does not exist yet. It is started when you save the first panel domain (step 10) or provision it yourself. Until then only 3000 and 3001 answer.
Check the printed address
If the server could not detect its public IP, the line says http://localhost:3000, and WEB_ORIGIN in .env.dist says localhost too. Open the dashboard at the address printed (replace it with your real IP in WEB_ORIGIN and PUBLIC_API_URL, then sudo docker compose -f docker-compose.dist.yml --env-file .env.dist up -d in /opt/aoox): sign-in and the terminal check that the address in the browser matches WEB_ORIGIN exactly.

Logs and status afterwards (the files are root-owned, so use sudo):

cd /opt/aoox
sudo docker compose -f docker-compose.dist.yml --env-file .env.dist ps
sudo docker compose -f docker-compose.dist.yml --env-file .env.dist logs -f api
Running the install again
sudo aoox install refuses to run again in a folder that already has an install. --force reinstalls over it: it first copies .env.dist and the compose files to /opt/aoox/backups/<timestamp>/ and then keeps your existing secrets, so the data stays readable. Only --force --new-secrets --wipe-data starts from scratch, and it deletes the panel database. To repair or refresh an install use sudo aoox reinstall (step 11).
Install refuses: leftover database volume
If you deleted /opt/aoox but Docker still has the old aoox_postgres_data volume, the installer stops before writing anything: a new random database password cannot open the old data. Either put the old .env.dist back (for example from /opt/aoox/backups/<timestamp>/) and run sudo aoox reinstall to keep the data, or run sudo aoox install --force --new-secrets --wipe-data to delete that data and start clean.
One command instead of the CLI
Prefer a single line? curl -fsSL https://aoox.dev/install.sh | sudo sh does the same job with environment variables instead of prompts and flags, and needs nothing but curl and sh. See Installation. You can still install the CLI afterwards for aoox update and aoox domain.

9. First sign-in

  1. Open the dashboard

    Go to http://SERVER_IP:3000. While there are no users you are redirected to /setup.

  2. Create the owner

    Enter a name, email and password (at least 8 characters). This first account is the owner; later users are invited from Settings (Users & roles).

  3. Check from the server (optional)

    curl -s http://localhost:3001/auth/setup-status

    {"needsSetup":true} before the account exists, {"needsSetup":false} after.

10. A domain with HTTPS

  1. Create two DNS A records

    At your DNS provider, point two names at the server's IP:

    DNS
    panel.example.com.       A   SERVER_IP
    api.panel.example.com.   A   SERVER_IP

    The dashboard and the API need separate names because the browser talks to the API directly. If you use Cloudflare, set both records to DNS only (grey cloud) while the certificate is issued.

  2. Wait until they resolve

    From your laptop:

    nslookup panel.example.com

    It must answer with SERVER_IP. A certificate can only be issued once DNS is right; failed attempts count against Let's Encrypt's rate limits.

  3. Set the panel domain (Settings → Domain panel)

    Sign in as the owner, open Settings → Domain panel, enter the dashboard domain, the API domain and an email for Let's Encrypt, then Save & apply. Ports 80/443 must be free on the server. The panel starts the reverse proxy itself if it is not running, rewrites the compose override and recreates the web and API containers, so the page disconnects for a few seconds. Or from the CLI:

    aoox login
    aoox domain set --web panel.example.com --api api.panel.example.com --acme-email you@example.com
  4. Open https://panel.example.com

    The certificate is requested the first time the host is reached, so allow a few seconds. Sign in again: the session from the IP address does not carry over to the new origin. Check:

    curl -I https://panel.example.com
  5. Optional: only allow access through the domain

    Once the HTTPS address opens, turn on Only allow access through the domain in Settings → Domain panel (or add --domain-only to the command above). Ports 3000/3001 then listen on 127.0.0.1 only. Remove the now useless ufw rules and, if you opened them, the provider firewall rules for 3000/3001:

    sudo ufw delete allow 3000/tcp
    sudo ufw delete allow 3001/tcp
Turn the switch on only after HTTPS works
If the domain breaks later (DNS, certificate, firewall), IP:3000 no longer answers either. Recovery over SSH is in Domain for the panel. The domain can also be given at install time (--web-domain, --api-domain, --acme-email) when DNS already points at the server, which skips the dashboard step.

11. After the installation

  • Terminal. A new install already authorized the terminal key for your user, so open the Terminal menu. Only if the install printed warning: terminal key not authorized automatically (or the Terminal shows an authentication error), use the owner's Authorize automatically button in Infrastructure → Terminal access, or run the command it shows as the same user. Details in Web terminal.
  • Registry and proxy. Provision the local registry before your first deploy from a Git repo. The reverse proxy is started automatically when you save a panel domain.
  • Back up the panel. Set up Instance backup (Settings → Instance backup), with a schedule and ideally an S3 destination, and download a copy off the server. A restore needs the same ENCRYPTION_KEY, so also keep a private copy of /opt/aoox/.env.dist somewhere other than this server.
  • Updating. aoox update panel --apply (or Settings → Update aoox) pulls newer images and restarts the stack; aoox update updates the CLI itself. They never rewrite the compose file or add new variables to .env.dist. When a release needs that, run sudo aoox reinstall (try --dry-run first): it rewrites the compose file, merges .env.dist without regenerating secrets, and recreates the containers without touching data. See Updating aoox and aoox reinstall.

If something goes wrong

SymptomCheck
The browser cannot open IP:3000The provider firewall / security group (ufw does not matter for Docker-published ports). On the server: sudo docker compose … ps.
IP:80 / IP:443 do not answer after the installExpected until the first panel domain is saved (step 10): the reverse proxy is not running yet.
The proxy cannot bind 80/443Something else (a preinstalled Apache/nginx) uses them: sudo ss -ltnp | grep -E ":(80|443) ", stop and disable it.
The certificate never arrives (self-signed / default certificate)DNS not propagated yet, port 80 blocked at the provider, or Let's Encrypt rate limits after repeated failures. Test with PROXY_ACME_STAGING=true. See Common pitfalls.
Login loops or the terminal says origin not allowedWEB_ORIGIN differs from the address in the browser.
Terminal: SSH authentication failedWrong TERMINAL_SSH_USER or key not authorized. See SSH user on a cloud VPS.

More in Troubleshooting.

Next steps

Found a mistake? Edit this page on GitHub ↗