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
| Minimum | Recommended | |
|---|---|---|
| CPU | 1 vCPU | 2 vCPU or more |
| RAM | 2 GB (add swap, step 5) | 4 GB or more |
| Disk | 25 GB SSD | 40 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-apiandhideandseeklab/aoox-webimages 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),80and443(HTTPS and certificates), plus3000and3001until the domain works (80/443 only answer once the first panel domain is saved, step 10). Do not open5000(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.
ssh root@SERVER_IPubuntu. 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 -yOn 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 rebootReconnect 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.)
Create the user (on the server)
adduser your-userusermod -aG sudo your-useradduserasks for a password and a few optional details. Keep the password:sudoasks for it.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.Install the public key on the server
macOS / Linux, from your laptop:
ssh-copy-id your-user@SERVER_IPWindows 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"Test in a second terminal
Keep the root session open and, in a new terminal window:
ssh your-user@SERVER_IPsudo -vYou should get in with the key (no server password; a key passphrase prompt is fine), and
sudo -vshould accept your password. Only continue when both work.
Optional: turn off password and root login
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
EOFsudo sshd -t && sudo systemctl reload sshsshd -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 jakartasudo timedatectl set-timezone Asia/JakartaPick 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 -dThe 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 -hsudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfileecho '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstabfree -hfree -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)
sudo apt install -y ufwsudo 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 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 shaoox --version~/.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 nodejssudo npm install -g @hideandseeklab/aoox7. 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.ascsudo 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
EOFsudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginOn Debian, replace ubuntu with debian in both the key URL and the URIs: line. Check with sudo docker compose version.
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 installIt 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) withdocker-compose.dist.ymland asecrets/folder owned by uid 1000 (the API container's user). - Detects the public IP through
api.ipify.organd writes/opt/aoox/.env.dist(mode 0600) with freshly generatedPOSTGRES_PASSWORD,JWT_SECRETandENCRYPTION_KEY,INSTALL_DIR=/opt/aoox(so the dashboard can later change the panel domain without further setup), andTERMINAL_SSH_USER=your-user. - Creates the terminal's SSH key in
secrets/and adds it to the~/.ssh/authorized_keysofyour-user. A problem here is only a warning (--no-terminal-authorizeskips 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.3000 and 3001 answer.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 apisudo 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)./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.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
Open the dashboard
Go to
http://SERVER_IP:3000. While there are no users you are redirected to/setup.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).
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
Create two DNS A records
At your DNS provider, point two names at the server's IP:
DNSpanel.example.com. A SERVER_IP api.panel.example.com. A SERVER_IPThe 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.
Wait until they resolve
From your laptop:
nslookup panel.example.comIt 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.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 loginaoox domain set --web panel.example.com --api api.panel.example.com --acme-email you@example.comOpen 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.comOptional: 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-onlyto the command above). Ports 3000/3001 then listen on127.0.0.1only. 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
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.distsomewhere other than this server. - Updating.
aoox update panel --apply(or Settings → Update aoox) pulls newer images and restarts the stack;aoox updateupdates the CLI itself. They never rewrite the compose file or add new variables to.env.dist. When a release needs that, runsudo aoox reinstall(try--dry-runfirst): it rewrites the compose file, merges.env.distwithout regenerating secrets, and recreates the containers without touching data. See Updating aoox and aoox reinstall.
If something goes wrong
| Symptom | Check |
|---|---|
The browser cannot open IP:3000 | The 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 install | Expected until the first panel domain is saved (step 10): the reverse proxy is not running yet. |
| The proxy cannot bind 80/443 | Something 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 allowed | WEB_ORIGIN differs from the address in the browser. |
| Terminal: SSH authentication failed | Wrong 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 ↗