How to set up repeatable VPS project infrastructure with Ansible, Nginx, and systemd
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.envvariables 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:
- Create the ACME webroot directory.
- Render a temporary HTTP-only Nginx config.
- Reload Nginx.
- Run Certbot in
certonly --webrootmode. - Render the final HTTPS Nginx config.
- Test Nginx.
- 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: