First time? Start with How to bootstrap a fresh Ubuntu VPS for Ansible deployments before continuing here.

Stack for this setup

Ansible: configure the server over SSH
Nginx: shared reverse proxy
systemd: run backend services
Certbot: Let's Encrypt certificates
pnpm: JavaScript/TypeScript monorepo workspaces
Turborepo: monorepo task orchestration
Biome: formatting and linting
Mise: tool versions and task aliases
Copier: new project scaffold generation (optional)

Ansible configures server state over SSH

Ansible SSHs into the server and makes it match the expected state. It can:

  • create app users
  • create /srv/<project> directories
  • clone or update Git repositories
  • install dependencies
  • audit dependencies for high/critical vulnerabilities (pnpm audit --prod --audit-level=high)
  • build the project
  • write Nginx configs
  • write systemd service files
  • enable and restart services
  • configure Certbot certificates
  • reload Nginx

Infrastructure contract

Each project repo contains infra/project.yml, the app-specific deployment contract.

infra/project.yml
Project repo:  "Here is what this app needs."
Platform repo: "Here is how this VPS satisfies those needs."

Example project shape

A common monorepo structure:

my-project/
  apps/
    client/        # React frontend
    server/        # API/contact capture backend
    dashboard/     # admin dashboard
  packages/
    shared/        # shared types, schemas, utilities
  infra/
    project.yml
  biome.json
  mise.toml
  package.json
  pnpm-workspace.yaml
  turbo.json

An infra/project.yml deployment contract:

id: book-platform
domain: example.com
aliases:
  - www.example.com

repo: git@github.com:username/book-platform.git
branch: main

deploy:
  base_dir: /srv/book-platform
  user: svc_book_platform
  group: svc_book_platform
  releases_dir: /srv/book-platform/releases
  current_dir: /srv/book-platform/current
  build_command: pnpm turbo build   # optional; defaults to pnpm turbo build

tls:
  enabled: true
  email: hello@example.com
  cert_name: example.com
  webroot: /var/www/_letsencrypt

frontend:
  enabled: true
  kind: static
  app: apps/client
  build_command: pnpm turbo build --filter=@book-platform/client
  artifact_dir: apps/client/dist
  public_root: /srv/book-platform/client/current

dashboard:
  enabled: true
  kind: static
  app: apps/dashboard
  build_command: pnpm turbo build --filter=@book-platform/dashboard
  artifact_dir: apps/dashboard/dist
  public_root: /srv/book-platform/dashboard/current
  route: /admin

server:
  enabled: true
  kind: node
  app: apps/server
  build_command: pnpm turbo build --filter=@book-platform/server
  start_command: /usr/bin/node apps/server/dist/index.js
  port: 3101
  service_name: book-platform-server
  env:
    NODE_ENV: production
    DATABASE_URL: ""

server.env variables are injected at both build time and runtime. SSR frameworks (Next.js, Remix) validate required environment variables at module initialization during the build — they fail without them present.

Platform repo shape

acleron-platform/
  ansible/
    inventory.ini
    group_vars/
      shared.yml
      vault.example.yml
    playbooks/
      bootstrap.yml
      deploy.yml
    roles/
      base/
      nginx/
      certbot/
      app_static/
      app_node/
      deploy_monorepo/
  scripts/
    gen-deploy-key.sh   # generate a per-project GitHub deploy key
  templates/
    nginx-http-bootstrap.conf.j2
    nginx-https-site.conf.j2
    systemd-node.service.j2
  bootstrap.sh
  mise.toml
  README.md

Point the deploy command at the project’s own config

ansible-playbook ansible/playbooks/deploy.yml \
  -e project_config=../book-platform/infra/project.yml

First deploy: deploy key

Every project gets a unique ed25519 deploy key so the VPS can clone from GitHub without using a personal SSH key. The platform includes a script that generates the key and prints the exact installation steps.

cd acleron-platform
./scripts/gen-deploy-key.sh <project-id>

The script outputs four steps:

1. Paste the public key into GitHub → repo Settings → Deploy keys (read-only)
2. SCP the private key to the VPS through the bastion
3. SSH to the VPS and move the key into place with correct ownership
4. Delete the local key pair

On a brand-new project the deploy user (svc_<id>) does not exist until Ansible creates it. Run the deploy once — it will fail at the git clone step — then install the key and re-run.

# 1. First Ansible run — creates the user and directory structure, fails at clone
mise run deploy

# 2. Install the deploy key (see script output for exact commands)
./scripts/gen-deploy-key.sh <project-id>

# 3. Re-run — clone succeeds, build and service start
mise run deploy

Certbot first-run flow

Separate ownership responsibilities

Ansible owns Nginx.
Certbot owns /etc/letsencrypt.
Nginx reads certificates from /etc/letsencrypt.

First-run TLS sequence:

  1. Create the ACME webroot directory.
  2. Render a temporary HTTP-only Nginx config.
  3. Reload Nginx.
  4. Run Certbot in certonly --webroot mode.
  5. Render the final HTTPS Nginx config.
  6. Test Nginx.
  7. Reload Nginx.

The temporary HTTP config serves only the ACME challenge path:

/.well-known/acme-challenge/

Nginx routing model

SPA mode (frontend.enabled: true):

https://example.com/        > React frontend (static, HTML5 history fallback)
https://example.com/api/    > Node backend on localhost:3101
https://example.com/admin/  > dashboard frontend (static)

SSR mode (frontend.enabled: false, server.enabled: true):

https://example.com/api/    > Node backend on localhost:3101 (explicit location)
https://example.com/*       > Node backend catch-all (SSR handles all routes)

In SSR mode Nginx adds a catch-all location / that proxies all remaining requests to the Node.js backend. This supports Next.js SSR, Remix, and any framework that handles routing server-side. Explicit locations (/api/) retain priority via Nginx location match precedence.

The backend runs as a systemd service. Frontend and dashboard are static build artifacts served directly by Nginx when enabled.

systemd role

Ansible renders a systemd unit for each backend app:

[Unit]
Description=book-platform Node.js server
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=svc_book_platform
Group=svc_book_platform
WorkingDirectory=/srv/book-platform/current
ExecStart=/usr/bin/node apps/server/dist/index.js

Restart=on-failure
RestartSec=5s
StartLimitBurst=3
StartLimitIntervalSec=30

StandardOutput=journal
StandardError=journal
SyslogIdentifier=book-platform-server

Environment="NODE_ENV=production"
Environment="DATABASE_URL=postgresql://user:pass@localhost:5432/bookdb"

[Install]
WantedBy=multi-user.target

Enable and start the service

systemctl daemon-reload
systemctl enable book-platform-server
systemctl restart book-platform-server

After restart, Ansible polls is-active up to 6 times at 5-second intervals (30 seconds total) before failing the deploy. This accounts for frameworks like Next.js that take several seconds to bind their port after systemd reports the process as started.

Local developer workflow

Every project repo includes a mise.toml that pins the same Node and pnpm major versions the platform installs on the VPS. Running mise install before anything else ensures the local toolchain matches the deployment target.

The package.json packageManager field locks the exact pnpm minor version, which corepack enforces:

{
  "packageManager": "pnpm@11.0.0"
}

Projects must adhere to the platform’s versions — exceptions belong in group_vars/all.yml, not in per-project workarounds.

[tools]
node = "24"
pnpm = "11"

[tasks.install]
run = "pnpm install"
description = "Install all workspace dependencies"

[tasks.dev]
run = "pnpm turbo dev"
description = "Start all apps in dev mode"

[tasks.build]
run = "pnpm turbo build"
description = "Build all packages and apps"

[tasks.lint]
run = "pnpm biome lint ."
description = "Lint all source files with Biome"

[tasks.format]
run = "pnpm biome format --write ."
description = "Format all source files with Biome"

[tasks.check]
run = "pnpm biome check ."
description = "Check lint + format with Biome"

[tasks.typecheck]
run = "pnpm turbo typecheck"
description = "Type-check all packages"

[tasks.test]
run = "pnpm turbo test"
description = "Run all tests"

[tasks.verify]
depends = ["lint", "typecheck", "test"]
description = "Full pre-deploy verification (lint + typecheck + test)"

[tasks.deploy]
run = "ansible-playbook ../acleron-platform/ansible/playbooks/deploy.yml -e project_config=infra/project.yml"
description = "Deploy to VPS using acleron-platform"

[tasks.deploy-check]
run = "ansible-playbook ../acleron-platform/ansible/playbooks/deploy.yml -e project_config=infra/project.yml --check --diff"
description = "Dry-run deploy — shows what would change without applying"
mise install
mise run verify
mise run deploy-check   # dry run — shows what would change
mise run deploy

Turborepo task configuration

Shared packages build before apps that depend on them.

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^typecheck"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Vault secrets

The platform repo contains no credentials. Secrets live in a vault file outside the repo so the platform can be shared, published, or reused across VPS servers without exposing project data.

~/.acleron/vault.yml   ← never committed, never shared

Naming convention: vault_<project_id>_<secret_name>

Each project’s infra/project.yml references only its own vault variables:

server:
  env:
    DATABASE_URL: ""
    STRIPE_SECRET_KEY: ""

First-time setup on a new machine:

mkdir -p ~/.acleron
cp acleron-platform/ansible/group_vars/vault.example.yml ~/.acleron/vault.yml

# Fill in real values, then encrypt:
nano ~/.acleron/vault.yml
ansible-vault encrypt ~/.acleron/vault.yml

# Store the vault password so you're not prompted on every deploy:
echo "your-vault-password" > ~/.acleron/.vault_pass
chmod 600 ~/.acleron/.vault_pass
export ANSIBLE_VAULT_PASSWORD_FILE=~/.acleron/.vault_pass

The deploy playbook loads secrets from ACLERON_VAULT_FILE (default: ~/.acleron/vault.yml) before processing any project config. This is set in mise.toml so it’s automatic when using mise run deploy.

Deploy an existing project

cd my-project
mise run deploy

Or directly from the platform repo:

ansible-playbook ansible/playbooks/deploy.yml \
  -e project_config=../my-project/infra/project.yml

Deploy a new project with Copier

copier copy acleron-project-template new-project
cd new-project
mise install
mise run check
mise run build
mise run deploy

References: