Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,23 @@ APP_ORIGIN=https://involute.example.com

POSTGRES_DB=involute
POSTGRES_USER=involute
POSTGRES_PASSWORD=replace-with-a-long-random-password
POSTGRES_PASSWORD=replace-with-a-long-url-safe-random-password
DATABASE_URL=postgresql://involute:replace-with-a-long-url-safe-random-password@db:5432/involute?schema=public
POSTGRES_VOLUME_NAME=involute_postgres-prod-data

SERVER_BIND_ADDRESS=127.0.0.1
WEB_BIND_ADDRESS=127.0.0.1
INVOLUTE_IMAGE_NAMESPACE=fakechris
INVOLUTE_IMAGE_TAG=latest

AUTH_TOKEN=replace-with-a-long-random-token
VIEWER_ASSERTION_SECRET=replace-with-a-long-random-secret
SESSION_TTL_SECONDS=2592000
REQUIRE_GOOGLE_OAUTH=true

ADMIN_EMAIL_ALLOWLIST=you@example.com
SEED_DATABASE=false

GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_CLIENT_ID=replace-with-google-client-id
GOOGLE_OAUTH_CLIENT_SECRET=replace-with-google-client-secret
GOOGLE_OAUTH_REDIRECT_URI=https://involute.example.com/auth/google/callback
12 changes: 12 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ jobs:
INVOLUTE_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.INVOLUTE_GOOGLE_OAUTH_CLIENT_ID }}
INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET }}
INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI: ${{ secrets.INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI }}
INVOLUTE_IMAGE_TAG: ${{ vars.INVOLUTE_IMAGE_TAG || 'latest' }}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid defaulting production deploys to mutable latest.

Line 43 makes the deployed image depend on whatever latest points to at deploy time, which can race Docker publishing or redeploy an unintended build. Prefer requiring INVOLUTE_IMAGE_TAG for production, or deploy an immutable tag such as the published sha-<short-sha> tag.

Suggested direction
-      INVOLUTE_IMAGE_TAG: ${{ vars.INVOLUTE_IMAGE_TAG || 'latest' }}
+      INVOLUTE_IMAGE_TAG: ${{ vars.INVOLUTE_IMAGE_TAG }}

Then fail validation for production when it is unset, or set it from a workflow that runs after the image publish completes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/deploy.yml at line 43, The workflow currently falls back
to a mutable default by setting INVOLUTE_IMAGE_TAG: ${{ vars.INVOLUTE_IMAGE_TAG
|| 'latest' }}, so update the deploy job to disallow using 'latest' for
production: remove the 'latest' fallback and add a validation step (or job-level
if check) that fails when the environment is production and INVOLUTE_IMAGE_TAG
is empty or equals 'latest'; alternatively set INVOLUTE_IMAGE_TAG from the
image-publish job output (e.g., a sha-<short-sha> tag) before running the deploy
job. Ensure the check references the INVOLUTE_IMAGE_TAG variable and the
production environment name so deploys cannot proceed with a mutable tag.

INVOLUTE_REQUIRE_GOOGLE_OAUTH: ${{ vars.INVOLUTE_REQUIRE_GOOGLE_OAUTH || 'true' }}
steps:
- name: Validate required deployment secrets
run: |
Expand Down Expand Up @@ -68,6 +70,14 @@ jobs:
exit 1
fi
done
if [ "$INVOLUTE_REQUIRE_GOOGLE_OAUTH" = "true" ]; then
for var in INVOLUTE_GOOGLE_OAUTH_CLIENT_ID INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI; do
if [ -z "$(printenv "$var")" ]; then
echo "Missing required secret: $var" >&2
exit 1
fi
done
fi
fi

- name: Checkout
Expand Down Expand Up @@ -119,6 +129,8 @@ jobs:
"involute_google_oauth_client_id": os.environ.get("INVOLUTE_GOOGLE_OAUTH_CLIENT_ID", ""),
"involute_google_oauth_client_secret": os.environ.get("INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET", ""),
"involute_google_oauth_redirect_uri": os.environ.get("INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI", ""),
"involute_image_tag": os.environ.get("INVOLUTE_IMAGE_TAG", "latest"),
"involute_require_google_oauth": os.environ.get("INVOLUTE_REQUIRE_GOOGLE_OAUTH", "true"),
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ dist
.env
.env.production
ops/ansible/inventory/hosts.yml
ops/ansible/group_vars/**/*.yml
ops/ansible/vault-password.txt
.venv-ansible/
.DS_Store
!packages/server/prisma/.env
Expand Down
39 changes: 28 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,19 +148,21 @@ INVOLUTE_IMAGE_NAMESPACE=fakechris INVOLUTE_IMAGE_TAG=latest pnpm compose:pull:u

## VPS deployment (fresh install)

This is the recommended first production path: one VPS, Docker Compose, Postgres, the Node API, the static web container, and Caddy terminating HTTPS on a single domain.
This is the recommended first production path: one VPS, Docker Compose, Postgres, the Node API, and the static web container from published Docker Hub images. Terminate HTTPS with either the host reverse proxy or the optional compose Caddy profile, but keep one env file: `.env.production`.

Status:

- the deployment files and automation are in place
- the Tailscale-only path has already been exercised
- the remaining production work is to validate the same stack on a public domain with real Google OAuth callback and one backup/restore drill
- production should run through `.env.production` and `docker-compose.prod.images.yml`
- deployments should use Ansible Vault for secrets, not hand-edited env files
- every deploy runs smoke checks for `/health`, `/auth/session`, and `/auth/google/start`

Files involved:

- [`docker-compose.prod.yml`](./docker-compose.prod.yml)
- [`docker-compose.prod.images.yml`](./docker-compose.prod.images.yml)
- [`Caddyfile`](./Caddyfile)
- [`.env.production.example`](./.env.production.example)
- [`scripts/prod-smoke.sh`](./scripts/prod-smoke.sh)
- [`scripts/postgres-backup.sh`](./scripts/postgres-backup.sh)

Assumptions:
Expand All @@ -181,8 +183,10 @@ cp .env.production.example .env.production
APP_DOMAIN=involute.example.com
APP_ORIGIN=https://involute.example.com
POSTGRES_PASSWORD=...
DATABASE_URL=postgresql://involute:<same-url-safe-password>@db:5432/involute?schema=public
AUTH_TOKEN=...
VIEWER_ASSERTION_SECRET=...
REQUIRE_GOOGLE_OAUTH=true
ADMIN_EMAIL_ALLOWLIST=you@example.com
GOOGLE_OAUTH_CLIENT_ID=...
GOOGLE_OAUTH_CLIENT_SECRET=...
Expand All @@ -198,22 +202,24 @@ pnpm compose:prod:up
4. Smoke check it:

```bash
docker compose --env-file .env.production -f docker-compose.prod.yml ps
curl -I https://involute.example.com
curl https://involute.example.com/health
docker compose --env-file .env.production -f docker-compose.prod.images.yml ps
pnpm smoke:prod https://involute.example.com
```

5. If you need to re-assert the first admin explicitly:

```bash
docker compose --env-file .env.production -f docker-compose.prod.yml run --rm \
docker compose --env-file .env.production -f docker-compose.prod.images.yml run --rm \
--entrypoint /bin/sh server -lc \
'pnpm --filter @turnkeyai/involute-server run admin:bootstrap you@example.com'
```

Operational notes:

- production compose keeps Postgres internal; only Caddy exposes `80/443`
- production compose passes `DATABASE_URL` directly to the API and migration containers; do not rely on ad-hoc URL concatenation
- use a URL-safe Postgres password so `DATABASE_URL` and `POSTGRES_PASSWORD` stay identical
- by default, `docker-compose.prod.images.yml` exposes API/web on `SERVER_BIND_ADDRESS:4200` and `WEB_BIND_ADDRESS:4201` for a host reverse proxy
- to let compose Caddy own `80/443`, run with the `caddy` profile and ensure host nginx/apache is not bound to those ports
- `server-init` runs `prisma migrate deploy` before the API starts
- `SEED_DATABASE` defaults to `false` in production; turn it on only for a fresh demo seed
- the web container is the static production build, not the Vite dev server
Expand Down Expand Up @@ -265,7 +271,17 @@ For the current Tailscale-only test phase, use:
- `involute_bind_address: <tailscale-ip>`
- `involute_app_origin: http://<tailscale-ip>:4201`

When the public domain and OAuth are ready, switch the inventory to `production` and use [`docker-compose.prod.yml`](./docker-compose.prod.yml).
When the public domain and OAuth are ready, switch the inventory to `production` and use [`docker-compose.prod.images.yml`](./docker-compose.prod.images.yml).

For local Ansible deploys, keep secrets in an encrypted vault file:

```bash
cp ops/ansible/group_vars/all/vault.yml.example ops/ansible/group_vars/all/vault.yml
ansible-vault encrypt ops/ansible/group_vars/all/vault.yml
ANSIBLE_VAULT_PASSWORD_FILE=ops/ansible/vault-password.txt pnpm deploy:prod
Comment on lines +279 to +281
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Include the vault password-file creation step.

This flow encrypts vault.yml, then deploys with ANSIBLE_VAULT_PASSWORD_FILE=ops/ansible/vault-password.txt, but never tells the operator to create that file with the same vault password. The deploy command will fail if the file is missing or contains a different password.

Docs patch
 cp ops/ansible/group_vars/all/vault.yml.example ops/ansible/group_vars/all/vault.yml
-ansible-vault encrypt ops/ansible/group_vars/all/vault.yml
+printf '%s\n' '<choose-a-vault-password>' > ops/ansible/vault-password.txt
+chmod 600 ops/ansible/vault-password.txt
+ANSIBLE_VAULT_PASSWORD_FILE=ops/ansible/vault-password.txt \
+  ansible-vault encrypt ops/ansible/group_vars/all/vault.yml
 ANSIBLE_VAULT_PASSWORD_FILE=ops/ansible/vault-password.txt pnpm deploy:prod
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cp ops/ansible/group_vars/all/vault.yml.example ops/ansible/group_vars/all/vault.yml
ansible-vault encrypt ops/ansible/group_vars/all/vault.yml
ANSIBLE_VAULT_PASSWORD_FILE=ops/ansible/vault-password.txt pnpm deploy:prod
cp ops/ansible/group_vars/all/vault.yml.example ops/ansible/group_vars/all/vault.yml
printf '%s\n' '<choose-a-vault-password>' > ops/ansible/vault-password.txt
chmod 600 ops/ansible/vault-password.txt
ANSIBLE_VAULT_PASSWORD_FILE=ops/ansible/vault-password.txt \
ansible-vault encrypt ops/ansible/group_vars/all/vault.yml
ANSIBLE_VAULT_PASSWORD_FILE=ops/ansible/vault-password.txt pnpm deploy:prod
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 279 - 281, The README is missing the step to create
the ANSIBLE_VAULT_PASSWORD_FILE used by the deploy command; add an explicit
instruction to create ops/ansible/vault-password.txt containing the same vault
password (or a secure symlink to your password manager output) and set strict
permissions (e.g., chmod 600) before running
ANSIBLE_VAULT_PASSWORD_FILE=ops/ansible/vault-password.txt pnpm deploy:prod so
the deploy can read the vault password; reference the vault file creation step
(cp ops/ansible/group_vars/all/vault.yml.example ...) and the
ANSIBLE_VAULT_PASSWORD_FILE environment variable in the same block.

```

`ops/ansible/group_vars/all/vault.yml` and `ops/ansible/vault-password.txt` are ignored by git.

GitHub Actions can run the same deployment path from [`.github/workflows/deploy.yml`](./.github/workflows/deploy.yml). Configure these repository secrets before enabling it:

Expand All @@ -278,7 +294,8 @@ GitHub Actions can run the same deployment path from [`.github/workflows/deploy.
- `INVOLUTE_VIEWER_ASSERTION_SECRET`
- `INVOLUTE_BIND_ADDRESS` for `tailscale`
- `INVOLUTE_APP_DOMAIN` and `INVOLUTE_POSTGRES_PASSWORD` for `production`
- optional: `INVOLUTE_ADMIN_EMAIL_ALLOWLIST`, `INVOLUTE_GOOGLE_OAUTH_CLIENT_ID`, `INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET`, `INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI`
- `INVOLUTE_GOOGLE_OAUTH_CLIENT_ID`, `INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET`, `INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI` when `REQUIRE_GOOGLE_OAUTH=true`
- optional: `INVOLUTE_ADMIN_EMAIL_ALLOWLIST`, `INVOLUTE_IMAGE_TAG`

Recommended repository variables:

Expand Down
15 changes: 12 additions & 3 deletions docker-compose.prod.images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ services:
environment:
ADMIN_EMAIL_ALLOWLIST: ${ADMIN_EMAIL_ALLOWLIST:-}
GOOGLE_OAUTH_ADMIN_EMAILS: ${ADMIN_EMAIL_ALLOWLIST:-${GOOGLE_OAUTH_ADMIN_EMAILS:-}}
DATABASE_URL: postgresql://${POSTGRES_USER:-involute}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-involute}?schema=public
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production}
SEED_DATABASE: ${SEED_DATABASE:-false}
SEED_DEFAULT_ADMIN: "false"

Expand All @@ -54,11 +54,12 @@ services:
ALLOW_ADMIN_FALLBACK: "false"
APP_ORIGIN: ${APP_ORIGIN:?Set APP_ORIGIN in .env.production}
AUTH_TOKEN: ${AUTH_TOKEN:?Set AUTH_TOKEN in .env.production}
DATABASE_URL: postgresql://${POSTGRES_USER:-involute}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-involute}?schema=public
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production}
GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-}
GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-}
GOOGLE_OAUTH_REDIRECT_URI: ${GOOGLE_OAUTH_REDIRECT_URI:-}
PORT: 4200
REQUIRE_GOOGLE_OAUTH: ${REQUIRE_GOOGLE_OAUTH:-true}
SESSION_TTL_SECONDS: ${SESSION_TTL_SECONDS:-2592000}
VIEWER_ASSERTION_SECRET: ${VIEWER_ASSERTION_SECRET:?Set VIEWER_ASSERTION_SECRET in .env.production}
healthcheck:
Expand All @@ -73,6 +74,8 @@ services:
timeout: 5s
retries: 20
start_period: 10s
ports:
- "${SERVER_BIND_ADDRESS:-127.0.0.1}:4200:4200"

web:
image: ${INVOLUTE_IMAGE_REGISTRY:-docker.io}/${INVOLUTE_IMAGE_NAMESPACE:-fakechris}/involute-web:${INVOLUTE_IMAGE_TAG:-latest}
Expand All @@ -83,9 +86,12 @@ services:
depends_on:
server:
condition: service_healthy
ports:
- "${WEB_BIND_ADDRESS:-127.0.0.1}:4201:4201"

caddy:
image: caddy:2.10-alpine
profiles: ["caddy"]
restart: unless-stopped
depends_on:
server:
Expand All @@ -112,13 +118,16 @@ services:
condition: service_healthy
environment:
AUTH_TOKEN: ${AUTH_TOKEN:?Set AUTH_TOKEN in .env.production}
DATABASE_URL: postgresql://${POSTGRES_USER:-involute}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-involute}?schema=public
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production}
INVOLUTE_CONFIG_PATH: /tmp/involute-config.json
VIEWER_ASSERTION_SECRET: ${VIEWER_ASSERTION_SECRET:?Set VIEWER_ASSERTION_SECRET in .env.production}
volumes:
- ./.tmp:/exports

volumes:
postgres-prod-data:
name: ${POSTGRES_VOLUME_NAME:-involute_postgres-prod-data}
caddy-data:
name: ${CADDY_DATA_VOLUME_NAME:-involute_caddy-data}
caddy-config:
name: ${CADDY_CONFIG_VOLUME_NAME:-involute_caddy-config}
1 change: 1 addition & 0 deletions ops/ansible/group_vars/all/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

16 changes: 16 additions & 0 deletions ops/ansible/group_vars/all/vault.yml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copy this file to vault.yml, replace values, then encrypt it:
#
# ansible-vault encrypt ops/ansible/group_vars/all/vault.yml
#
# Keep vault.yml and the vault password file out of git.

involute_auth_token: replace-with-a-long-random-token
involute_viewer_assertion_secret: replace-with-a-long-random-secret
involute_admin_email_allowlist: you@example.com

# Use a URL-safe password because DATABASE_URL is built from this value.
involute_postgres_password: replace-with-a-long-url-safe-random-password

involute_google_oauth_client_id: replace-with-google-client-id
involute_google_oauth_client_secret: replace-with-google-client-secret
involute_google_oauth_redirect_uri: https://involute.example.com/auth/google/callback
15 changes: 8 additions & 7 deletions ops/ansible/inventory/hosts.yml.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
all:
hosts:
involute_tailscale:
involute_production:
ansible_host: tailnet-host.example.ts.net
ansible_user: root
involute_stack_profile: production
involute_bind_address: 100.x.y.z
involute_stack_profile: tailscale
involute_app_origin: http://100.x.y.z:4201
involute_seed_database: true
involute_admin_email_allowlist: "first-admin@example.com"
involute_auth_token: change-me
involute_viewer_assertion_secret: change-me
involute_app_domain: involute.example.com
involute_app_origin: https://involute.example.com
involute_smoke_base_url: https://involute.example.com
involute_seed_database: false
involute_require_google_oauth: true
# Put real secrets in ops/ansible/group_vars/all/vault.yml.
Comment on lines +3 to +13
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Example still carries Tailscale-style bind address under the production profile.

ansible_host: tailnet-host.example.ts.net and involute_bind_address: 100.x.y.z are CGNAT/Tailscale values, but the profile is now production with https://involute.example.com as origin. Via the template fallback chain in env.production.j2, this will set both SERVER_BIND_ADDRESS and WEB_BIND_ADDRESS to 100.x.y.z, which conflicts with the 127.0.0.1 defaults shown in .env.production.example and with a typical production layout (Caddy fronting loopback-bound services).

Either drop involute_bind_address here (so the template falls through to 127.0.0.1) or set involute_server_bind_address/involute_web_bind_address explicitly and use a realistic ansible_host, so the example isn't self-contradictory.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ops/ansible/inventory/hosts.yml.example` around lines 3 - 13, The example
inventory's production profile is inconsistent: remove or replace the Tailscale
CGNAT values so templates don't set SERVER_BIND_ADDRESS/WEB_BIND_ADDRESS to
100.x.y.z; either delete involute_bind_address from the involute_production
block (letting env.production.j2 fall back to 127.0.0.1) or add explicit keys
involute_server_bind_address and involute_web_bind_address with
loopback/realistic production values, and change ansible_host from
tailnet-host.example.ts.net to a realistic host name or IP; update the
involute_production block accordingly so env.production.j2 and
.env.production.example are consistent.

Loading
Loading