From cofounder
This skill should be used when the user asks to "deploy to Locaweb Cloud", "set up GitHub Actions deployment workflows", "create a preview environment", "add a production environment", "tear down an environment", "configure a custom domain", "connect to the database", "check deployment logs", "scale the VM", "recover from snapshots", "disaster recovery", "what is the app URL", "what is the deployed URL", "where is my app", or asks about architecture decisions (monolith vs microservices, vertical vs horizontal scaling), platform constraints (Postgres only, single container, port 80), managing secrets and environment variables, Dockerfile requirements, database migrations, or performing operations and troubleshooting on live infrastructure (SSH access, container logs, health checks).
How this skill is triggered — by the user, by Claude, or both
Slash command
/cofounder:app-deployThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
To deploy web apps, create for each environment:
examples/config/deploy.preview.ymlexamples/config/deploy.production.ymlexamples/config/deploy.ymlreferences/env-vars.mdreferences/kamal.mdreferences/operations.mdreferences/postgres-recipe.mdreferences/recovery.mdreferences/scaling.mdreferences/teardown.mdreferences/workflows.mdscripts/generate_pg_cmd.pyTo deploy web apps, create for each environment:
.github/workflows/deploy-{env}.yml.github/workflows/teardown-{env}.ymlconfig/deploy.{env}.yml.kamal/secrets.{env}And common to all environments:
config/deploy.yml.kamal/secrets-commonDockerfile at repo rootSee references/workflows.md for deploy and references/teardown.md for teardown workflow syntax. See the examples/ folder for .kamal/ (secrets) and config/ (Kamal config) example files. The .kamal/ and config/ folders live at the repo root so the Kamal deploy job can access them.
Caller workflow (.github/workflows/deploy-{env}.yml)
| |
provisioning job deploy job
| |
v v
+----------------------------------+ +----------------------------------+
| External workflow | | Kamal deployment |
|(gmautner/locaweb-cloud-provision)| | (runs in caller's deploy job) |
| | | |
| Provisions infrastructure: | | kamal setup / kamal deploy |
| VMs, networks, disks, IPs, | | Builds and pushes Docker image |
| firewalls, snapshots, DNS | | Deploys to provisioned VMs |
| | | Reboots scaled accessories |
| Outputs: infra_env, IPs, | | |
| infrastructure_changed, | | |
| scaled_accessories | | |
+----------------------------------+ +----------------------------------+
infra_env, infrastructure_changed, scaled_accessories, web_ip, worker_ips, accessory_ipsThese constraints apply to every application deployed to this platform. Communicate these upfront when starting any deployment work:
app_port in the proxy block of /config/deploy.ymlGET /up returning HTTP 200 when healthyforward_headers: false is non-negotiable -- VMs are directly exposed to the internet with no upstream proxy. The agent must never set this to true.web_plan (see references/scaling.md -- VM Plans for available sizes). Prefer runtimes and frameworks that scale well vertically.servers.workers.cmd in deploy.yml).volumes for app roles, directories for accessories -- For main app roles (web, workers), use Kamal's volumes keyword for persistent data mounts. For accessories, use directories (which support mode and owner options). Both are auto-created on the host by Kamal and map directly to host paths — making data visible, portable, and backed up. Never use named Docker volumes (myapp_data:/path). For directories syntax (string and hash formats), mode/owner options, and the distinction between volumes and directories, see references/kamal.md — Accessories./data/{subdir} -- both the web VM and each accessory VM have a persistent disk mounted at /data/. Always mount subdirectories of /data/, never /data/ root directly (see references/env-vars.md -- Disk Storage Path for web usage, references/postgres-recipe.md -- Volume Mount for the database example). Two reasons: (1) /data/ is an attached disk with scheduled snapshot policies that ensure disaster recovery — data outside /data/ is not backed up; (2) the ext4 filesystem creates lost+found at the mount root, which breaks Docker images that expect a clean directory on first boot (PostgreSQL initdb, Redis appendonly.aof, etc.).docker/build-push-action, no docker build, no docker push, no login to ghcr.io). Kamal handles the entire build-push-deploy lifecycle using the Dockerfile at the repo root.proxy.ssl: true in every environment config — both nip.io and custom domains get automatic Let's Encrypt certificates via HTTP-01 challenge. Let's Encrypt has never failed to increase rate limits for nip.io when asked, so nip.io subdomains are safe to use with TLS.supabase/postgres recipe — a Postgres image enriched with several extensions, as recommended by the tech-stack skill. Accessories that serve HTTP/HTTPS traffic through kamal-proxy need ports 80 and 443 opened at the firewall — pass "ports": "80,443" in the accessory's JSON entry (port 22/SSH is always open). See references/kamal.md — Accessories for proxy configuration details.kamal deploy does not update accessories, but the deploy workflow runs kamal accessory reboot all after kamal setup on every deploy. This ensures accessory config changes (image tag, env vars, volumes, ports, cmd) are always applied. Accessories have downtime during reboot (no rolling deploy). See references/kamal.md — Accessories for details.env_name and accessory name values must use only lowercase letters, digits, and underscores ([a-z0-9_]). No hyphens, uppercase, or special characters.If the application's current design conflicts with any of these, resolve the conflict before proceeding with deployment setup.
generate_pg_cmd.pyOutputs the complete PostgreSQL cmd string tuned for a VM plan, if using the supabase/postgres recipe. Use this for the accessories.db.cmd field in config/deploy.<environment>.yml.
mise x -- python3 scripts/generate_pg_cmd.py --plan medium
# Output: postgres -D /etc/postgresql -c shared_buffers=1GB -c effective_cache_size=3GB -c work_mem=10MB -c maintenance_work_mem=256MB -c max_connections=100
Valid plans: micro, small, medium, large, xlarge, 2xlarge, 4xlarge.
Follow these steps in order. Each step is idempotent -- safe to re-run across agent sessions.
/upgit remote -vorigin is already set, skip this stepCheck if an SSH key already exists for this repo:
test -f ~/.ssh/<repo-name> && echo "Key exists" || echo "Key missing"
If the key already exists, reuse it -- do not overwrite.
If the key does not exist, generate a new Ed25519 SSH key:
ssh-keygen -t ed25519 -f ~/.ssh/<repo-name> -N "" -C "<repo-name>-deploy"
chmod 600 ~/.ssh/<repo-name>
This key will be:
SSH_PRIVATE_KEY GitHub secret (the private key)gh secret list
If both CLOUDSTACK_API_KEY and CLOUDSTACK_SECRET_KEY appear → skip to Step 5.
Otherwise:
A. Check for a Locaweb Cloud account
Ask the user: "Você já tem uma conta na Locaweb Cloud?"
B. Find the API keys
Guide the user through these steps:
If the keys do not appear after waiting:
Wait for the user to confirm they have both keys in hand, then continue with C.
C. Create the GitHub secrets
Give them the GitHub secrets URL:
echo "$(gh repo view --json url -q .url)/settings/secrets/actions"
Ask them to open that URL and click "New repository secret" for each:
| Name | What to paste |
|---|---|
CLOUDSTACK_API_KEY | Copiar Chave da API |
CLOUDSTACK_SECRET_KEY | Copiar Chave secreta |
Wait for the user to confirm both secrets are saved, then continue to Step 5.
For the full secrets and environment variables reference, see references/env-vars.md.
Discover all app env vars: Read the project's .env file to get the full list of environment variables the application uses. Cross-reference with the application's config loading code (e.g., backend/internal/config/config.go) to confirm which variables are expected. For each variable, decide:
DEV_MODE must never be set in production) or already set directly in the Kamal config's env.clear or Dockerfile (example: PORT).kamal/secrets (DATABASE_URL from POSTGRES_PASSWORD)env.clear in the Kamal config (no GitHub Secret needed).kamal/secrets.<env> + entry in env.secret in config/deploy.<env>.yml + entry in the workflow env: blockKeep this classified list — it drives Steps 6 and 7.
Check which secrets already exist via gh secret list.
If the app uses the supabase/postgres recipe, set up database secrets:
Generate a random password for each environment:
# Preview password
mise x -- python -c "import secrets; print(secrets.token_urlsafe(32))"
# Production password (different from preview)
mise x -- python -c "import secrets; print(secrets.token_urlsafe(32))"
Set POSTGRES_PASSWORD as a GitHub Secret using gh secret set --body with the generated password (the agent does this directly -- never ask the user to set generated passwords)
DATABASE_URL is not a separate GitHub Secret -- it is derived from POSTGRES_PASSWORD in the .kamal/secrets file (see examples/ for the pattern)
The default preview environment uses unsuffixed names: POSTGRES_PASSWORD
Additional environments use suffixed names matching the environment name: e.g., POSTGRES_PASSWORD_PRODUCTION
For any other app secrets identified in the discovery step above, check whether the value already exists in the project's .env file:
OK to use same value locally and deployed (e.g., SMTP passwords, OAuth credentials, third-party API keys, depending on the case) — the agent sets the GitHub Secret directly from .env (see Step 6).
Different value or not in .env — the user must set it via the GitHub UI (see Step 6).
Never accept secret values through the chat
Use gh secret list to check which secrets already exist -- only create missing ones.
# SSH private key for preview (skip if already set)
gh secret set SSH_PRIVATE_KEY < ~/.ssh/<repo-name>
# Postgres password for preview (skip if already set)
gh secret set POSTGRES_PASSWORD --body "<generated password from Step 5>"
For additional environments, use suffixed names:
# SSH private key for production
gh secret set SSH_PRIVATE_KEY_PRODUCTION < ~/.ssh/<repo-name>-production
# Postgres password for production
gh secret set POSTGRES_PASSWORD_PRODUCTION --body "<generated password from Step 5>"
Note: DATABASE_URL does not need a GitHub Secret -- it is derived from POSTGRES_PASSWORD in the .kamal/secrets file.
For secrets only the user knows (app API keys, SMTP credentials, etc.), check the project's .env file first.
Reuse from .env: If the secret exists in .env and the same value may apply to the deployed environment, set it directly — the value never appears in chat:
# One-time setup (skip if already installed):
mise x -- pip install -q python-dotenv
# Set a secret from .env (value never appears in chat):
mise x -- python -c "from dotenv import dotenv_values; import sys; print(dotenv_values('.env')[sys.argv[1]], end='')" SECRET_NAME | gh secret set SECRET_NAME
Fall back to GitHub UI: If the secret is not in .env, or the deployed value should differ from the local one, give the user the GitHub secrets URL:
echo "$(gh repo view --json url -q .url)/settings/secrets/actions"
Ask them to click "New repository secret" and tell them the exact Name to enter and where to find the value. Wait for the user to confirm all manually-added secrets are saved before continuing.
Read the example files before writing. Each sub-step below names an example file — read it with the Read tool first, then adapt it for the project. Lines marked # do not change must be preserved exactly. Remove sections the project doesn't need (e.g., workers, volumes). Add project-specific values where the comments indicate.
config/deploy.yml)Read examples/config/deploy.yml and adapt it:
env.clear variables (non-sensitive config shared by all environments)volumes entries if the app stores files on disk (keep paths under /data/)servers.workers block if the project has no workersvolumes block if the app has no persistent storage# do not change exactly as-is — these are platform constraints (forward_headers: false, registry ERB templates, builder config, SSH user)env.secret here — arrays are replaced (not merged) by destination files, so any secrets listed here would be silently lost. Put ALL secrets in each config/deploy.<env>.ymlFor Postgres accessories, generate the cmd string using generate_pg_cmd.py --plan <chosen_plan>. See the supabase/postgres recipe.
config/deploy.{env}.yml)Read examples/config/deploy.preview.yml (or examples/config/deploy.production.yml for non-preview) and adapt it:
env.secret to list ALL secrets for this environment (common + env-specific) — this is the only place secrets are declaredenv.clear for environment-specific non-sensitive config (e.g., APP_ENV: preview)docs/INFRASTRUCTURE.md — match image, set host via ERB (<%= ENV['INFRA_<NAME>_IP'] %>), configure port, cmd, env, and directories (always under /data/)proxy.host — nip.io for preview (<%= ENV['INFRA_WEB_IP'] %>.nip.io), custom domain for production. Always ssl: trueservers.workers block if the project has no workersIMPORTANT — INFRA_*_IP is for Kamal and external URLs only: Use INFRA_*_IP only in host: fields, servers.*.hosts, and proxy.host. Never in env.clear or env.secret. For inter-component communication, use the CloudStack internal DNS hostname (the accessory name, e.g., db, redis).
.kamal/secrets-common)Read examples/.kamal/secrets-common and adapt it:
KAMAL_REGISTRY_PASSWORD line — this is NOT a GitHub Secret; it comes from secrets.GITHUB_TOKEN in the workflowSECRET_NAME=$SECRET_NAME).kamal/secrets.{env})Read examples/.kamal/secrets.preview (or examples/.kamal/secrets.production for non-preview) and adapt it:
POSTGRES_PASSWORD=$POSTGRES_PASSWORDPOSTGRES_PASSWORD=$POSTGRES_PASSWORD_PRODUCTIONDATABASE_URL) are composed inline — they don't need their own GitHub SecretGitHub Secrets --> Workflow env: block --> Shell environment --> .kamal/secrets* --> Kamal --> Container
POSTGRES_PASSWORD, API_KEY_PRODUCTION)env: block maps each GitHub Secret to an environment variable on the runner-d <destination>, it reads .kamal/secrets-common and .kamal/secrets.<destination> to resolve secret values from the shell environmentenv.secret in config/deploy.yml (common) and config/deploy.<env>.yml (env-specific) are injected into the containerThe left side of each secrets file line is the Kamal secret name (referenced in env.secret). The right side is the shell environment variable name (which matches the GitHub Secret name). For non-preview environments, the GitHub Secret is suffixed (e.g., POSTGRES_PASSWORD_PRODUCTION), so the right side maps to the suffixed name while the left side stays unsuffixed.
Accessory-to-config sync: The accessories JSON array in the caller workflow's infra job must match the accessories declared in config/deploy.<env>.yml. Each accessory needs a corresponding entry in the JSON array with a matching name, plus the desired plan and disk_size_gb. Accessories that use kamal-proxy (i.e., have a proxy block in the Kamal config) also need "ports": "80,443" to open the firewall for HTTP/HTTPS traffic.
Forward sync (development → deployment): When generating the accessories JSON for the workflow and the accessory blocks in the Kamal destination config, use docs/INFRASTRUCTURE.md as the source of truth for which accessories exist, their images, and their environment variables.
Reverse sync (deployment → development): When adding, removing, or changing accessories in the Kamal config or workflow (e.g., the user asks to add Redis during deployment setup, or to remove an accessory that is no longer needed), update docs/INFRASTRUCTURE.md to match before the task is complete. The infrastructure manifest and Kamal accessory config must always describe the same set of services.
WARNING: disk_size_gb is MANDATORY for every accessory. Even if the accessory does not need persistent storage, you must include disk_size_gb with a value in the range 10–4000 GB. Example: {"name":"nginx","plan":"small","disk_size_gb":10,"ports":"80,443"}.
Naming: accessory name must use only lowercase letters, digits, and underscores ([a-z0-9_]). No hyphens, uppercase, or special characters.
Example sync:
# In config/deploy.preview.yml:
accessories:
db:
image: supabase/postgres:17.6.1.104
# ... backend service, no proxy
n8n:
image: n8nio/n8n:latest
host: <%= ENV['INFRA_N8N_IP'] %>
proxy:
host: n8n.<%= ENV['INFRA_N8N_IP'] %>.nip.io
ssl: true
app_port: 5678
# ... web-facing, uses kamal-proxy
# In caller workflow (infra job):
with:
accessories: '[{"name":"db","plan":"medium","disk_size_gb":20},{"name":"n8n","plan":"small","disk_size_gb":10,"ports":"80,443"}]'
Start with a preview environment. Read the example workflows with the Read tool, then adapt them:
Deploy workflow: Read the Preview Workflow Example section in references/workflows.md. Copy the full workflow YAML and adapt it:
accessories JSON to match the accessories in config/deploy.preview.yml and docs/INFRASTRUCTURE.md — every accessory needs name, plan, and disk_size_gb; web-facing ones also need "ports": "80,443"env: block — one entry per GitHub Secret that .kamal/secrets files reference (derived secrets like DATABASE_URL don't need an entry)workers_replicas and workers_plan if the project has no workerssecrets: inherit — Kamal handles the entire build-push-deploy lifecycle.github/workflows/deploy-preview.ymlTeardown workflow: Read references/teardown.md for the teardown pattern. Save as .github/workflows/teardown-preview.yml
The preview workflow (triggered on push) gives immediate feedback on every change to the main branch, matching a typical developer flow. Other environments can be added depending on the team's processes.
A common choice is a "production" environment triggered on version tags (v*), where a tag signals that the pointed commit is ready for production. Feel free to create other environments with different triggers and workflow inputs to match your needs.
For each additional environment:
~/.ssh/<repo-name>-<env_name> (same procedure as Step 3)SSH_PRIVATE_KEY_PRODUCTIONPOSTGRES_PASSWORD_PRODUCTIONAPI_KEY_PRODUCTION.CLOUDSTACK_API_KEY, CLOUDSTACK_SECRET_KEY) don't need to be recreated -- just pass them in every caller workflowconfig/deploy.<env>.yml (same structure as the preview config, with the appropriate hosts, proxy host, and accessories).kamal/secrets.<env> file mapping Kamal secret names to suffixed env var names (e.g., POSTGRES_PASSWORD=$POSTGRES_PASSWORD_PRODUCTION).github/workflows/deploy-<env>.yml (same two-job pattern, with the environment's trigger, secrets, and inputs).github/workflows/teardown-<env>.ymlAfter setup is complete, use this loop to deploy and verify the application.
Prerequisite: Before entering this loop, all changes -- including the Kamal config files, workflow files, and any application code -- must be committed and pushed to the remote repository. The deploy is triggered by
git push. If the code has not been committed and pushed yet, do that first:git add -A && git commit -m "Add deployment config and workflows" && git pushThe tech-stack skill normally handles this, but if there was a handoff gap, ensure it happens now before proceeding.
+-----------------------------------------------------+
| |
| Push triggers GitHub Actions workflow |
| | |
| v |
| Monitor workflow run (gh run watch) |
| | |
| v |
| Workflow succeeds? --No--> Read logs, fix, |
| | commit/push, |
| Yes repeat |
| | |
| v |
| Health check: curl /up -> HTTP 200? |
| | |
| Yes --> Done (deployment verified) |
| | |
| No |
| | |
| v |
| SSH debug (logs, container state) |
| | |
| v |
| Return to tech-stack Local Development |
| Feedback Loop to diagnose and fix |
| |
+-----------------------------------------------------+
After push, before starting to monitor, give the user a prominent clickable link to the workflow run:
gh run list --limit=1 --json databaseId,url -q '.[0].url'
Present it prominently:
Your deploy is running! Follow it live here:
<URL from the command above>
Then monitor the run:
gh run watch
If the workflow fails, read the error:
gh run view <run-id> --log-failed
Common failure causes:
gh secret list to verify all required secrets existpermissions: contents: read, packages: write in the caller workflowFix the issue, commit, and push. The preview workflow (triggered on push) will start a new run automatically. Continue the cycle until the workflow succeeds.
curl -s -o /dev/null -w "%{http_code}" https://<web_ip>.nip.io/up (get web_ip from the workflow run summary)curl -s -o /dev/null -w "%{http_code}" https://<domain>/upWhen adding, removing, or changing an accessory after the initial deployment:
docs/INFRASTRUCTURE.md — add or remove the row.config/deploy.<env>.yml) — add or remove the accessory block. For new accessories, include image, host (ERB), port, cmd, env, and directories. For web-facing accessories, add the proxy block with a health check path the image actually exposes (not /up)..github/workflows/deploy-<env>.yml) — add or remove the entry in the accessories JSON. Remember:
disk_size_gb is mandatory for every accessory"ports": "80,443"name must match the Kamal configenv.clear in the Kamal configenv.secret, create the GitHub Secret, add to .kamal/secrets.<env>, and add to the workflow's env: blockWhen removing an accessory, also check whether any application code still references its env var and remove the dependency.
Quick reference for interacting with deployed infrastructure. See references/operations.md for full details.
| Task | Command pattern |
|---|---|
| Get deployment IPs | rm -rf $HOME/tmp/provision-output && gh run download <run-id> --name provision-output --dir $HOME/tmp/provision-output |
| SSH into a VM | Resolve key first: REPO_NAME=$(gh repo view --json name -q .name), then ssh -i ~/.ssh/$REPO_NAME[-<env_name>] root@<ip> — always use -i, never rely on the default SSH key |
| Connect to accessory (e.g. Postgres) | SSH into accessory VM -> docker exec -it <repo-name>-db psql -U postgres |
| View app logs | SSH into web VM -> docker logs $(docker ps -q --filter "label=service=<repo-name>") --tail 100 |
| Check app health | curl -s https://<web_ip>.nip.io/up |
| Shell into app container | SSH into web VM -> docker exec -it $(docker ps -q --filter "label=service=<repo-name>") sh |
Dockerfile at repository rootCMD/entrypoint serves the web applicationservers.workers.cmd in deploy.ymlGET /up returning HTTP 200 when healthyDATABASE_URL env var (or individual vars like POSTGRES_HOST, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD if the framework requires them -- the agent sets these in deploy.yml under env.clear/env.secret). The app must fail with a clear error if it needs the database but these variables are missing -- do not silently skip database functionality.Example minimal Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 80
ENV PORT=80
CMD ["gunicorn", "--bind", "0.0.0.0:80", "--workers", "2", "app:app"]
The platform runs a single web VM, so running migrations at container startup is the correct approach. This avoids race conditions (no concurrent instances), requires no separate migration container, and keeps migrations synchronized with the deployment lifecycle -- a new code push triggers a redeploy, which restarts the container, which runs migrations before serving traffic.
Include migrations in the container entrypoint, before the web server starts:
CMD ["sh", "-c", "python manage.py migrate && exec gunicorn --bind 0.0.0.0:80 --workers 2 app:app"]
Ensure that:
alembic, django, knex, ActiveRecord) must be installed in the image. Verify that the COPY and RUN pip install (or equivalent) steps include everything the migration command needs.After a deploy workflow completes, extract information from:
web_ip, worker_ips (JSON array), accessory_ips (JSON object keyed by accessory name), infrastructure_changed, scaled_accessories, infra_envprovision-output artifact: JSON file retained for 90 dayshttps://<web_ip>.nip.io -- works immediately, no DNS needed, TLS via Let's Encrypthttps://<domain> -- requires DNS A record pointing to web_ip, TLS via Let's EncryptBoth nip.io and custom domains support TLS. Let's Encrypt HTTP-01 challenge provisions certificates automatically.
When the user asks to configure a custom domain, determine which environment should receive it. Always ask -- do not default to the only existing environment without confirming.
Explain the options in the user's language, using these concepts:
v*). Changes are staged in preview first and only promoted to production when a tag is created. Tie the domain here for a controlled release process.Decision point: If the user has no production environment, ask them:
Mention that this can be changed later (e.g., moving the domain from preview to production, or adding a new domain to production), so there is no wrong choice to start with.
If a local development environment is not available (the user cannot run the app locally), recommend option 2 more strongly: without local dev, preview is the only place to catch issues before they go live, so it's valuable to keep it as a staging area separate from the public-facing production environment.
Once the user decides, proceed with DNS Configuration for the chosen environment. If a production environment is needed but doesn't exist yet, create it first following Step 9.
The web VM's public IP is not known until the first deployment completes. To set up a custom domain:
proxy.host domain from the destination config). The app will be accessible at https://<web_ip>.nip.io.web_ip from the workflow output or step summary.Type: A
Name: myapp.example.com (or @ for apex)
Value: <web_ip from step 2>
TTL: 300
config/deploy.<env>.yml) to set proxy.host to the domain (SSL is already true).Let's Encrypt HTTP-01 challenge requires the domain to resolve to the server before the certificate can be issued. The IP is stable across re-deployments to the same environment -- it only changes if the environment is torn down and recreated.
When the custom domain is an apex/bare domain (e.g., example.com rather than a subdomain like app.example.com), users commonly expect both example.com and www.example.com to work. kamal-proxy only routes requests whose Host header matches the configured host(s), so www.example.com will fail with a TLS error unless explicitly included.
When the user provides an apex domain, proactively include the www subdomain. Use the hosts: array in the proxy config and instruct the user to create two DNS A records:
Type: A Name: @ Value: <web_ip> TTL: 300
Type: A Name: www Value: <web_ip> TTL: 300
The destination config uses proxy.hosts (array) instead of proxy.host (string):
proxy:
hosts:
- example.com
- www.example.com
ssl: true
kamal-proxy will provision separate Let's Encrypt certificates for each hostname. Both DNS records must resolve to the server before deployment.
Canonical hostname and redirect: BASE_URL is always the bare/apex domain (e.g., https://example.com). This applies even when the user specifies www.example.com as their domain -- recognize the apex (example.com) as the canonical hostname. The application must redirect www requests to the bare domain (HTTP 301) so that only one hostname serves content. This keeps BASE_URL unique and avoids issues with OAuth callbacks, cookies, and link sharing referencing inconsistent hostnames. kamal-proxy does not perform host-to-host redirects -- the redirect must be implemented at the application level (e.g., middleware that checks the Host header and redirects www.* to the bare domain).
Workers scale horizontally by changing workers_replicas in the caller workflow (see references/workflows.md -- Deploy Input Reference for all inputs). For scaling details, see references/scaling.md -- Scaling Workers. The worker command is set in config/deploy.yml under servers.workers.cmd, not as a workflow input.
workers_replicas: 0 means no workers (the workflow will not provision worker VMs)workers_replicas: 1 or higher provisions that many worker VMsWhen workers are enabled:
servers.workers.cmd and servers.workers.proxy: false to config/deploy.yml:# config/deploy.yml
servers:
workers:
cmd: "celery -A tasks worker --loglevel=info"
proxy: false
config/deploy.{env}.yml. The provision job exports INFRA_WORKER_IP_0, INFRA_WORKER_IP_1, etc. -- one for each workers_replicas:# config/deploy.preview.yml
servers:
web:
hosts:
- <%= ENV['INFRA_WEB_IP'] %>
workers:
hosts:
- <%= ENV['INFRA_WORKER_IP_0'] %>
- <%= ENV['INFRA_WORKER_IP_1'] %>
workers_replicas and workers_plan in the caller workflow's infra job:with:
workers_replicas: 2
workers_plan: "small"
Scaling happens by updating workflow inputs and/or Kamal config, then redeploying. See references/scaling.md for VM plans, disk sizes, and detailed instructions.
Change web_plan in the workflow and redeploy. No config file changes needed. Causes brief downtime.
workers_plan in the workflow and redeploy. No config file changes needed. Causes brief downtime.workers_replicas in the workflow. Also update the host list in config/deploy.<env>.yml to match the new replica count — see references/scaling.md -- Scaling Workers for details. Commit, push, and redeploy.Change the plan in the accessories JSON in the workflow. For database accessories using supabase/postgres, also regenerate the cmd — see references/scaling.md -- Scaling the Database for the full procedure.
Other accessories may also have memory-dependent configuration (e.g., maxmemory for Redis, JVM heap for Elasticsearch). When scaling any accessory, review its cmd, env, or container configuration in config/deploy.<env>.yml and adjust parameters to match the new plan's resources.
See references/teardown.md for tearing down environments, inferring zone/env_name from existing workflows, and reading last run outputs.
Every deployed environment has automatic daily snapshots of all data volumes. If an environment is lost (teardown, VM failure, or data corruption), it can be recovered by running the deploy workflow with recover: true.
When the user asks to "recover", "recuperar", "restore", "do DR", or "disaster recovery": the critical action is adding recover: true to the infra job inputs in the deploy workflow. Without this flag, the workflow creates blank disks and the data is lost. Simply changing the zone is not recovery — recovery means restoring from snapshots via the recover: true flag.
Recovery procedure:
zone input to recover in a different zone (snapshots are replicated across zones, so recovery works in either zone)recover: true to the infra job's with: block in the deploy workflow (e.g., .github/workflows/deploy-preview.yml)recover: true from the workflow file — if left in place, subsequent runs will fail because the network and volumes already existSee references/recovery.md for the full procedure, pre-flight requirements, and current limitations.
When the developer cannot run the language runtime or database locally, the Deployment Feedback Loop becomes the primary iteration cycle:
curl /up) to verifyRecommendation: Start with the default preview environment triggered on push, without a domain. This gives immediate feedback on every change, with TLS via nip.io. When the app is mature, consider adding a production environment (triggered on version tags) for public release -- especially important when no local dev environment is available, since preview is the only place to catch issues before they reach users. See Choosing the Target Environment for a Domain for guidance on where to attach a custom domain.
supabase/postgres image, a Postgres image enriched with extensions as recommended by the tech-stack skillnpx claudepluginhub gmautner/marketplace --plugin cofounderDeploys apps to Render by analyzing codebases, generating render.yaml blueprints, and providing dashboard deeplinks. For Git-backed services, Docker images, databases, and cron jobs.
Generates deployment configurations for hosting providers like Vercel, Railway, AWS, covering env vars, domains, SSL, strategies, rollback plans, and health checks. Useful for production deploys.
Guides deploying apps to Vercel, Railway, Netlify, and others; covers hosting selection, custom domains, env vars, production DBs, DNS, and going live.