# Nimbus — full documentation > A control panel that turns one Linux server into a hosting platform: add a domain, pick a stack, and it serves over HTTPS — with deployments, databases, access control, an HTTP API and an MCP server. Every page of this site, concatenated. Generated; do not edit. --- # Your sites on *one box* you understand. > What Nimbus is: a control panel for a single Linux server, with deployments, databases, access control, an API and an MCP server. Nimbus turns one Linux server into a hosting platform you can hold in your head. Name a domain, pick a stack, and it is serving over HTTPS about a minute later. [Read the guide]({{base}}help/index.html.md) [Drive it from an agent]({{base}}agents/index.html.md) Swept away - kubernetes manifests - ingress controllers - terraform state - node pools - helm charts - IAM policies one box one panel your sites Day to day ## What the panel does **Add a site `domain + stack`** : Name the domain and choose a stack. The panel creates the container, scaffolds the folder, and publishes the route. **Serve it over HTTPS `caddy`** : Every domain and alias gets its own certificate, issued and renewed on its own. There is no TLS configuration to write. **Deploy on push `gitea actions`** : The runner lives inside the site’s own container. It builds the new release there and swaps it in, so shipping never needs a shell on the box. **Give a site a database `mysql`** : One database and one user per site, created on request. phpMyAdmin is already on the box for when you need to look inside. **Edit the environment `.env`** : Read and write a Laravel site’s .env from the panel. Saves are atomic, and database credentials can be filled in for you. **Limit who can reach it `ip allowlist`** : The panel answers only to addresses you have unlocked, and any folder of any site can sit behind the same gate. Not just a UI ## Every panel action is available to a program The control panel is one client of the platform, not the platform itself. Anything you can do by clicking — create a site, change a stack, provision a database, unlock an address, roll a deploy — a script or an agent can do too. **MCP server** `for agents` Point an MCP-speaking assistant at your box and it discovers the tools it can call, with the same permissions as the account behind them. ``` { "mcpServers": { "nimbus": { "url": "https://panel.example.com/mcp", "headers": { "Authorization": "Bearer $NIMBUS_TOKEN" } } } } ``` **HTTP API** `for scripts` A JSON API over the same actions, with token auth. Useful when the caller is a shell script, a CI job, or another service. ``` $ curl -H "Authorization: Bearer $NIMBUS_TOKEN" \ https://panel.example.com/api/sites $ curl -X POST -H "Authorization: Bearer $NIMBUS_TOKEN" \ -d 'domain=shop.example.com&image=laravel' \ https://panel.example.com/api/sites ``` Both speak the panel’s own vocabulary — sites, stacks, routes, databases, locks — so what you learn from the guide applies to both. [How an agent uses them →]({{base}}agents/index.html.md) The stack ## What runs on the box ### Per site Each site gets its own container, built from one of these. - **Laravel app** `laravel` PHP 8.3 — nginx, php-fpm and supervisor, with the deployment runner baked in. - **Static site** `web-base` nginx serving a docroot from the site’s folder. This site runs on it. ### Shared Containers every site on the box uses. - `caddy`Terminates TLS and routes each domain to its container. - `mysql`One server, one database per site that asks for one. - `redis`Cache and queues for the sites that want them. - `phpmyadmin`Database access in the browser, behind the same IP gate. - `panel`The control panel — a site on the platform it manages. ## Running an app on a Nimbus box? The site owner’s guide covers the whole path — the folder your code lives in, the stack that runs it, the domain that reaches it, and the gate that can stand in front. [Read the guide]({{base}}help/index.html.md) --- # Working with Nimbus from an AI agent > How an AI agent drives a Nimbus box: the MCP server, the HTTP API, and the AGENTS.md file to drop into a site's repository. For machines ## Working with Nimbus from an AI agent There are three separate things here, and they are for three different moments. Pick the one that matches what your agent is doing. **Operating the box `MCP`** : An assistant that should be able to *act* — create a site, provision a database, unlock an address, restart a container. Connect it to the panel’s MCP server and it discovers the tools it may call. **Scripting the box `HTTP API`** : The same actions as JSON over HTTP, for a caller that is a shell script or a CI job rather than a model. **Working in a site’s repo `AGENTS.md`** : A coding agent editing an application that is *hosted* here. It does not need the panel at all — it needs to know how deployment works. Drop the file below into the repository. Connect ## The MCP server Each box exposes its own endpoint at `/mcp` on the panel domain. Tools are scoped to the token’s account, so an agent can never do more than the person who issued it. **Claude Code** `.mcp.json` ``` { "mcpServers": { "nimbus": { "url": "https://panel.example.com/mcp", "headers": { "Authorization": "Bearer $NIMBUS_TOKEN" } } } } ``` **Anything else** `HTTP` The API mirrors the panel’s vocabulary one-to-one, so the guide doubles as its documentation. ``` $ curl -H "Authorization: Bearer $NIMBUS_TOKEN" \ https://panel.example.com/api/sites ``` **Scope** A token belongs to a panel account and inherits its role. An `unlock_user` token can unlock an address and nothing else; only an admin token can create or delete a site. Treat one like the password it replaces. Drop-in ## AGENTS.md, for a site’s repository When a coding agent is asked to add deployment to an app hosted here, it needs facts about this platform that are not in the repository it is looking at. [`AGENTS.md`]({{base}}AGENTS.md) is those facts, written for a machine to act on. Save it at the **root of the site’s repository**, keeping the filename. That name is the convention — coding agents look for `AGENTS.md` in a repo root the way a person looks for `README.md`, and renaming it to something more descriptive is what stops it being found. ``` $ curl -O https://panel.nimbus-online.net/AGENTS.md ``` **Give it the three facts** The file tells the agent what to write, but not which site it is writing for. Before you set it going, give it the **runner label**, the **domain** and the **stack** — all three are on the site’s Deployments page in the panel. Guessing the runner label produces a workflow that never runs. [Read AGENTS.md]({{base}}AGENTS.md) [How deployment works]({{base}}help/ci.html.md) Reading, not acting ## This whole site, as text For an agent that just needs to understand the platform, every page here is published as markdown too, and indexed the standard way. **[`/llms.txt`]({{base}}llms.txt)** : The index: one line per page, with a description. Start here — it is small on purpose. **[`/llms-full.txt`]({{base}}llms-full.txt)** : Every page concatenated, for a single fetch. **Any page `.md`** : **Append** `.md` to any page URL — `/help/sites.html.md` is the markdown of `/help/sites.html`. A URL with no filename takes `index.html.md`, so `/help/` becomes `/help/index.html.md`. That is the llmstxt.org convention: appended, not substituted. --- # Your app runs in a container. A domain points at it. > Your app runs in a container and a domain points at it. That sentence is the whole platform. That single sentence is the whole platform. Everything in this guide is a stop along that path — the folder your code lives in, the image that runs it, the domain that reaches it, and the gate that can stand in front. Your domain app.example.com Points at the box. You own the DNS record. The edge Caddy · HTTPS Terminates TLS and gets the certificate for you. Your container no-app-example-com:80 One container per site. Never a public port. Your files /var/www/virtual/app.example.com A real folder on the host. Your code goes here. ## What you are responsible for The platform gives you a running container, a routed domain, and a certificate. It does not touch your application. The split is sharper than on most hosts, so it is worth stating plainly. | The platform does | You do | |---|---| | Runs a container for your site and restarts it if it dies | Put your application files in the site folder — a Laravel app goes in its `application/` subdirectory | | Routes your domain to it and issues the HTTPS certificate | Point the DNS record at the box before publishing | | Serves the folder with nginx, and PHP-FPM on the Laravel image | Ship a working app — the health check does not test it | | Runs the background programs you declare | Write those program definitions and keep them valid | | Provides shared MySQL and Redis on the internal network | Get your own database and credentials, and configure your app | **Read this before you deploy anything** There is no build pipeline, no git integration, and no deploy command. You copy files into the site folder yourself. And because PHP opcache is set to never re-check timestamps, **your changed code does not go live until the container is restarted.** [Chapter 04](deploying.html.md) covers what this means in practice. ## The vocabulary Five words carry most of the meaning. They mean the same thing in this guide and in the panel. **Site** : One website: a domain, an image, and a folder. One container runs it. **Folder** : A directory under `/var/www/virtual/` holding your code. Mounted into the container at the identical path. **Image** : The runtime your site uses — a static web server, or PHP 8.3. Chosen from a catalog when the site is created. **Published** : Whether a domain currently routes to your site. A site can run without being published. **Locked** : Whether the whole site, or certain folders, answer only to unlocked IP addresses. Everyone else gets a 403. ## Where to go next The chapters build on each other in order. If you are setting up a site for the first time, read them straight through — it is about fifteen minutes. If you are here with a specific problem, jump. [ 02 What a site is The folder, the container, the docroot, and what the status dot actually measures. ](sites.html.md) [ 03 Choosing an image Static site or PHP 8.3 — what each one gives you, and the one setting you can change. ](images.html.md) [ 04 Getting your code on How files reach the folder today, and the restart rule you cannot skip. ](deploying.html.md) [ 05 Background work Queue workers and the scheduler: three filenames, and the rules they must follow. ](background-jobs.html.md) [ 06 Databases and Redis What is on the internal network, how to reach it, and what you must arrange yourself. ](services.html.md) [ 07 Domains and HTTPS Publishing, certificates, extra domains, and why a certificate can stay pending. ](domains.html.md) [ 08 When you get a 403 Two different 403s, how to tell them apart, and how to let yourself back in. ](access.html.md) [ 09 Limits and reference Upload sizes, timeouts, PHP settings, status meanings, and a troubleshooting table. ](reference.html.md) [Next02 · What a site is](sites.html.md) --- # What a site is > A domain, an image and a folder, bound together. Creating one gives you a running container. A site is three things bound together: a domain, an image, and a folder. Creating one gives you a running container. Everything else in this guide adjusts one of those three. Folder — you are here /var/www/virtual/app.example.com Your code. The panel creates it and never touches the contents. Mounted into /var/www/virtual/app.example.com The identical path inside the container. No translation. Served by nginx :80 Docroot depends on the image — see below. ## The folder Every site has a folder on the host, under `/var/www/virtual/`. When a site is created the folder defaults to the domain name, so `app.example.com` gets `/var/www/virtual/app.example.com`. A folder outside that base directory is rejected. The important property: **the folder is mounted into the container at exactly the same path.** There is no mapping to remember. A file at `/var/www/virtual/app.example.com/application/artisan` on the host is at that same path inside the container. This is why absolute paths in your background programs work in both places — see [chapter 05](background-jobs.html.md). The panel creates the folder if it is missing. Beyond that it never reads, writes, or clears the contents — not on a restart, not on a settings change, not when the image changes. ## Where the docroot points This differs by image, and it is the first thing that trips people up. | Image | Docroot | So your files look like | |---|---|---| | Static Website web-base | | /index.html | | Laravel App laravel | /application/public | /application/public/index.php /application/artisan /application/vendor | On the Laravel image the application lives in an `application/` subdirectory of the folder, and only its `public/` is exposed to the web. That is the standard Laravel layout, and it means your `vendor/` and the app's own `.env` and `storage/` symlinks sit outside the docroot where they belong. Two pieces of durable state live **beside** `application/`, not inside it: `/storage/` (uploads, logs, sessions — the standard Laravel tree, scaffolded for you by the image) and `/.env` (your secrets). A deploy symlinks both back into `application/`, so it can wipe and re-publish `application/` on every push without ever touching your data or secrets. See [chapter 04](deploying.html.md). **A fresh site is not empty** If the docroot has no `index.php` or `index.html`, the Laravel image writes a placeholder page saying the image is running with no app deployed. Seeing that page means the container is healthy and waiting for your code — not that something broke. ## The container One container runs one site. It is named after the domain, with dots turned into dashes and an `no-` prefix: `app.example.com` becomes `no-app-example-com`. You will see this name in the panel and in log output. Containers have **no published host ports**. Nothing about your site is reachable at `the-box-ip:8080` or similar. Traffic arrives only through the edge proxy, which dials your container by name on the internal network. This is also why your app can reach shared services by name — covered in [chapter 06](services.html.md). ## Created is not published These are two separate actions, and the split is deliberate. **Created** : The folder exists, the container runs, and the site is reachable from nowhere. Nothing is routed to it and no certificate has been requested. **Published** : The domain is routed to your container and a certificate is issued. This is the point at which DNS must already be correct. **Unpublished** : The route is withdrawn. The container keeps running and your files are untouched — the site is just unreachable. You can safely create a site, get your code in place, confirm it works, and publish afterwards. Unpublishing is not destructive and is the right way to take a site offline temporarily. ## What the status dot means The panel shows a live status for each site, derived from the container's health check at the moment you look. It is not stored and not historical. ** Up** : The container is running and its health check passes. ** Starting** : The container is running but has not yet reported healthy. The Laravel image allows 20 seconds before it starts counting failures. ** Down** : The container is stopped, missing, or failing its health check. **Green does not mean your app works** The health check asks nginx for `/health`, which returns a fixed `200` without touching PHP, and confirms the docroot is readable. On the Laravel image it also checks that php-fpm and supervisor are running as processes. It never executes your application. A site with a fatal PHP error, a missing `vendor/`, or a broken database connection reports Up quite happily. Always confirm a deploy by loading a real page. ## Restarting The panel offers a restart action for a site. You will need it more often than you might expect, because it is how newly deployed PHP code and newly added background programs take effect. Both are covered next. [Previous01 · Start here](index.html.md) [Next03 · Choosing an image](images.html.md) --- # Choosing an image > The image decides what can run in your container: a static file server, or PHP with a deployment runner. The image is the runtime your site gets. There are two, they layer on each other, and the choice is mostly whether you need PHP. ## The two images | Static Website | Laravel App | |---|---| | nginx serving files straight from your folder. | Everything in the static image, plus PHP 8.3 via php-fpm, and supervisor for background work. | | Docroot is the folder root. | Docroot is `public/` inside the folder's `application/` subdirectory. | | No settings to configure. | One setting: PHP memory limit. | | No background programs. | Queue workers, scheduler, and boot tasks. | | Good for: built front-ends, static exports, landing pages, anything you compile elsewhere. | Good for: Laravel, and PHP applications generally — the layout convention is Laravel's, but nothing is Laravel-specific. | Despite the name, the Laravel image is a general PHP 8.3 runtime. If your PHP application serves from a `public/` subdirectory and tolerates a front-controller rewrite, it will run. ## What the PHP image includes PHP 8.3 with these extensions compiled in and enabled: ``` fpm cli mysql mbstring xml bcmath curl zip gd redis opcache intl ``` **Composer** is installed and on the path, as are `git` and `unzip`. That matters because there is no build step outside the container — if you need to install dependencies, you do it inside the container against the mounted folder. Requests are served by nginx with a standard front-controller rewrite: anything that is not a real file falls through to `index.php`. Static assets — images, fonts, CSS and JS — get a one-year immutable cache header, so fingerprint your asset filenames if you expect to change them. **Dotfiles are not served** Requests for paths beginning with a dot are refused, so a stray `.env` or `.git` in the docroot is not exposed. The one exception is `/.well-known/`, which is allowed through for certificate validation and similar uses. ## The setting you can change Images declare which settings they accept. Today the Laravel image declares exactly one, and the static image declares none. **PHP memory** : `PHP_MEMORY_LIMIT` — default `256M`. Accepts any value PHP understands, such as `512M`. Applied when the container starts. You choose settings when creating a site and can change them afterwards. Changing a setting **recreates the container**. Your domain, folder, route, certificate and files all survive that — the site is briefly unavailable while the new container starts, and nothing else moves. **There is no way to add your own environment variables** Only settings declared by the image become environment variables in the container. Arbitrary keys are rejected rather than passed through, so you cannot inject `DB_PASSWORD` or an API key through the panel. Your application's own configuration belongs in a file in your site folder — for Laravel, the usual `.env`. It sits outside the docroot and is not web-accessible. See [chapter 06](services.html.md) for the connection details to put in it. ## Changing image later A site can move from one image to another. Settings that both images declare carry over if the value is still valid; settings only the new image declares take their default; settings only the old image had are dropped. The panel shows you exactly which of those three happens to each setting before you confirm. What it does not do is move your files. Because the two images use different docroots, switching a site from static to PHP means your content needs to end up under `application/public/` — that rearrangement is yours to do. **Adding a new runtime** The catalog is extensible — a new image is a Dockerfile plus a catalog entry, with no panel changes. But building and registering images is an operator task, not something you can do from the panel. Ask your operator if you need a runtime that is not listed. [Previous02 · What a site is](sites.html.md) [Next04 · Getting your code on](deploying.html.md) --- # Getting your code on > How your files reach the folder the container serves, and what must survive a deploy. There is no deploy button, no git integration, and no build pipeline. You put files in the folder, then restart the site. This chapter is short because the mechanism is — but the restart is not optional, and that part catches everyone. **The rule** **Changed PHP files do not take effect until the container restarts.** You can copy a fixed file into place, reload the page, and watch the old code run indefinitely. The reason is the opcache. PHP normally compiles every `.php` file it touches on every request, so it keeps the compiled result in memory and reuses it — a large speedup for an app that loads hundreds of files per request. Normally it also checks each file's modification time to notice edits, but here that check is **switched off**: it saves a disk read per file per request, at the cost of never noticing that a file changed. So once a file has been compiled and cached, the copy in memory is what runs. Editing the file on disk changes something PHP has stopped looking at. Restarting replaces the processes, the cache dies with them, and the next request compiles your new code from disk. ## The shape of a deploy Whatever tooling you wrap around it, every deploy here is the same three steps. 1. **Get the files into the site folder** — on a static site that is `/var/www/virtual//` directly; on a Laravel site the app goes into that folder's `application/` subdirectory (`/var/www/virtual//application/`). 1. **Do any work the app needs** — install dependencies, run migrations, rebuild caches. 1. **Restart the site** from the panel, so PHP picks up the new code. Step 3 is required for **any change to PHP code — including Blade templates**. The only things exempt are static assets: CSS, JavaScript, images and fonts are served straight off disk by the web server without PHP involved, so they update the moment you copy them up. **Templates are not an exception** It is tempting to assume a Blade template is re-read each request. It is not. Blade compiles your `.blade.php` into a plain PHP file under `storage/framework/views/`, named from a hash of the view's path — so the name stays the same when you edit it. Laravel notices the source changed and rewrites that compiled file, but the opcache is still holding the *previous* version under that same path and never re-checks the disk. The old template renders on. Treat the rule as simple: if it is PHP, or it becomes PHP, it needs a restart. ## Step 1 — moving files How you get files onto the box depends entirely on what access your operator has given you: SSH and `rsync`, SFTP, or something else. The platform itself takes no position and provides no upload mechanism. On a **static** site you publish the built output to the site folder directly. On a **Laravel** site you publish into the folder's `application/` subdirectory, and you keep two things **outside** it: `/storage/` (the durable Laravel storage tree — logs, caches, uploaded files; the image scaffolds it for you) and `/.env` (your credentials, placed on the box). The deploy symlinks both back into `application/`, which is what makes it safe to wipe and re-publish `application/` on every push: ``` # from your project, into application/ on the box rsync -az --delete \ --exclude .git --exclude storage --exclude .env \ ./ user@box:/var/www/virtual/app.example.com/application/ # point storage/ and .env at the durable copies beside application/ ln -sfn ../storage /var/www/virtual/app.example.com/application/storage ln -sfn ../.env /var/www/virtual/app.example.com/application/.env ``` Excluding `.env` and `storage` from the sync matters: they are the two things that must not be overwritten — `.env` holds your credentials and lives only on the box, and `storage/` holds logs, caches and uploaded files you would otherwise wipe on every deploy. Because they sit outside `application/` and are symlinked in, the `--delete` can never reach them. Run `php artisan storage:link` once inside `application/` so `public/storage` points at the storage tree. Vendor dependencies (`vendor/`, `node_modules/`) either travel in the sync or, more usually, are installed on the box in the next step. **File ownership is handled for you** Every time the container starts it takes ownership of the site folder for the web user. You do not need to `chown` anything after copying files in — a restart settles it. ## Step 2 — work that runs inside the container The PHP image ships Composer, git and the PHP CLI, so dependency installs and framework commands run inside your own container, against the mounted folder. Run them from the app root, `/var/www/virtual//application`. Because the folder is at the same path inside and out, the commands read exactly as they would locally: ``` composer install --no-dev --optimize-autoloader php artisan migrate --force php artisan config:cache ``` Running these needs a shell in your container, which your operator grants. If you do not have that access, dependencies have to be vendored — committed or built elsewhere and synced up with everything else. ## Step 3 — the restart Use the restart action on your site in the panel. It clears the opcache by replacing the process, and it is also when newly added [background programs](background-jobs.html.md) are picked up. A restart takes a few seconds and does not touch your folder, your domain, your certificate, or your settings. **The symptom to recognise** If you have deployed a fix and the old behaviour persists — an error on a line you already corrected, a route that does not exist any more, a config value that will not change — do not go looking for a caching bug in your app. Restart the site first. This accounts for most "my deploy did not work" reports. The same goes for a stale `config:cache`: rebuild it *then* restart, in that order. ## Verifying Load a real page in a browser. As covered in [chapter 02](sites.html.md), the status dot in the panel goes green as soon as nginx answers, and knows nothing about whether your application actually runs — a site with a fatal error still reports up. If something is wrong, your logs are on the container's standard output: PHP errors, nginx errors, and anything your background programs print. Retrieving them means `docker logs` on the box, so ask your operator if you do not have that access. **A deployment runner is designed, not built** There is a design for CI running inside each site's own container — pushing to a repository would deploy into your folder, run your build commands, and reload PHP for you, with no manual restart. **None of it exists yet.** Until it does, the three steps above are the whole story. Treat any mention of automated deploys you may find in the project's design documents as a plan rather than a feature. [Previous03 · Choosing an image](images.html.md) [Next05 · Background work](background-jobs.html.md) --- # Background work > Queue workers and scheduled tasks, declared in your repository and run inside your own container. Queue workers, a scheduler, and one-shot startup tasks. There is no switch for these in the panel — you declare them as files in your own folder, which means they travel with your code. **Queue workers & scheduler are PHP-image only** The queue worker and scheduler need PHP, so they run on the **Laravel** image. The static (`web-base`) image does run a process supervisor, but the only file it honours is `boot.conf` — a one-shot startup task; `queue-worker.conf` and `scheduler.conf` do not apply there. ## Three filenames Create a directory `.nimbus/supervisor/` at the root of your app and put program definitions in it. Because these ship in your repo, they land at `/application/.nimbus/supervisor/`. The image honours **exactly three filenames** — nothing else is read. | File | Purpose | |---|---| | boot.conf | Runs once when the container starts, then exits. Use it for warm-ups or one-time setup. | | queue-worker.conf | Long-running queue workers, kept alive and restarted if they die. | | scheduler.conf | The task scheduler. | The rule is simply presence: **the file exists, the program runs. Delete it, and it stops.** There is no state stored anywhere in the panel and nothing to toggle. **Other filenames are ignored in silence** There is no wildcard matching. A file named `worker.conf` or `queue-worker.conf.bak` is never loaded — and never warned about either. If a program is not running, check the filename first, character by character. This is deliberate: a stray or planted `.conf` in a web-accessible folder must never become a running process. ## What a definition looks like These are supervisor program definitions. The image supplies no defaults, so each file must be complete on its own: ``` [program:queue-worker] command=/usr/bin/php /var/www/virtual/app.example.com/application/artisan queue:work --sleep=3 --tries=3 directory=/var/www/virtual/app.example.com/application user=www-data autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ``` Use **absolute paths** in `command`. Because your folder is mounted at the identical path inside the container, the literal site path is correct in both places — no guesswork about what the path looks like from the inside. Log to `/dev/stdout` and `/dev/stderr` as above. Everything the container prints is collected together, so your worker output lands beside nginx and PHP errors instead of in a file nobody reads. ## The rules your file must satisfy Every file is validated at container start. The checks are few and strict: 1. **It must parse as INI.** A syntax error means the file is rejected. 1. **Only program sections are allowed** — `[program:…]`, `[group:…]` or `[eventlistener:…]`. You cannot redefine the supervisor daemon itself. 1. **Programs run as `www-data`.** Leave `user=` out and it is filled in for you. Set it to anything else and the file is rejected outright rather than quietly rewritten. A rejected file is **skipped, never fatal**. The reason is printed to the container log and the rest of the site carries on serving. This matters: supervisor aborts on a bad config, so an unvalidated file would take nginx and PHP down with it and the site would go completely dark. One broken worker definition costs you that worker, not the site. ## Making a change take effect Programs are read **at container start**. Adding, editing or deleting a file does nothing until then. Restart the site from the panel — the same restart that clears the opcache after a deploy, so a deploy that adds a worker needs only the one restart. Recreating the container for a settings change, or switching image, also re-reads them. None of those operations ever modify or remove your `.nimbus/` directory. **Checking whether a program loaded** On start, the container logs one line listing which programs it loaded, and a separate `SKIPPED` line with the reason for each file it rejected. That log line is the fastest way to tell "my file has a typo in the name" from "my file has a typo inside it". Reading it needs log access from the box. You cannot manage these programs from inside the container — the supervisor control socket is restricted to root, and your shell is not. Restarting the site is the supported way to apply a change. [Previous04 · Getting your code on](deploying.html.md) [Next06 · Databases and Redis](services.html.md) --- # Databases and Redis > One MySQL database per site, shared Redis, and how the credentials reach your app. MySQL and Redis run alongside your site on the internal network. You reach them by name, from inside your container, with no ports and no host addresses involved. Your container no-app-example-com Connects outward by service name. Database mysql:3306 MySQL 8.0, shared. Cache and queues redis:6379 Redis 7, durable. ## Connecting The hostnames are literally `mysql` and `redis`. Neither publishes a port to the host, so they are unreachable from the public internet and from your laptop — only containers on the internal network can see them. In a Laravel `.env`, that comes out as: ``` DB_CONNECTION=mysql DB_HOST=mysql DB_PORT=3306 DB_DATABASE=your_database DB_USERNAME=your_user DB_PASSWORD=your_password REDIS_HOST=redis REDIS_PORT=6379 ``` The `mysql` and `redis` PHP extensions are both compiled into the image, so no further installation is needed. You can edit these values, and the rest of your `.env`, straight from the panel: open your site and use the **Environment** tab. It shows the live `.env` exactly as stored; save your changes there, then restart the site so it reads them. **Create your database from the panel** Open your site and go to the **Database** tab, then **Create database**. The panel makes a MySQL database and a user for you — the name and username are derived from your domain, the password is generated — and shows you all four values (database, username, password, host). No need to ask your operator. On a Laravel site you do not even have to copy them by hand: press **Apply to .env** and the panel writes the `DB_*` lines above straight into your `.env`, leaving everything else untouched. Then restart the site so it picks them up. You can **Reset password** at any time, or **Drop database** to delete it — dropping asks you to confirm because it permanently destroys the data. ## Redis is durable, not disposable Redis is configured to persist to disk and — importantly — **not** to evict keys when memory fills up. When it runs out of memory, writes fail loudly instead of data silently disappearing. That is a deliberate choice for queues and locks: a job quietly evicted from a queue is a job that never runs and never errors. The consequence for you is that **Redis is not a throwaway cache you can fill without thinking**. If you cache heavily, set expiry times on what you write. Unbounded, un-expiring writes will eventually cause failures rather than quietly making room. It is shared with other sites, so key prefixes matter. Laravel's default cache and queue prefixes derive from your app name — set `APP_NAME` to something distinctive and you get separation for free. ## phpMyAdmin If your operator has published it, phpMyAdmin is available for browsing and editing your database in the browser. Sign in with the **username and password from your site's Database tab** — the same credentials the panel generated. It is intended for site owners, not just the operator, so it is reasonable to ask for the address. **It is always IP-locked** phpMyAdmin is permanently restricted to unlocked IP addresses. From anywhere else it returns a 403 with no explanation — the same bare refusal described in [chapter 08](access.html.md). If you get one, unlock your IP address first, then reload. ## What else is on the network Your container can reach other containers by name, but there is nothing else you are expected to use. There is no shared object storage, mail relay, or search service provided by the platform. Anything else your app needs is an external service you configure yourself, reached over the internet as normal — outbound connections are not restricted. [Previous05 · Background work](background-jobs.html.md) [Next07 · Domains and HTTPS](domains.html.md) --- # Domains and HTTPS > Point DNS at the box and the certificate happens on its own. Extra domains are aliases. Certificates are automatic and you never handle one. The single thing that can go wrong is DNS, and it goes wrong in a specific, recognisable way. ## Publish in the right order Certificates are issued by proving control of the domain over HTTP, which means **the domain must already resolve to this box before you publish.** The order that works: 1. Point the DNS record at the box and wait for it to propagate. 1. Confirm it resolves — `dig +short app.example.com` should return the box address. 1. Publish the site in the panel. Publishing before DNS is correct is not destructive. The route is registered and the certificate simply stays pending, retrying in the background. The panel shows a DNS-not-ready warning on the site while that is the case. Once DNS resolves, the certificate is issued without further action from you. **Certificate pending looks like a broken site** While a certificate is pending, HTTPS requests fail — a browser warning or a connection error, depending on the client. The site is not broken and the container is fine. Check DNS first, and give it time before assuming something needs fixing. ## What you get **Certificates** : Issued and renewed automatically from Let's Encrypt. Nothing to install, configure or remember to renew. **HTTP** : Handled at the edge. Your container only ever sees plain HTTP on port 80 from the proxy — write your app to trust the forwarded protocol headers rather than checking the scheme directly. **Wildcards** : Not supported. Each domain gets its own certificate, so each one needs its own DNS record. **On a development box, expect a browser warning** Development installations typically use Let's Encrypt's staging environment, which issues certificates from an untrusted authority. The "your connection is not private" warning is expected there and does not indicate a misconfiguration. On production it should never appear. ## Extra domains A site can answer on more than one domain — `www.example.com` alongside `example.com`, a legacy domain, a vanity domain. These are added as aliases on the existing site rather than as separate sites. Each alias behaves as its own route with its own certificate, so **each one needs its own DNS record pointing at the box**. An alias whose DNS is not ready stays pending exactly as a primary domain would. Two properties worth knowing: - **Aliases inherit the site's lock state.** Locking a site locks every way of reaching it; you cannot bypass a lock by using an alias. - **Aliases never affect your folder.** The primary domain names the folder, and adding, removing or renaming aliases leaves the folder exactly where it is. An alias can be promoted to become the primary domain. That is a rename, not a rebuild — no downtime, no re-provisioning, and the folder does not move. Which means a site's folder name and its primary domain can legitimately differ after a promotion, and that is fine. **Domains are unique across everything** A domain can be claimed once — by one site, one alias, or one internal service route. Attempting to reuse one is refused at the point you enter it, with a message naming what already holds it, rather than failing later. ## Redirects between domains The platform routes domains to your site; it does not redirect between them. If you want `www.example.com` to redirect to `example.com` rather than serve the same content, add it as an alias and handle the redirect in your application — a middleware or a route check comparing the request host. Both domains reach your app, so the decision is yours to make in code. [Previous06 · Databases and Redis](services.html.md) [Next08 · When you get a 403](access.html.md) --- # When you get a 403 > Locked sites and locked folders answer only to unlocked addresses. How to get yours admitted. Sites and folders can be locked to a list of unlocked IP addresses. If yours is not on that list you get a refusal with no explanation — so the first job is recognising what you are looking at. ## How locking works A locked site or folder serves only requests from unlocked IP addresses. Everyone else gets a `403`, refused at the proxy before your application is ever consulted. Two shapes exist: - **The whole site** is locked — nothing is reachable except from an approved address. - **Specific folders** are locked, such as an admin area, while the rest of the site stays public. A whole-site lock supersedes the folder list. Locks apply to [aliases](domains.html.md) too, so there is no way around one by using a different domain. The list is global rather than per-site: an approved address passes everywhere it is allowed. It keeps working even if the control panel itself is stopped, because enforcement lives in the proxy and not in the panel. ## Telling the two 403s apart They look completely different, and the difference tells you what to do. | | A locked site or folder | The panel refusing you | |---|---|---| | What you see | A blank browser error page. No branding, no words, no links. | A styled Nimbus page reading *"You do not have access to this part of the panel."* | | What it means | Your IP address is not unlocked. | You are signed in, but your account does not have admin rights. | | What to do | Unlock your IP address — see below. | Nothing you can fix yourself. Ask your operator, or sign out if you used the wrong account. | The blank one is the one you can act on. It is deliberately bare — the refusal happens at the proxy, which knows nothing about who you are or that a panel exists. ## Unlocking your IP address The control panel's own domain is never lockable. That is the way back in, and it always answers. 1. Go to the panel's domain — ask your operator for it if you do not have it, and keep it somewhere you can find without a working site. 1. Sign in. The panel takes you straight to **IP unlock**. 1. Your current IP address is shown under **"Your address"**, with a line telling you whether it is unlocked. 1. Press **"Unlock my IP address"**. 1. Reload the site that refused you. It should serve immediately — the change takes effect right away, not after a delay. **You hold one IP address at a time** Unlocking replaces your previous IP address rather than adding to it. Moving between networks — office to home, or onto mobile data — means the old address stops working the moment you unlock the new one. If the page tells you that you are currently unlocked from a different address, that is exactly what has happened: you moved. Press the button again to move your entry to where you are now. **You cannot unlock an IP address you are not at** The page has no address field. It unlocks the IP address the request actually came from, and nothing else — there is no way to type in a colleague's address or a range. Anyone needing access must sign in themselves, from the place they need access from. The practical consequence: a VPN, a proxy, or a mobile connection that changes address needs unlocking again each time the address changes. ## What you can see in the panel If your account is a plain unlock account rather than an admin, the panel is small on purpose. You get the IP unlock page and a sign-out button — no site list, no settings, and no view of whose IP addresses are unlocked. Managing sites is done by your operator. If you land on a 403 inside the panel itself, the page always offers a way out: a link to IP unlock and a sign-out button. You should never need to clear cookies to escape. ## If it still refuses you - **Check the address actually changed.** The unlock page shows what the panel sees. If that does not match what you expect, something between you and the box — a VPN or corporate proxy — is rewriting it. - **Check you are hitting the right site.** A 403 from phpMyAdmin and a 403 from your own site look identical. - **If the panel warns that the change was saved but not enforced**, the proxy did not accept it. That is an operator problem, not yours — report it rather than retrying indefinitely. [Previous07 · Domains and HTTPS](domains.html.md) [Next09 · Limits and reference](reference.html.md) --- # Limits and reference > Paths, names, versions and the things the platform will not do for you. The numbers you will eventually hit, and a table for when something is not behaving. ## Request and upload limits | Setting | Value | What hitting it looks like | |---|---|---| | Maximum upload size | 64M | Large uploads rejected by the web server before PHP sees them | | POST body size | 64M | Same, for form submissions | | Maximum execution time | 60s | Long request killed mid-flight | | Request timeout at the web server | 60s | A `504` even if PHP is still working | | Maximum input variables | 5000 | Very large forms silently truncated | | Default memory limit | 256M | Fatal "allowed memory size exhausted" — adjustable per site | | Session lifetime | 7200s | Users signed out after two hours idle | Both timeouts are 60 seconds, so anything genuinely long-running belongs in a [queue worker](background-jobs.html.md) rather than a web request. Only the memory limit is adjustable per site. ## PHP environment **Version** : PHP 8.3, served by php-fpm behind nginx. **Extensions** : `mysql`, `mbstring`, `xml`, `bcmath`, `curl`, `zip`, `gd`, `redis`, `opcache`, `intl` **Tooling** : Composer, git, unzip, and the PHP CLI are available inside the container. **Timezone** : `UTC`. Set your application's own timezone in its configuration if you need another. **Error display** : Off. Errors are logged to the container output, never rendered to visitors. **Opcache** : Enabled, and does **not** re-check file timestamps. Restart the site after deploying PHP changes. **Concurrency** : Up to 20 PHP worker processes per site, recycled every 500 requests. ## Paths **Site folder** : `/var/www/virtual//` — identical inside and outside the container **Docroot, static image** : the folder itself **Docroot, PHP image** : `/application/public` **App root, PHP image** : `/application` — the deployed app (repo contents, `vendor/`, `artisan`); wiped and re-published on each deploy **Durable data, PHP image** : `/storage` (Laravel storage tree) and `/.env` (secrets) — kept outside `application/` and symlinked into it, so they survive a re-publish **Background programs** : `/application/.nimbus/supervisor/` — only `boot.conf`, `queue-worker.conf`, `scheduler.conf` **Health endpoint** : `/health` — reserved; do not define a route at this path ## Service addresses **MySQL** : `mysql:3306` — version 8.0. Database and credentials are provided by your operator. **Redis** : `redis:6379` — version 7. Persistent, does not evict; set expiry on cache entries. **Your site** : `no-` on the internal network, port 80. No published host ports. ## Troubleshooting | Symptom | Most likely cause | |---|---| | Deployed a fix, old behaviour persists | Opcache. Restart the site — see [chapter 04](deploying.html.md). | | Edited a Blade template, old markup still renders | Also opcache. The compiled template keeps the same filename, so the cached copy wins. Restart the site. | | Page says "image OK (no app deployed)" | Nothing at the docroot. On the PHP image your entry point must be at `application/public/index.php`. | | Status is Up but the site errors | Expected — the health check never runs your app. Check the container logs. | | Blank 403, no styling | Your IP address is not unlocked. See [chapter 08](access.html.md). | | Certificate warning or HTTPS failure after publishing | DNS not resolving to the box yet, so the certificate is still pending. On a dev box, an untrusted authority is normal. | | App cannot connect to the database | The database or user was probably never created — the panel does not create them. | | Queue worker not running | Filename must be exactly `queue-worker.conf`; then restart the site. Check the log for a `SKIPPED` line. | | Worker rejected on start | Invalid INI, a non-program section, or `user=` set to something other than `www-data`. | | Uploads fail over ~64 MB | The upload limit. Not adjustable per site. | | Request dies at 60 seconds | Execution or gateway timeout. Move the work to a queue. | | Assets stale after deploy | Static files carry a one-year immutable cache header. Fingerprint asset filenames. | ## What this platform does not do Stated plainly, so you can plan around it rather than searching for a setting that is not there: - No deploy pipeline, git integration, or build step. Files are copied in and the site is restarted. - No database or user provisioning. Ask your operator. - No custom environment variables beyond the settings an image declares. - No wildcard certificates. Every domain needs its own DNS record. - No backups of your site folder as part of the platform. Confirm with your operator what is backed up. - No self-service accounts, password reset, or email — credentials are handed to you directly. - No log viewer in the panel. Logs are read from the box. **Designed but not built** The project has designs for in-container CI that would deploy on push and reload PHP for you, and for automatic banning of probing addresses. Neither exists in the running platform. If you come across them in the project's design documents, read them as intent rather than as behaviour you can rely on. [Previous08 · When you get a 403](access.html.md) [Next10 · Deploy on push](ci.html.md) --- # A push to your repository deploys the site. > Turning on the in-container runner, the reference workflows, and why `if: always()` matters. Chapter 04 got your code on by hand. This is the automated version: connect the site to a Gitea repository once, and every push runs your build and publishes it — with visitors seeing a short maintenance page instead of a half-updated site. ## How it runs, and why that is safe When deployments are enabled, a runner is registered for your repository and runs **inside your site's own container**. That means it already has your app's tooling — PHP, Composer, the bundled Node — and it reloads the site through the container's own supervisor. It never touches the box's Docker, and a push to your repo can only ever affect **your** site. There is no shared build farm to queue behind and nothing your job can reach beyond its own container. ## Turning it on Enabling is an operator action in the panel — on your site's **Deployments** page they pick your repository and switch it on. You do not need a panel account for the deploy itself; you interact with it entirely through your repository. Two things come back to you from that page: 1. A **runner label** — the value your workflow targets with `runs-on:`. It is your site's container name, e.g. `no-app-example-com`. 1. Confirmation that the runner is registered and idle, waiting for your first push. Because the runner is registered *before* your repo has anything in it, your very first push is an ordinary deploy — there is no separate "seed the folder" step. Checkout authenticates automatically; you do not add a deploy key or store any secret. ## The workflow Copy the example matching your image into your repo as `.gitea/workflows/deploy.yml` and fill in the label and your domain. The full files are in `.gitea/workflows-examples/` (a Laravel one and a static one). The shape is the same for both: ``` jobs: deploy: runs-on: no-app-example-com # your runner label steps: - run: nimbus deploy:begin # maintenance page on, workers stopped - uses: actions/checkout@v4 # … install, build, publish, migrate … - run: nimbus deploy:end # new code live, workers back, page off if: always() ``` **Building this with an AI agent?** There is a companion file written for coding agents, `AGENTS.md` (next to this guide). It states the whole deploy model, the exact facts an agent needs from you — the runner label, the domain, the image type — and ready-to-adapt workflow templates. Drop it into your repository as `AGENTS.md`, or point your agent at it, and it can write the workflow itself. ## The two hooks, and the one rule `nimbus deploy:begin` and `nimbus deploy:end` bracket the risky middle of a deploy. Between them the site answers every request with a **maintenance page** (a 503), so no visitor ever sees a half-published folder or a half-run migration, and your background workers are stopped so none runs old code against a freshly migrated database. `deploy:end` then reloads PHP — which is what makes your new code actually take effect — restarts the workers, and drops the maintenance page. **Always close the window** The closing hook **must** carry `if: always()`. Without it, a step that fails — a red test, a broken build — would stop the workflow before `deploy:end` runs, and your site would sit behind the maintenance page. With it, the page always lifts and visitors get the previous working site back. As a safety net the platform also clears a stuck window automatically after a few minutes, but do not rely on that — it is the last resort, not the plan. ## What you do not have to do No restart from the panel after a normal deploy — `deploy:end` already reloaded PHP, so the opcache is reset and your new code is live. Even a push that adds a background worker for the first time takes effect without a restart. A restart is only needed the first time deployments are enabled, and that is the operator's one click. ## When it does not deploy - **The maintenance page stays up.** A job failed after `deploy:begin` and either did not reach `deploy:end` or you omitted `if: always()`. Fix the workflow; the window clears itself after the deadline in the meantime. - **Nothing happens on push.** Check the branch in your workflow's `on: push` matches the branch you pushed, and that `runs-on:` is exactly the label from the Deployments page. - **A change is not visible.** Confirm your publish step actually wrote into the live folder (`/var/www/virtual/`) — the runner's checkout lands in a work directory, not the docroot, until you copy it across. [Previous09 · Limits and reference](reference.html.md) [Back to01 · Start here](index.html.md)