Pitcrew by Example

Real-world recipes for deploying, configuring, and managing infrastructure with Pitcrew.

Deploy a Node.js App

This recipe installs Node.js, copies your application, installs dependencies, and sets up a systemd service.

deploy-node.pit

import "fs"
import "pkg"
import "svc"
import "user"
import "tpl"

host := "10.0.0.5"
app_name := "myapp"
app_port := 3000

# Define the deployment function
fn deploy(name string, port int) {
    # Create app user
    if !user.exists(name) {
        user.create(name)
    }

    # Install Node.js
    pkg.install("nodejs")

    # Create app directory and copy files
    app_dir := "/opt/#{name}"
    `mkdir -p #{app_dir}`
    `cp -r ./app/* #{app_dir}/`

    # Install dependencies
    cd app_dir {
        `npm install --production`
    }

    # Set ownership
    `chown -R #{name}:#{name} #{app_dir}`

    # Create systemd service
    service := tpl.render(
        >> [Unit]
        >> Description={{name}}
        >> After=network.target
        >> 
        >> [Service]
        >> User={{name}}
        >> WorkingDirectory={{app_dir}}
        >> ExecStart=/usr/bin/node index.js
        >> Environment=PORT={{port}}
        >> Restart=always
        >> 
        >> [Install]
        >> WantedBy=multi-user.target,
        {"name": name, "app_dir": app_dir, "port": "#{port}"}
    )

    fs.write("/etc/systemd/system/#{name}.service", service) catch {|err|
        print("Failed to write service: " + err)
    }

    # Enable and start
    `systemctl daemon-reload`
    svc.enable(name)
    svc.restart(name)

    print("Deployed #{name} on port #{port}")
}

# Run on the remote host
ssh deploy(app_name, app_port), host: host, user: "root"

The ssh keyword serializes the function and all captured values, then runs them on the remote host. Outer variables like app_name and app_port are available as read-only constants inside the SSH session.

Configure Nginx as a Reverse Proxy

Install nginx and set up a reverse proxy to a backend application.

nginx-proxy.pit

import "fs"
import "pkg"
import "svc"
import "tpl"

host := "10.0.0.5"
domain := "myapp.example.com"
backend_port := 3000

fn setup_nginx(domain string, port int) {
    # Install nginx
    pkg.install("nginx")

    # Generate config from template
    config := tpl.render(
        >> server {
        >>     listen 80;
        >>     server_name {{domain}};
        >> 
        >>     location / {
        >>         proxy_pass http://127.0.0.1:{{port}};
        >>         proxy_set_header Host $host;
        >>         proxy_set_header X-Real-IP $remote_addr;
        >>         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        >>         proxy_set_header X-Forwarded-Proto $scheme;
        >>     }
        >> },
        {"domain": domain, "port": "#{port}"}
    )

    # Write config and remove default
    fs.write("/etc/nginx/sites-available/#{domain}", config) catch {|err|
        print("Failed: " + err)
    }
    `ln -sf /etc/nginx/sites-available/#{domain} /etc/nginx/sites-enabled/#{domain}`

    # Test config and reload
    test_result := $`nginx -t`
    if test_result.status != 0 {
        print("Nginx config test failed: " + test_result.stderr)
        return
    }

    svc.enable("nginx")
    svc.restart("nginx")
    print("Nginx configured for #{domain} -> :#{port}")
}

ssh setup_nginx(domain, backend_port), host: host, user: "root"

The ! after a backtick command gives you a struct with stdout, stderr, and status fields instead of raising on non-zero exit.

Set Up PostgreSQL Replication

Configure a primary PostgreSQL server and a streaming replica.

postgres-replication.pit

import "fs"
import "pkg"
import "svc"
import "user"

primary_host := "10.0.0.10"
replica_host := "10.0.0.11"

# --- Configure the primary ---
fn setup_primary(replica_ip string) {
    pkg.install("postgresql")

    # Create replication user
    $`sudo -u postgres psql -c "CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'repl_pass';"`

    # Configure replication in postgresql.conf
    pg_conf :=
        >> listen_addresses = '*'
        >> wal_level = replica
        >> max_wal_senders = 3
        >> wal_keep_size = 256MB
    fs.write("/etc/postgresql/16/main/conf.d/replication.conf", pg_conf) catch {|err|
        print("Failed: " + err)
    }

    # Allow replica to connect
    hba_line := "host replication replicator #{replica_ip}/32 md5\n"
    `echo #{hba_line} >> /etc/postgresql/16/main/pg_hba.conf`

    svc.restart("postgresql")
    print("Primary configured for replication")
}

# --- Configure the replica ---
fn setup_replica(primary_ip string) {
    pkg.install("postgresql")

    # Stop PostgreSQL and clear data directory
    svc.stop("postgresql")
    `rm -rf /var/lib/postgresql/16/main/*`

    # Base backup from primary
    `sudo -u postgres pg_basebackup -h #{primary_ip} -U replicator -D /var/lib/postgresql/16/main -Fp -Xs -R`

    svc.start("postgresql")
    print("Replica streaming from " + primary_ip)
}

# Deploy to both servers
p := async ssh setup_primary(replica_host), host: primary_host, user: "root"
resolve(p)   # wait for primary first

ssh setup_replica(primary_host), host: replica_host, user: "root"

In production, use proper secrets management instead of hardcoded passwords. See the Secrets Management section.

Rolling Deploys with Health Checks

Deploy to multiple servers one at a time, verifying health after each.

rolling-deploy.pit

import "net"
import "svc"
import "sync"

servers := ["10.0.0.5", "10.0.0.6", "10.0.0.7"]
app_port := 3000
deploy_version := "v1.2.3"

# Deploy to a single server
fn deploy_one(version string) {
    # Pull new version and restart
    `cd /opt/myapp && git fetch && git checkout #{version}`
    `cd /opt/myapp && npm install --production`
    svc.restart("myapp")
}

# Health check: wait for the app to respond
fn wait_healthy(server string, port int) bool {
    attempts := 0
    while attempts < 30 {
        if net.port_open(server, port) {
            resp := net.get("http://#{server}:#{port}/health") catch {|err|
                attempts++
                `sleep 1`
                continue
            }
            if resp.status == 200 {
                return true
            }
        }
        attempts++
        `sleep 1`
    }
    false
}

# Roll through each server sequentially
for i := 0; i < len(servers); i++ {
    server := servers[i]
    print("Deploying to " + server + "...")

    ssh deploy_one(deploy_version), host: server, user: "deploy"

    if wait_healthy(server, app_port) {
        print("  #{server} healthy")
    } else {
        print("  #{server} FAILED health check - aborting!")
        break
    }
}

print("Rolling deploy complete")

For parallel deploys with a concurrency limit, use async(limiter) instead of a sequential for loop. See the Async section of the language tour.

Secrets Management Patterns

Several approaches to handling secrets without hardcoding them in your scripts.

Pattern 1: Environment variables

import "env"

db_password := env.get("DB_PASSWORD")
if db_password == "" {
    print("DB_PASSWORD not set")
    return
}

Pattern 2: AWS SSM Parameter Store

import "aws"

db_password := aws.ssm_get("/prod/db-password")
api_key := aws.ssm_get("/prod/api-key")

Pattern 3: Read from encrypted file

import "fs"

# Decrypt with age/sops on the local machine, then pass to remote
secrets := `sops -d secrets.enc.json`

fn configure(secrets_json string) {
    fs.write("/opt/myapp/.env", secrets_json) catch {|err|
        print("Failed: " + err)
    }
    `chmod 600 /opt/myapp/.env`
    `chown myapp:myapp /opt/myapp/.env`
}

ssh configure(secrets), host: "10.0.0.5", user: "root"

Secrets are decrypted locally and passed as function arguments to the remote host via the SSH wire protocol. They never touch the remote filesystem as plaintext until explicitly written.

Pattern 4: Generate on the remote host

fn generate_secrets() {
    password := `openssl rand -base64 32`
    `echo #{password} > /root/.db_password`
    `chmod 600 /root/.db_password`
}

ssh generate_secrets(), host: "10.0.0.5", user: "root"