How to prepare your project for the panel
This guide is for whoever is going to publish their own project on the panel: you, someone helping you or
an AI assistant. It says what the panel expects to find in the repository, what to prepare for each kind of
project and gives files ready to copy. The examples use example.com and obvious example passwords: replace
them with your own.
1. Before you start
Section titled “1. Before you start”What you need
Section titled “What you need”- The code in a Git repository (GitHub, GitLab or another that accepts
https://or SSH). It can be public. If it is private, the panel needs a read-only credential: a token with theContents: Readpermission for that repository only, or your GitHub account connected in Settings → Integrations. When you create the project, the Test access button checks that the panel can read it, without creating anything. - The branch that goes live (the default is
main). The panel clones only that branch. Code that is not on it is not published. - A domain, if you want your own address. Without a domain, the panel creates an automatic address with
HTTPS for testing. With a domain, create an A record at your DNS provider pointing to the VPS IP, for the
name you type and also for the pair with and without
www(@andwww; the pair only exists for the main domain, such asexample.com: a subdomain such asshop.example.comhas no pair). On Cloudflare, keep the cloud grey (DNS only). With the orange cloud the certificate is not issued. The screen shows the exact records. More in Features and in Troubleshooting. To connect a subdomain later (Project → Domains → Connect new domain), pick the parent domain and type only the label:apibecomesapi.example.comandapp.v2becomesapp.v2.example.com. The screen shows “Will be connected as …” before you save. A full hostname that already ends with the parent is not duplicated, and@means the parent itself. Choose Full hostname to connect an unrelated domain. The domain is saved only when you click Connect; nothing is saved before that. While a request is running the window cannot be closed, so you always see the result.
What the panel does for you
Section titled “What the panel does for you”- Downloads the code (
git clone) and updates it on every deploy. - Builds and starts the containers (Docker).
- Issues and renews the HTTPS certificate by itself (Caddy) and connects the domain to your site, with WebSocket support (tick the option when you create the project).
- Stores the variables and hands them to the project.
- Connects the project’s email, if you enable it (
SMTP_*andMAIL_*variables). - Makes backups and shows the logs of each service.
What the panel does NOT do
Section titled “What the panel does NOT do”- It does not use the system’s PM2, nginx or Apache. Everything runs in containers. Do not install anything on the VPS by hand.
- It does not run loose commands from your project. Whoever installs, compiles and starts is the
Dockerfileor compose file that you write. There is no “runnpm startafterwards”. - It does not read the
.envon your computer. If it is in.gitignore(as it should be), the panel never sees it. You import the variables through the Variables screen (see section 5). - It does not update by itself. After a
git push, click Deploy. The panel only warns you that there is a new commit.
2. How the panel decides what to do
Section titled “2. How the panel decides what to do”When it detects the project, the panel looks at the main folder (the repository root) and uses the first rule that fits:
- Compose. If one of these files exists, in this order, the panel adopts the first one it finds:
compose.paas.yml,compose.paas.yaml,compose.prod.yml,compose.prod.yaml,compose.production.yml,docker-compose.prod.yml,docker-compose.prod.yaml,docker-compose.production.yml,compose.yml,compose.yaml,docker-compose.yml,docker-compose.yaml. It never rewrites your file. - Dockerfile without
package.json. It becomes a single container. The port comes from theEXPOSEline of the last stage (in a multi-stage Dockerfile, the stage that becomes the final image, not the build one). package.json.- With a
Dockerfile, theDockerfilehas priority, even for Vite or Next withoutput: "export". The panel warns that the Dockerfile was chosen. If you prefer the static site without Docker, choose the type by hand (see below). - Without a
Dockerfile, and with abuildscript: if it is Vite, Next.js withoutput: "export", orout/,dist/orbuild/with anindex.htmlis already in the repository, the panel runs the build and publishes the folder as a static site. With no clues, it assumes the build producesdist/(it may be wrong). - A
package.jsonwithout abuildscript but with aDockerfilealso becomes a Dockerfile project.
- With a
index.htmlin the root, no build: a static site published as is.
If none of this fits, the screen says “The panel doesn’t know how to deploy this code yet” and lists what it publishes.
Choosing the type by hand. The wizard’s detection screen has a Project type field. Detection is usually
right, but if the code allows more than one way (for example, Vite with a Dockerfile), you choose. The panel
checks that the code has what the type needs (a Dockerfile for the Dockerfile type, a compose file for the
compose type, and so on) and refuses with an explanation if it does not. Note: choosing static site publishes every non-hidden
file in the folder; if there is a Dockerfile, a compose file, package.json or *.php in the root, the screen
shows a critical warning, because those files would go public as text. The choice is saved on the project; if
the code stops fitting it, the panel goes back to automatic detection and says so.
My project is… what to prepare
Section titled “My project is… what to prepare”| My project is… | Prepare in the repository | Recipe |
|---|---|---|
| A page in HTML, CSS and JS without a build | index.html in the root | 3.1 |
| React, Vue or Svelte with Vite | package.json with a build that produces dist/ and vite in the dependencies | 3.2 |
| Next.js with static pages only | next.config with output: "export" | 4 |
| Next.js with a server (API, SSR) | Nothing: the panel generates the Dockerfile (or use your own Dockerfile/compose) | 4 and the Next.js guide |
A single-service app (Python, Go, Java…), no database and no package.json | Dockerfile with EXPOSE and .dockerignore | 3.3 |
A single-service Node app without a build script | Dockerfile (the panel recognizes it) | 3.3 |
| An app with a database, cache or more than one service | compose.prod.yml | 3.4 |
| Laravel (PHP) with MySQL or PostgreSQL | Dockerfile + compose.prod.yml | 3.5 |
| WordPress, n8n, Metabase and similar | compose.prod.yml with the official image | 3.4 |
3. Recipes
Section titled “3. Recipes”3.1 Static site (plain HTML)
Section titled “3.1 Static site (plain HTML)”my-site/├── index.html├── style.css└── img/logo.pngThat is all. The panel copies the files to a web server (nginx) without hidden files or folders: .git,
.env and the like do not go live (only .well-known is kept, useful for domain verifications).
- Links between pages work as files:
about.html,contact/index.html. - A page that does not exist returns 404 (there is no “if not found, show
index.html” rule). For plain HTML that is right, and the single-page option stays off.
3.2 Static site with a build (Vite, React)
Section titled “3.2 Static site with a build (Vite, React)”package.json:
{ "name": "my-site", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", "vite": "^5.4.0" }}What the panel does: runs npm install and npm run build in a Node 22 container and publishes the dist/
folder. The package manager comes from the packageManager field of package.json; without it, from the lock
file (pnpm-lock.yaml, yarn.lock), and last it is npm.
Things to watch:
- Commit the lock file (
package-lock.json,pnpm-lock.yamloryarn.lock) so the build is always the same. - Browser-side routes (React Router etc.): tick Single-page application (on the wizard’s detection
screen, and later in the project’s Settings). The server then answers
index.htmlfor any path that is not a file (try_files $uri $uri/ /index.html), and openinghttps://example.com/dashboarddirectly, or pressing F5, works. The panel comes with the option on when it finds a bundler (Vite, Create React App) with React, Vue, Svelte or similar and no server; for plain Vite and plain HTML it comes off (tick it by hand if your site has browser-side routes). The change takes effect on the next deploy. - Build variables (
VITE_...): in the Variables tab, tick build on the ones the build must read. Only the ticked ones enter the static build; the others never reach it. They end up in the site’s JavaScript, visible to any visitor: use them only for public values (such as your API address) and never for a password or secret key. Names with theVITE_,NEXT_PUBLIC_,PUBLIC_andREACT_APP_prefixes get a suggestion to tick. A committed.env.productionkeeps working too.
If you prefer to serve dist/ with your own nginx configuration, publish with a compose.prod.yml (it has
priority over the Vite detection) and your own Dockerfile:
FROM node:22-alpine AS buildWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM nginxinc/nginx-unprivileged:1.27-alpineCOPY nginx.conf /etc/nginx/conf.d/default.confCOPY --from=build /app/dist /usr/share/nginx/htmlEXPOSE 8080This Dockerfile uses npm ci, which requires package-lock.json. With pnpm, replace the install lines with
COPY package.json pnpm-lock.yaml ./ and RUN corepack enable && pnpm install --frozen-lockfile; with yarn, with
COPY package.json yarn.lock ./ and RUN corepack enable && yarn install --frozen-lockfile.
.dockerignore (keeps .env.production, which only has public values):
node_modulesdist.git.env.env.localnginx.conf:
server { listen 8080; root /usr/share/nginx/html; location / { try_files $uri /index.html; }}compose.prod.yml:
services: web: build: . image: example-site:1.0 expose: - "8080" restart: unless-stoppedThe panel suggests the Service port from expose (8080 here). Check it on the detection screen.
3.3 App with a Dockerfile
Section titled “3.3 App with a Dockerfile”For an app with a single service and no database, no package.json in the root (Python, Go, Java,
Ruby…). Example for Python with Flask and gunicorn (it works the same for any language: what matters is the
EXPOSE and listening on all addresses). Put gunicorn in requirements.txt:
FROM python:3.12-slimWORKDIR /appENV PYTHONUNBUFFERED=1
COPY requirements.txt ./RUN pip install --no-cache-dir -r requirements.txtCOPY . .
# runs without being rootRUN useradd --create-home appUSER app
EXPOSE 8000CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"].dockerignore (avoids copying junk and secrets into the image):
.git.env.env.*.venv__pycache__Rules to make it work:
- Write
EXPOSE <port>with the port the app listens on. The panel takes the proxy port from it. WithoutEXPOSE, the screen asks for the port and you fill in Service port. - The app must listen on
0.0.0.0, not on127.0.0.1/localhost. On localhost only the container itself can reach it and the site returns a 502 error. - Run without being root (
USER app, after creating the user, as in the example). It helps security, and the panel already drops extra privileges from the container. - Do not store data inside the container. In this mode there is no volume: whatever the app writes to disk is lost on every deploy. If you need persistent files or a database, use recipe 3.4.
- The project’s Variables reach the container as environment variables (in Python,
os.environ["NAME"]; in Node,process.env.NAME). Only the ones ticked build go todocker buildas--build-arg, and only those the Dockerfile declares withARG. Do not tick a secret as “build”: the value may stay in the image layers.
Node app without a build script. A package.json without build, but with a Dockerfile, is recognized
as a Dockerfile project (the port comes from EXPOSE). The Dockerfile:
FROM node:22-alpineWORKDIR /appENV NODE_ENV=production
COPY package*.json ./RUN npm ci --omit=devCOPY . .
# runs without being rootUSER node
EXPOSE 3000CMD ["node", "server.js"]The .dockerignore is the one above (replace .venv and __pycache__ with node_modules). The Dockerfile
alone is enough, as in section 4; if you prefer a one-service compose.prod.yml, it also has
priority and works.
3.4 App with a database: compose.prod.yml
Section titled “3.4 App with a database: compose.prod.yml”When the project has more than one service (app, database, cache), write a compose.prod.yml in the root.
Complete example for Node + PostgreSQL + Redis:
services: app: build: . image: example-app:1.0 restart: unless-stopped expose: - "3000" environment: NODE_ENV: production PORT: "3000" DATABASE_URL: postgres://app:${DB_PASSWORD:?set DB_PASSWORD in Variables}@db:5432/app REDIS_URL: redis://cache:6379 SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET in Variables} volumes: - uploads:/app/uploads depends_on: db: condition: service_healthy cache: condition: service_started # migrations before starting; replace with your tool's command command: ["sh", "-c", "npm run migrate && node server.js"]
db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: app POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in Variables} POSTGRES_DB: app volumes: - dbdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U app -d app"] interval: 5s timeout: 3s retries: 10
cache: image: redis:7.2-alpine restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] volumes: - cachedata:/data
volumes: uploads: dbdata: cachedata:The Dockerfile and .dockerignore are the Node ones from recipe 3.3.
Why it is written this way (each item avoids a block or a common problem):
- Internal network. Do not declare
networks:. Compose already creates a private network for the project, and the services talk to each other by name (db,cache). The panel connects only the web service to the proxy network by itself. exposeinstead ofports.exposeonly opens the port to the other containers.portswould publish on the VPS: a database one (5432, 3306, 6379…) is blocked, and the panel removes 80/443 by itself.- Which service gets the site. The panel picks the service by name (
web,app,frontend,site…) or by image (caddy,nginx,traefik). Give the site’s service one of those names. Check the “Web service” and “Service port” on the detection screen: the panel uses the firstexposeport (or the internal port ofports) and only falls back to 80, with a note, when the service declares none. - Named volumes (
dbdata:) keep the data and survive deploys. Do not use./folder:/datafor a database. Folders inside the project (./uploads:/app/uploads) are accepted, but a named volume is simpler. healthcheck+depends_on: condition: service_healthymakes the app wait for the database to be ready.- Migrations in the
command(or in anentrypoint): the database starts empty on the first deploy. - Passwords outside the file:
${DB_PASSWORD:?message}has no value in the repository and blocks the deploy with a clear message if the variable is not filled in. Generate long values (openssl rand -hex 24; hexadecimal avoids characters that break the database URL). - Pinned versions on the images (
postgres:16-alpine).:latestor no version only gives a warning, but makes the deploy unpredictable. - The image name is yours (
example-app:1.0). Do not useimage: node:22next tobuild:(nornginx,alpine,caddy): it is blocked.
Variations:
- MySQL instead of PostgreSQL:
image: mysql:8.4, volume at/var/lib/mysql, variablesMYSQL_DATABASE,MYSQL_USER,MYSQL_PASSWORDandMYSQL_ROOT_PASSWORD, and healthcheck["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -p\"$$MYSQL_ROOT_PASSWORD\" --silent"]($$is how compose writes$). - Ready-made product (WordPress, n8n, Metabase…): use the official image, the same layout (web service + database + named volumes) and the variables from the product’s documentation.
- Application plus a database only: delete the
cacheservice, theREDIS_URLand thecachedatavolume.
3.5 Laravel (PHP) with MySQL and Redis
Section titled “3.5 Laravel (PHP) with MySQL and Redis”Four files. The Dockerfile uses Apache with PHP, pointing at public/, and builds the Vite assets (a default
Laravel uses @vite in its views; without the public/build folder the page returns a 500 error). If your
project does not use Vite, delete the first stage and the line that copies public/build:
FROM node:22-alpine AS assetsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run build
FROM composer:2 AS vendorWORKDIR /appCOPY composer.json composer.lock ./RUN composer install --no-dev --no-scripts --no-autoloader --prefer-distCOPY . .RUN composer dump-autoload --no-dev --optimize
FROM php:8.4-apacheRUN docker-php-ext-install pdo_mysql opcache \ && pecl install redis-6.1.0 && docker-php-ext-enable redis \ && a2enmod rewrite \ && sed -ri 's!/var/www/html!/var/www/html/public!g' /etc/apache2/sites-available/*.conf /etc/apache2/apache2.conf /etc/apache2/conf-available/*.confWORKDIR /var/www/htmlCOPY --from=vendor /app ./COPY --from=assets /app/public/build ./public/buildCOPY docker/entrypoint.sh /usr/local/bin/entrypoint.shRUN chmod +x /usr/local/bin/entrypoint.shEXPOSE 80ENTRYPOINT ["entrypoint.sh"]CMD ["apache2-foreground"]This Dockerfile is the exception to the “run without being root” rule of recipe 3.3: the official Apache starts as
root only to open port 80 and serves requests as www-data. The panel still drops extra privileges from the
container. If a Vite plugin reads files from vendor/ (Livewire, for example), copy vendor into the assets
stage too.
.dockerignore (the panel already takes out the .env it writes, but your .dockerignore protects builds made outside the panel):
.git.env.env.localnode_modulesvendorstorage/logsThis .dockerignore keeps .env.production, where the public VITE_* values the Vite build reads live (or tick them build in Variables and declare ARG in the assets stage). Never put a secret there.
The assets stage uses npm ci, which requires package-lock.json: a new Laravel only has it after the first npm install, so run it and commit the file. With pnpm or yarn, replace the install lines as in the static Vite site (recipe 3.2).
docker/entrypoint.sh (creates the folders the volume leaves empty and runs the migrations):
#!/bin/shset -emkdir -p storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cachechown -R www-data:www-data storage bootstrap/cachephp artisan config:cachephp artisan migrate --forceexec "$@"compose.prod.yml:
services: web: build: . image: example-laravel:1.0 restart: unless-stopped expose: - "80" environment: APP_ENV: production APP_DEBUG: "false" APP_URL: https://example.com APP_KEY: ${APP_KEY:?set APP_KEY in Variables} DB_CONNECTION: mysql DB_HOST: db DB_PORT: "3306" DB_DATABASE: app DB_USERNAME: app DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in Variables} REDIS_CLIENT: phpredis REDIS_HOST: cache CACHE_STORE: redis SESSION_DRIVER: redis volumes: - storage:/var/www/html/storage depends_on: db: condition: service_healthy
db: image: mysql:8.4 restart: unless-stopped environment: MYSQL_DATABASE: app MYSQL_USER: app MYSQL_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in Variables} MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?set DB_ROOT_PASSWORD in Variables} volumes: - dbdata:/var/lib/mysql healthcheck: test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -p\"$$MYSQL_ROOT_PASSWORD\" --silent"] interval: 5s timeout: 3s retries: 20
cache: image: redis:7.2-alpine restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] volumes: - cachedata:/data
volumes: storage: dbdata: cachedata:Variables to fill in: APP_KEY (generate it on your computer with php artisan key:generate --show),
DB_PASSWORD and DB_ROOT_PASSWORD (different, long values). For email, see
section 5.
Prefer the predis/predis package? Install it with Composer, set REDIS_CLIENT: predis and remove the pecl install from the Dockerfile.
With PostgreSQL: image: postgres:16-alpine, DB_CONNECTION: pgsql, port 5432, the pdo_pgsql extension
(apt-get install -y libpq-dev before docker-php-ext-install) and the healthcheck from recipe 3.4.
4. Next.js
Section titled “4. Next.js”Option A: static site. In next.config.js (or .mjs, .ts):
/** @type {import('next').NextConfig} */const nextConfig = { output: "export", images: { unoptimized: true },};export default nextConfig;The panel recognizes output: "export", runs the build and publishes the out/ folder. There is no server: do
not use API routes, getServerSideProps or middleware.
Option B: with a server (API, SSR). Use standalone mode and a Dockerfile of your own. In next.config:
const nextConfig = { output: "standalone" };export default nextConfig;Dockerfile (runs without being root; if the project has no public/ folder, delete the line that copies it):
FROM node:22-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci
FROM node:22-alpine AS buildWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN npm run build
FROM node:22-alpine AS runWORKDIR /appENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0RUN addgroup -S app && adduser -S app -G appCOPY --from=build --chown=app:app /app/.next/standalone ./COPY --from=build --chown=app:app /app/.next/static ./.next/staticCOPY --from=build --chown=app:app /app/public ./publicUSER appEXPOSE 3000CMD ["node", "server.js"].dockerignore (the panel already takes out the .env it writes; .env.production, with public values only, stays):
node_modules.next.git.env.env.localHOSTNAME=0.0.0.0 is mandatory: without it Next listens only on localhost and the site returns 502. With a
package.json that has build and no output: "export" and no vite, the panel uses this Dockerfile and
reads the port from EXPOSE.
If the app uses a database, write a compose.prod.yml like the one in recipe 3.4
with build: . on the site’s service. Mind the NEXT_PUBLIC_... variables: Next bakes them into the code
during the build. Tick them build in the Variables tab and declare ARG NEXT_PUBLIC_... in the Dockerfile: the panel passes
them as --build-arg (and as build.args in compose). Or keep only public values in a committed .env.production.
5. Variables and secrets
Section titled “5. Variables and secrets”How it works
Section titled “How it works”- In the project, open the Variables tab. The screen lists the variables the compose file asks for
(
${NAME}) and suggests the ones from the repository’s.env.example, without a value, until the first successful deploy. After it, new names in.env.exampleshow up in a notice, for you to add (it counts once you save) or dismiss. - Have a
.envon your computer? Click Import .env file, review it and click Save variables. Nothing goes to the server before you save. - Run the Deploy. Variables only take effect on the next deploy.
Golden rules
Section titled “Golden rules”- Never commit the
.envor write passwords in the code, in theDockerfileor in the compose file. Put.envin.gitignore. A committed secret must be considered leaked: replace it. The panel warns when it finds something that looks like one (secret-in-code). - Commit a
.env.examplewith only the names and fake values (DB_PASSWORD=replace-with-a-long-value). It feeds the suggestions on the screen. - Mark the required ones with
${NAME:?message}. If a value is missing, the deploy stops before starting and says which one is missing. Without the:?, an empty variable slips through silently and the app breaks later.
What the panel writes, and where
Section titled “What the panel writes, and where”- The variables are stored encrypted on the panel’s server.
- On deploy, in a compose project, the panel writes a
.envfile in the project folder on the VPS (restricted permissions, outside Git) with the header “Generated by the panel”. Compose uses it to resolve${NAME}andenv_file: .env. If your repository already has a committed.env, the panel does not overwrite it: it writes the variables separately and says so in the deploy log. - In a Dockerfile project, they become the container’s environment variables. The ones ticked build and
declared with
ARGin the Dockerfile also go todocker build. - In a static site without a build, they are not used (there is no server to read them). With a build (Vite etc.), only the ones ticked build enter the build.
- “Build” = public. A ticked variable goes into the site’s JavaScript or the image layers. The screen warns
you (also that the value stays in the image history,
docker history, and that--build-argshows up in the server’s process list during the build). Never tick a password or secret key; names that look like secrets (KEY,TOKEN,SECRET…) get an extra warning. Reserved names (PATH,NODE_*,DOCKER_*,GIT_*…) and values with line breaks cannot be ticked. - The
.envthe panel writes stays out of the build (see the warning in section 3). - The project folder on the VPS is described in Where your projects’ files live.
Project email
Section titled “Project email”If your app sends email, enable the project’s Email tab (it needs the mail server
configured). On every deploy the panel hands the app SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS,
MAIL_FROM and MAIL_FROM_NAME (the display name; if you do not set one, the project name is used).
Does your app read other names (MAIL_HOST, SMTP_SENHA, EMAIL_DE)? In Link to project variables,
choose, for each app name, the email value it receives. In the compose file, write the app’s name as required
(for example MAIL_HOST: ${MAIL_HOST:?}): the link counts as filled in. The link wins over a value saved
under the same name in Variables.
Databases and caches stay out of the email network. The email network is shared by every project with email
enabled; attaching your database to it would make it reachable from other projects’ containers. So the panel
attaches to the email network (and hands the SMTP_* values to) only the services that may send mail: app, worker,
scheduler and other non-database services. Services with a recognised database/cache image (MySQL, MariaDB,
Postgres, Mongo, Redis, Valkey, SQL Server, ClickHouse and similar), and services with a build: whose Dockerfile
ends FROM one of those images, are left out. Your app still talks to the database normally, over the project’s own
network. The deploy log lists which services joined and which were left out, and why.
Do you have a database-image service that really sends email? Mark it with the tws.mail=true label in the
compose file (labels: ["tws.mail=true"]) and it joins. The tws.mail=false label does the opposite: it removes a
non-database service from the email network. If build: uses target:, the target stage is what counts. For projects
already running: on restart the panel disconnects from the email network any database with a recognised image that an
older deploy had attached (the project’s own network is never touched); the rest stay attached until the next
deploy, which decides and stamps tws.mail.decided. Note: because of this new label, the first deploy after the upgrade recreates every container, including the database (data in volumes is kept).
6. Rules that block the deploy
Section titled “6. Rules that block the deploy”Before starting, the panel checks the compose file. What a hosting service should not ask Docker for is a block and the deploy stops, with the explanation on screen and in the log. Summary and how to fix each (the full table, with all the fields and examples, is in Compose rules):
| If your compose file… | Rule | How to fix |
|---|---|---|
publishes a database port ("5432:5432", "3306:3306", "6379:6379"…) | db-port-exposed | Delete the ports line. The other services reach the database as db:5432. |
has privileged: true, cap_add, devices, network_mode: host, pid: host… | privileged-container, host-namespace | Remove the field. A web app rarely needs it. |
mounts /var/run/docker.sock or another server folder (/srv/data:/data, ~/x) | panel-resource, host-mount | Do not mount the Docker socket (no override possible). For data, use a named volume (data:/data) or a folder inside the project (./data:/data). |
uses a trivial password (password, admin, root, 123456, changeme…), under 6 characters, or a user equal to the password, written in the file or coming from a saved Variable (${DB_PASSWORD} set to 123) | weak-credentials | Use ${DB_PASSWORD:?...} and a long random value in Variables. The user must differ from the password. The message names only the Variable, never its value (whoever sees the report learns that the Variable is weak, but not its value). An existing project with a weak saved password is blocked on the next deploy. |
publishes 80:80 or 443:443 | proxy-port-conflict | Remove it. The panel already handles HTTPS (it is only a block if the project brings its own HTTPS proxy). |
uses container_name/hostname with a panel name (tws-panel, paas-caddy…) or starting with paas-/tws- (the same goes for the web service’s name), a volume or network name: of the panel, or image: node next to build: | reserved-name, panel-resource, panel-image | Pick another name (or remove container_name). Do not use name: on volumes and networks. |
| uses a field the panel does not know | unsupported-field | Remove the field named in the message. |
Each message shows the file, the service and the exact spot, plus a suggestion. For what each rule accepts and rejects, and the difference between what can and cannot get an override, read Compose rules. See also Deploy blocked by guardrail.
7. Checklist and “it went wrong, now what?”
Section titled “7. Checklist and “it went wrong, now what?””Before the first deploy
Section titled “Before the first deploy”- The code is in Git and on the branch I will publish (
git pushdone). - There is a
compose.prod.yml,Dockerfile,package.jsonwithbuildor anindex.htmlin the root, according to the table. - The app listens on
0.0.0.0on the port I declared (EXPOSEorexpose). - No database published in
ports; noprivileged,docker.sockor server folder mounted. - No password written in the files.
.envis in.gitignoreand a.env.exampleexists. - Important data is in named volumes.
- Image versions pinned (
postgres:16-alpine, notlatest). - Domain: A records (and
www) pointing to the VPS, grey cloud on Cloudflare. (You can create the project before DNS propagates.) - Private repository: the Test access button said “Access confirmed”.
- In the Variables tab, all the required ones are filled in.
- When creating, the Security guardrails list shows no red items (yellow and grey ones only warn).
- The
DockerfilewithCOPY . .has a.dockerignore(the panel already takes out the.envit writes, but yours protects builds outside the panel). - Site with browser-side routes (React Router etc.): the Single-page application option is on.
- Variables read by the static build are ticked build and are public.
It went wrong, now what?
Section titled “It went wrong, now what?”- Read the deploy log (Deploys tab, open the latest). A deploy has 5 steps: code ingestion, preparation, build and start, proxy and health check. The step that failed tells you where to look. When a compose fails, the log has a Por que falhou block (written in Portuguese) with the last lines of the sick service.
- Look at the Logs tab for what the app wrote after starting: the state of each service (running, healthy, restarts) and the log lines, with errors marked. Details in Features. Secrets are hidden only on a best-effort basis: do not make the app write passwords to the log.
- Common messages:
faltam variáveis obrigatórias do compose: NAME(required compose variables are missing): fill them in under Variables and deploy again.Deploy bloqueado: N violação(ões)(deploy blocked): fix what the window shows (section 6).Tipo de projeto desconhecido(unknown project type): there is nocompose,Dockerfile,package.jsonwithbuildorindex.html(section 2).Informe a porta exposta pelo container(give the container’s exposed port): the last stage of theDockerfilelacksEXPOSE; fill in Service port.health check falhou(health check failed): the app did not answer in time (the panel waits about 1 minute for HTTP and up to 90 seconds for HTTPS). Look at the Logs tab. The most common causes: the app listens only onlocalhost, the proxy port is wrong, or the migrations take longer than the deadline. A 4xx on the root does not fail the deploy; a 5xx does.- Certificate not issued: almost always wrong DNS or an orange cloud on Cloudflare (Troubleshooting).
- Still stuck? Troubleshooting and Help and feedback.
The panel’s server messages above (deploy log, guardrail cards) are written in Portuguese even when the interface is in English; they are quoted here exactly as they appear.
8. Using an AI assistant
Section titled “8. Using an AI assistant”If an AI assistant (Claude, ChatGPT, Cursor, Copilot…) is helping you organize the repository, paste the text
below into the conversation, inside the project folder, and let it work. Replace what is between <...>.
Prepare this repository to be published on TWS Panel (a self-hosted PaaS that runs everything in Dockercontainers, with automatic HTTPS through a central Caddy). Only change the repository; do not run anything ona server and do not read or show the contents of .env files.
How the panel decides what to do, in the repository root, using the first rule that fits:1. If compose.paas.yml, compose.prod.yml, compose.production.yml, docker-compose.prod.yml, compose.yml or docker-compose.yml exists (in this order), it is adopted without being rewritten.2. Dockerfile without package.json: one container, port from the EXPOSE of the last stage.3. package.json: if a Dockerfile exists, the Dockerfile has priority (even for Vite or Next export), with a warning; a package.json without a "build" script but with a Dockerfile also uses the Dockerfile. Without a Dockerfile: if it has `next` without output "export", it is Next.js with a server (the panel generates the Dockerfile). With a "build" script: Vite, Next with output "export", or out/dist/build with an index.html is a static site (build on Node 22, folder served by nginx).4. index.html in the root without a build: static site.The type can also be chosen by hand on the detection screen (the panel checks that the code fits).
Mandatory rules:- Project type: <static site | app with Dockerfile | app with database | Next.js (export or server) | Laravel>. App port: <3000>. Database/cache: <PostgreSQL | MySQL | Redis | none>. Example domain: example.com.- The app listens on 0.0.0.0 (not localhost), on the port declared in EXPOSE (Dockerfile) or expose (compose).- Dockerfile: pinned base image version, non-root process (USER; exception: the official PHP Apache image, which serves as www-data), no secrets in ENV/ARG. Every Dockerfile with "COPY . ." needs a .dockerignore that excludes .env (and .git, node_modules, vendor); the panel already takes the .env it writes out of the build, but the .dockerignore protects builds outside the panel. Do not name the built image node, nginx, alpine, caddy, tws-panel or paas-*.- With more than one service, use compose.prod.yml. Do not declare networks (the default network is already internal). Use "expose" instead of "ports" and NEVER publish database ports (5432, 3306, 6379, 27017...), 80 or 443. Give the site's service a name such as web or app.- Data in named volumes declared at the end of the file. Never mount server folders, "/", docker.sock, and never use privileged, cap_add, devices, network_mode: host or pid: host; avoid container_name.- Databases with a healthcheck, and the app with depends_on condition service_healthy. Migrations in the app's command/entrypoint (for example "php artisan migrate --force" or "npm run migrate").- Secrets NEVER in the file: use ${VAR:?message} for every password, key and token. Database user different from the password (use "app", not "postgres" or "admin"). No values such as password, admin, root, 123456, changeme.- Create a .env.example with the variable names and fake values, and make sure .env is in .gitignore.- Third-party images with a pinned version (no :latest) and no development tools (phpMyAdmin, Adminer, Mailhog, Mailpit) in the production file.- Do not use unusual compose fields; the panel blocks fields it does not know.- Node server with a package.json without a "build" script and with a Dockerfile: the panel uses the Dockerfile (port from the EXPOSE of the last stage).- Next.js with a server and no Dockerfile: the panel generates the image; set `output: "standalone"` in next.config, commit the lockfile and never put a secret in NEXT_PUBLIC_* (it goes into the site's JavaScript).- Static builds (Vite, Next export) only receive the Variables ticked "build" in the panel (or public values in a committed .env.production): VITE_*/NEXT_PUBLIC_* only with public values, never a secret. For a SPA with browser-side routes, tell me to turn on "Single-page application".- Laravel: build the Vite assets in the Dockerfile and install the phpredis extension (or use REDIS_CLIENT=predis).- Check "Service port" on the detection screen (the panel uses the first expose port; with neither expose nor ports, the guess is 80).
Deliver: the files created or changed, the list of variables I must fill in on the panel's Variables screen(names only, with one sentence on what each is), and how to generate safe values (openssl rand -hex 24).At the end, check each rule above item by item and say what could not be met.If an AI assistant uses the panel in your place, also read AI access (API tokens): a scoped token lets the assistant read projects and logs without your password.