Ansible kamal
Skill mauriciovieira/skills/skills/engineering/ansible-kamal
Skills for real engineers - not vibe coding
npx -y skills add mauriciovieira/skills --skill ansible-kamalAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when bootstrapping a Rails project that needs Ubuntu VPS provisioning (Ansible) and Kamal 2 deploy (kamal-proxy on 80/443) — scaffolds infra/ansible, infra/kamal, config/deploy*.yml, .kamal/secrets* templates from a hardened reference layout, asking IP, DNS, and whether the host runs both staging+production or only a single domain.
SKILL.md
8.8 KB, as published. Nobody here has run it
ansible-kamal
Scaffolds a full Ansible + Kamal 2 infrastructure tree for a Rails app on a single Ubuntu VPS. Generates:
- Root
Makefile—make deploy ENV=…,make db-restore-from-vps ENV=…,make build,make run-local,make mailhog. - Root
scripts/—kamal-deploy-from-pass.sh,db-restore-from-vps.sh(provider-agnostic — works with Hetzner, DigitalOcean, OVH, Hostinger, AWS Lightsail, etc., as long as the Ansible inventory points at the box). infra/ansible/— playbooks (bootstrap,site,dump_db,restore_db), roles (common,users,ssh_bootstrap,firewall,fail2ban,docker,postgresql,traefik,opt), inventory,Makefile(setup,test,bootstrap,ansible), README.infra/kamal/README.md— secrets /pass/ GitHub Actions guide.config/deploy.yml(+config/deploy.staging.ymlif staging is enabled)..kamal/secrets,.kamal/secrets-common(+.kamal/secrets.stagingif enabled).
When to Use
- A new Rails project that needs a single Ubuntu VPS with Kamal 2 + host PostgreSQL 16.
- An existing project missing
infra/ansible/where you want the same hardened baseline (UFW, fail2ban, sshd drop-in, Docker, PG on host).
Do NOT use when the project already has infra/ansible/ — diff manually instead, this skill overwrites.
Inputs to ask the user (in this order)
- App slug (snake_case) — e.g.
myapp,petshop. Used for DB names (<slug>_production,<slug>_staging), DB users (<slug>_prod_user,<slug>_staging_user), and env var prefix (POSTGRES_<SLUG_UPPER>_PROD_PASSWORD). - Service name (kebab-case) — Kamal
service:and image name. Default = slug with_→-(e.g.my-app). - Image repo — e.g.
myorg/<service>for GHCR. - VPS IP (IPv4) — host key in
inventory/production.ymlandconfig/deploy.ymlservers.web.hosts. - Inventory group name — Ansible group; default = slug (e.g.
myapp). - Environment mode — ONE of:
staging+production— two Kamal destinations, two DBs, two domains (recommended for production apps).single— only one domain / DB / Kamal destination, no staging plumbing emitted.
- Production domain — e.g.
example.com. - Staging domain — only if
staging+production. Default =staging.<production_domain>. - Let's Encrypt email — for kamal-proxy ACME (and Traefik if ever enabled).
- Deploy user — Linux user Ansible (
site.yml) + Kamal SSH as. Default =<slug>_deploy. Must NOT beroot. - Pass backend —
custom(uses a customPASSWORD_STORE_DIR, e.g.~/.password-store-custom) orpass(default~/.password-store). - Pass namespace — e.g.
infra/myapp. Default =infra/<inventory_group>.
Variable map
After collecting inputs, derive:
| Placeholder | Value |
|---|---|
__APP_SLUG__ | snake_case slug |
__APP_SERVICE__ | kebab-case service |
__APP_SLUG_UPPER__ | uppercase snake_case (env var prefix) |
__INVENTORY_GROUP__ | inventory group |
__VPS_IP__ | IPv4 |
__DOMAIN_PROD__ | production FQDN |
__DOMAIN_STAGING__ | staging FQDN (or empty in single mode) |
__LETSENCRYPT_EMAIL__ | ACME email |
__DEPLOY_USER__ | deploy linux user |
__IMAGE_REPO__ | container image (no tag) |
__PASS_BACKEND__ | custom or pass |
__PASS_STORE_DIR__ | $(HOME)/.password-store-custom or empty for default |
__PASS_NAMESPACE__ | e.g. infra/myapp |
__ENV_MODE__ | staging+production or single |
How to scaffold
Run the render script from the target project root (the repo to scaffold INTO):
APP_SLUG=myapp \
APP_SERVICE=my-app \
APP_SLUG_UPPER=MYAPP \
INVENTORY_GROUP=myapp \
VPS_IP=203.0.113.10 \
DOMAIN_PROD=example.com \
DOMAIN_STAGING=staging.example.com \
[email protected] \
DEPLOY_USER=myapp_deploy \
IMAGE_REPO=myorg/my-app \
PASS_BACKEND=custom \
PASS_STORE_DIR='$(HOME)/.password-store-custom' \
PASS_NAMESPACE=infra/myapp \
ENV_MODE=staging+production \
TARGET_DIR=. \
~/.claude/skills/ansible-kamal/scripts/render.sh
For ENV_MODE=single leave DOMAIN_STAGING empty (or unset). PASS_STORE_DIR may be empty when PASS_BACKEND=pass.
The script:
- Walks
templates/, applies placeholder substitution. - Strips blocks fenced by
# >>> staging-only/# <<< staging-onlywhenENV_MODE=single. - Skips files matching
*.staging.*andsecrets.stagingwhenENV_MODE=single. - Renames
*.tmpl→ no suffix. - Refuses to overwrite existing
infra/ansible/unlessFORCE=1.
After scaffold
Tell the user, in order:
cd infra/ansible && make setup && make test— bootstraps venv + galaxy collections + lints.- Generate deploy SSH key pair,
pass insert -m __PASS_NAMESPACE__/deploy_ssh_{private,public}_key. - Generate DB passwords:
openssl rand -base64 32, store at__PASS_NAMESPACE__/postgres___APP_SLUG___prod_password(and_staging_if applicable). - DNS
Arecords:__DOMAIN_PROD__(+__DOMAIN_STAGING__) →__VPS_IP__. - Open the VPS provider's firewall (or
ufw): allow 22/80/443 from anywhere (or your IP for 22). - Run
DEPLOY_SSH_KEY="$(pass show __PASS_NAMESPACE__/deploy_ssh_public_key)" make bootstrap(root reachable). - Run
make ansible(loads PG passwords from pass and provisionssite.ymlas__DEPLOY_USER__). - Wire GitHub Environments + secrets per
infra/kamal/README.md. bundle exec kamal setup(production) andbundle exec kamal setup -d staging(if staging).
Known constraints baked in
- PostgreSQL on host, not Docker accessory. Containers reach it via
host.docker.internal(add-host: host.docker.internal:host-gatewayin deploy.yml). - kamal-proxy owns 80/443 (Traefik role disabled by default; kept in tree).
- UFW runs in
bootstrap.ymlonly, withMANAGE_BUILTINS=yesandDEFAULT_FORWARD_POLICY=ACCEPTso SSH survives Docker's iptables hooks. Includes nft INPUT rehook task to recover from Docker orphaning UFW jumps. - sshd drop-in
05-ansible-sshd-hardening.confin bootstrap; cloud-init50-and60-removed. - Deploy user sudo restricted to
systemctl,apt,apt-get. No/etc/sshediting. - Docker daemon MTU 1450 for Let's Encrypt egress on cloud bridges.
Files emitted (full list)
Makefile # root: deploy, db-restore-from-vps, build, run-local, mailhog
scripts/kamal-deploy-from-pass.sh
scripts/db-restore-from-vps.sh
infra/README.md
infra/ansible/ansible.cfg
infra/ansible/Makefile # ansible: setup, test, bootstrap, ansible
infra/ansible/README.md
infra/ansible/requirements.txt
infra/ansible/requirements.yml
infra/ansible/playbooks/{bootstrap,site,dump_db,restore_db}.yml
infra/ansible/inventory/production.yml
infra/ansible/inventory/group_vars/all.yml
infra/ansible/roles/common/tasks/main.yml
infra/ansible/roles/users/tasks/main.yml
infra/ansible/roles/users/library/managed_authorized_key.py # idempotent single-key authorized_keys module
infra/ansible/roles/ssh_bootstrap/{tasks/main.yml,templates/sshd_ansible_hardening.conf.j2,handlers/main.yml}
infra/ansible/roles/firewall/tasks/{main,nft_input_rehook}.yml
infra/ansible/roles/fail2ban/{tasks/main.yml,handlers/main.yml}
infra/ansible/roles/docker/{tasks/main.yml,templates/daemon.json.j2,handlers/main.yml}
infra/ansible/roles/postgresql/{tasks/{main,discover,ufw}.yml,templates/{postgresql.conf,pg_hba.conf,create_databases.sql}.j2,handlers/main.yml}
infra/ansible/roles/opt/tasks/main.yml
infra/ansible/roles/traefik/{tasks/main.yml,templates/{traefik.yml,docker-compose.yml}.j2}
infra/kamal/README.md
config/deploy.yml
config/deploy.staging.yml # only if ENV_MODE=staging+production
.kamal/secrets
.kamal/secrets-common
.kamal/secrets.staging # only if ENV_MODE=staging+production
Common mistakes
| Mistake | Fix |
|---|---|
Forgot __VPS_IP__ substitution → bootstrap connects to placeholder | Re-run render.sh; verify inventory/production.yml and config/deploy.yml have the IP. |
Used __APP_SLUG__ with hyphens | Slug must be snake_case; service name is the kebab-case form. Postgres identifiers reject hyphens. |
single mode but staging files present | Set ENV_MODE=single, leave DOMAIN_STAGING empty, re-render. |
Deploy user = root | Disallowed; users role creates it as a non-sudo account with restricted sudoers. Pick <slug>_deploy. |
| Pass paths don't match Makefile | __PASS_NAMESPACE__ flows into Makefile make ansible target. Match what's in pass. |