426 lines
13 KiB
Markdown
426 lines
13 KiB
Markdown
# Self-Hosted Deployment Plan
|
|
|
|
Deploy sites to a custom server via SSH + rsync + nginx, alongside the existing git-based (GitHub/GitLab Pages) publishing.
|
|
|
|
---
|
|
|
|
## Design Decisions
|
|
|
|
1. **Git push always happens** when a `GitRemote` is configured — regardless of deployment target.
|
|
2. **Self-hosted deploy is additive** — rsyncs to the server on top of the git push.
|
|
3. **User selects `deploymentTarget`** on the Site (`GIT_PAGES`, `SELF_HOSTED`, or `NONE`) — controls which URL is the primary published URL and which prereqs are validated on publish.
|
|
4. **Custom domain lives in `SelfHostedConfig`**, not on `Site`. Custom domains for git pages are handled outside Indie.
|
|
|
|
---
|
|
|
|
## Implementation Steps
|
|
|
|
### Step 1 — Site Model: Add DeploymentTarget
|
|
|
|
**File: `model/Site.java`** — add enum + field:
|
|
|
|
```java
|
|
public enum DeploymentTarget {
|
|
GIT_PAGES, SELF_HOSTED, NONE
|
|
}
|
|
|
|
@Enumerated(EnumType.STRING)
|
|
@Column(nullable = false)
|
|
@Builder.Default
|
|
private DeploymentTarget deploymentTarget = DeploymentTarget.NONE;
|
|
```
|
|
|
|
**File: `model/SiteStatus.java`** — no change needed.
|
|
|
|
### Step 2 — DTOs: Update SiteRequest/SiteResponse
|
|
|
|
**File: `dto/SiteRequest.java`** — add:
|
|
```java
|
|
private String deploymentTarget; // "GIT_PAGES", "SELF_HOSTED", "NONE"
|
|
```
|
|
|
|
**File: `dto/SiteResponse.java`** — add:
|
|
```java
|
|
private String deploymentTarget;
|
|
```
|
|
Update `from(Site site)` to map the field.
|
|
|
|
### Step 3 — SelfHostedConfig Entity (New)
|
|
|
|
**File: `model/SelfHostedConfig.java`:**
|
|
|
|
```java
|
|
package com.krrishg.model;
|
|
|
|
import jakarta.persistence.*;
|
|
import lombok.*;
|
|
import java.time.LocalDateTime;
|
|
import java.util.UUID;
|
|
|
|
@Entity
|
|
@Table(name = "self_hosted_configs")
|
|
@Data @Builder @NoArgsConstructor @AllArgsConstructor
|
|
public class SelfHostedConfig {
|
|
@Id
|
|
private UUID id;
|
|
|
|
@Column(nullable = false, unique = true)
|
|
private UUID siteId;
|
|
|
|
@Column(nullable = false, unique = true)
|
|
private String domain;
|
|
|
|
@Column(nullable = false)
|
|
private String sshHost;
|
|
|
|
@Column(nullable = false)
|
|
@Builder.Default
|
|
private int sshPort = 22;
|
|
|
|
@Column(nullable = false)
|
|
private String sshUser;
|
|
|
|
@Column(nullable = false, length = 8192)
|
|
private String encryptedSshKey;
|
|
|
|
@Column(nullable = false)
|
|
@Builder.Default
|
|
private String deployPath = "/var/www/{domain}/public";
|
|
|
|
@Column(nullable = false)
|
|
@Builder.Default
|
|
private String nginxSitesPath = "/etc/nginx/sites-available";
|
|
|
|
@Column(nullable = false)
|
|
@Builder.Default
|
|
private Boolean nginxSslEnabled = false;
|
|
|
|
private LocalDateTime deployedAt;
|
|
|
|
@PrePersist
|
|
void onCreate() {
|
|
if (id == null) id = UUID.randomUUID();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 4 — Repository (New)
|
|
|
|
**File: `repository/SelfHostedConfigRepository.java`:**
|
|
|
|
```java
|
|
package com.krrishg.repository;
|
|
|
|
import com.krrishg.model.SelfHostedConfig;
|
|
import org.springframework.data.jpa.repository.JpaRepository;
|
|
import java.util.Optional;
|
|
import java.util.UUID;
|
|
|
|
public interface SelfHostedConfigRepository extends JpaRepository<SelfHostedConfig, UUID> {
|
|
Optional<SelfHostedConfig> findBySiteId(UUID siteId);
|
|
Optional<SelfHostedConfig> findByDomain(String domain);
|
|
boolean existsBySiteId(UUID siteId);
|
|
boolean existsByDomain(String domain);
|
|
}
|
|
```
|
|
|
|
### Step 5 — KeyManager: SSH Key Encryption
|
|
|
|
**File: `service/KeyManager.java`** — add methods using the same AES-256-GCM pattern as git token encryption:
|
|
|
|
```java
|
|
public String encryptSshKey(String rawKey);
|
|
public String decryptSshKey(String encryptedKey);
|
|
```
|
|
|
|
### Step 6 — Deployer Interface (New)
|
|
|
|
**File: `service/Deployer.java`:**
|
|
|
|
```java
|
|
package com.krrishg.service;
|
|
|
|
import com.krrishg.model.SelfHostedConfig;
|
|
import com.krrishg.service.RenderService.SiteOutput;
|
|
|
|
public interface Deployer {
|
|
String deploy(SiteOutput output, SelfHostedConfig config) throws Exception;
|
|
void remove(SelfHostedConfig config) throws Exception;
|
|
String buildUrl(SelfHostedConfig config);
|
|
}
|
|
```
|
|
|
|
### Step 7 — SshRsyncDeployer Implementation (New)
|
|
|
|
**File: `service/SshRsyncDeployer.java`:**
|
|
|
|
Key operations:
|
|
|
|
**`deploy()`:**
|
|
1. Render site files to temp directory
|
|
2. Expand `{domain}` placeholder in `deployPath` to `config.getDomain()`
|
|
3. Decrypt SSH key to temp file (chmod 0600, delete after)
|
|
4. `ssh <user>@<host> -p <port> -i <keyfile> "mkdir -p <deployPath>"`
|
|
5. `rsync -avz --delete -e "ssh -p <port> -i <keyfile> -o StrictHostKeyChecking=accept-new" <tempDir>/ <user>@<host>:<deployPath>/`
|
|
6. Generate nginx config string for the domain
|
|
7. Write nginx config to temp file, rsync it to `<nginxSitesPath>/<domain>` on server
|
|
8. `ssh <user>@<host> -i <keyfile> "sudo ln -sf <nginxSitesPath>/<domain> /etc/nginx/sites-enabled/ && sudo nginx -s reload"`
|
|
9. Clean up temp files
|
|
10. Return `"https://" + domain + "/"`
|
|
|
|
**Nginx config template:**
|
|
```
|
|
server {
|
|
listen 80;
|
|
server_name <domain>;
|
|
root <deployPath>;
|
|
index index.html;
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
}
|
|
```
|
|
|
|
Optionally support SSL: if `nginxSslEnabled`, also generate a `listen 443 ssl;` block with placeholder cert paths, or skip SSL (user configures separately).
|
|
|
|
**`remove()`:**
|
|
1. SSH `rm -rf <deployPath>`
|
|
2. SSH `sudo rm <nginxSitesPath>/<domain> /etc/nginx/sites-enabled/<domain>`
|
|
3. SSH `sudo nginx -s reload`
|
|
|
|
### Step 8 — DeploymentService: Dispatch Logic
|
|
|
|
**File: `service/DeploymentService.java`** — inject `SelfHostedConfigRepository`, `Deployer`, `SshRsyncDeployer`.
|
|
|
|
Update `publish()`:
|
|
|
|
```java
|
|
public String publish(UUID siteId) {
|
|
Site site = siteRepository.findById(siteId)
|
|
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
|
|
|
SiteStructure structure = getLatestVersion(siteId);
|
|
if (structure == null) {
|
|
throw new IllegalStateException("No generation versions found");
|
|
}
|
|
|
|
SiteOutput output = renderService.renderSite(structure);
|
|
|
|
// Step 1: Always push to git if remote is configured
|
|
GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null);
|
|
if (remote != null) {
|
|
gitService.deployToPages(siteId.toString(), remote, structure);
|
|
}
|
|
|
|
// Step 2: Additionally deploy to custom server if configured
|
|
SelfHostedConfig selfHostedConfig = selfHostedConfigRepository.findBySiteId(siteId).orElse(null);
|
|
if (selfHostedConfig != null) {
|
|
deployer.deploy(output, selfHostedConfig);
|
|
}
|
|
|
|
// Step 3: Validate and set URL based on deployment target
|
|
String url = null;
|
|
switch (site.getDeploymentTarget()) {
|
|
case GIT_PAGES:
|
|
if (remote == null) {
|
|
throw new IllegalStateException("Deployment target is GIT_PAGES but no git remote is configured");
|
|
}
|
|
PagesProvider provider = PagesProvider.forRemote(remote);
|
|
url = provider.buildPagesUrl(remote);
|
|
break;
|
|
case SELF_HOSTED:
|
|
if (selfHostedConfig == null) {
|
|
throw new IllegalStateException("Deployment target is SELF_HOSTED but no self-hosted config exists");
|
|
}
|
|
url = deployer.buildUrl(selfHostedConfig);
|
|
break;
|
|
case NONE:
|
|
// Still publish but no primary URL — user might want git push without pages URL
|
|
break;
|
|
}
|
|
|
|
site.setStatus(SiteStatus.PUBLISHED);
|
|
site.setPublishedAt(LocalDateTime.now());
|
|
site.setPublishedUrl(url);
|
|
siteRepository.save(site);
|
|
|
|
log.info("Published site {}: target={}, url={}", siteId, site.getDeploymentTarget(), url);
|
|
return url;
|
|
}
|
|
```
|
|
|
|
Update `unpublish()` similarly — still removes git pages branch, and also removes from self-hosted server if configured.
|
|
|
|
### Step 9 — SelfHostedController (New)
|
|
|
|
**File: `controller/SelfHostedController.java`:**
|
|
|
|
`@RestController @RequestMapping("/api/sites/{id}/self-hosted")`
|
|
|
|
| Method | Path | Purpose |
|
|
|--------|------|---------|
|
|
| `GET` | `/api/sites/{id}/self-hosted` | Get config (sshKey omitted from response) |
|
|
| `PUT` | `/api/sites/{id}/self-hosted` | Create/update config. Encrypts sshKey via KeyManager. Optionally sets `site.deploymentTarget = SELF_HOSTED` |
|
|
| `DELETE` | `/api/sites/{id}/self-hosted` | Remove config. Removes files from server via `deployer.remove()`. Resets `site.deploymentTarget` if it was `SELF_HOSTED` |
|
|
|
|
All endpoints require ownership check (same pattern as `SiteController.findSiteForUser`).
|
|
|
|
### Step 10 — SiteController: Accept deploymentTarget
|
|
|
|
**File: `controller/SiteController.java`** — in `createSite()` and `updateSite()`, set `site.setDeploymentTarget(...)` from the request. Validate that if target is `GIT_PAGES`, a GitRemote exists (or at least warn). If `SELF_HOSTED`, a `SelfHostedConfig` should exist.
|
|
|
|
### Step 11 — DTOs for Self-Hosted (New)
|
|
|
|
**File: `dto/SelfHostedConfigRequest.java`:**
|
|
```java
|
|
@Data
|
|
public class SelfHostedConfigRequest {
|
|
@NotBlank private String domain;
|
|
@NotBlank private String sshHost;
|
|
private int sshPort = 22;
|
|
@NotBlank private String sshUser;
|
|
@NotBlank private String sshKey;
|
|
private String deployPath;
|
|
private String nginxSitesPath;
|
|
private Boolean nginxSslEnabled;
|
|
}
|
|
```
|
|
|
|
**File: `dto/SelfHostedConfigResponse.java`:**
|
|
```java
|
|
@Data @Builder
|
|
public class SelfHostedConfigResponse {
|
|
private UUID id;
|
|
private UUID siteId;
|
|
private String domain;
|
|
private String sshHost;
|
|
private int sshPort;
|
|
private String sshUser;
|
|
private String deployPath;
|
|
private String nginxSitesPath;
|
|
private Boolean nginxSslEnabled;
|
|
private LocalDateTime deployedAt;
|
|
}
|
|
```
|
|
|
|
### Step 12 — Frontend: Models
|
|
|
|
**File: `frontend/src/app/core/models/site.ts`** — add to `SiteResponse`:
|
|
```typescript
|
|
deploymentTarget: 'GIT_PAGES' | 'SELF_HOSTED' | 'NONE';
|
|
```
|
|
|
|
**File: `frontend/src/app/core/models/deployment.ts`** — add:
|
|
```typescript
|
|
export interface SelfHostedConfigRequest {
|
|
domain: string;
|
|
sshHost: string;
|
|
sshPort: number;
|
|
sshUser: string;
|
|
sshKey: string;
|
|
deployPath: string;
|
|
nginxSitesPath: string;
|
|
nginxSslEnabled: boolean;
|
|
}
|
|
|
|
export interface SelfHostedConfigResponse {
|
|
id: string;
|
|
siteId: string;
|
|
domain: string;
|
|
sshHost: string;
|
|
sshPort: number;
|
|
sshUser: string;
|
|
deployPath: string;
|
|
nginxSitesPath: string;
|
|
nginxSslEnabled: boolean;
|
|
deployedAt: string | null;
|
|
}
|
|
```
|
|
|
|
### Step 13 — Frontend: Site Service
|
|
|
|
**File: `frontend/src/app/core/services/site.service.ts`** — add:
|
|
```typescript
|
|
getSelfHostedConfig(siteId: string): Observable<SelfHostedConfigResponse> {
|
|
return this.http.get<SelfHostedConfigResponse>(`/api/sites/${siteId}/self-hosted`);
|
|
}
|
|
|
|
saveSelfHostedConfig(siteId: string, config: SelfHostedConfigRequest): Observable<SelfHostedConfigResponse> {
|
|
return this.http.put<SelfHostedConfigResponse>(`/api/sites/${siteId}/self-hosted`, config);
|
|
}
|
|
|
|
deleteSelfHostedConfig(siteId: string): Observable<void> {
|
|
return this.http.delete<void>(`/api/sites/${siteId}/self-hosted`);
|
|
}
|
|
```
|
|
|
|
### Step 14 — Frontend: Settings Component
|
|
|
|
**File: `frontend/src/app/llm-workspace/settings/settings.component.ts`** — add:
|
|
|
|
1. **Deployment Target selector** (dropdown) — between "Git Pages", "Self-Hosted", "None". Shows status indicators for which targets are configured.
|
|
|
|
2. **Self-Hosted Config card** — expandable section with form fields for domain, SSH host, SSH port, SSH user, SSH key (textarea, masked), deploy path, nginx sites path. Save and Remove buttons.
|
|
|
|
3. **Publishing card update** — show which target will be used and the expected URL. Keep the publish/unpublish buttons.
|
|
|
|
---
|
|
|
|
## Server-Side Prerequisites
|
|
|
|
The SSH user on the target server needs:
|
|
|
|
1. **Write access** to the `deployPath` directory
|
|
2. **sudo NOPASSWD** for:
|
|
```bash
|
|
deploy-user ALL=(ALL) NOPASSWD: /usr/sbin/nginx -s reload, \
|
|
/bin/ln -sf /etc/nginx/sites-available/* /etc/nginx/sites-enabled/, \
|
|
/bin/rm /etc/nginx/sites-enabled/*, \
|
|
/bin/rm /etc/nginx/sites-available/*
|
|
```
|
|
|
|
---
|
|
|
|
## Files Summary
|
|
|
|
### Backend (existing, modified)
|
|
| File | Change |
|
|
|------|--------|
|
|
| `model/Site.java` | Add `DeploymentTarget` enum + field |
|
|
| `model/SiteStatus.java` | No change |
|
|
| `dto/SiteRequest.java` | Add `deploymentTarget` |
|
|
| `dto/SiteResponse.java` | Add `deploymentTarget` |
|
|
| `service/KeyManager.java` | Add SSH key encrypt/decrypt methods |
|
|
| `service/DeploymentService.java` | Dispatch logic for both targets |
|
|
| `controller/SiteController.java` | Accept `deploymentTarget` in create/update |
|
|
| `repository/SiteRepository.java` | No change (optional: add findByDeploymentTarget) |
|
|
|
|
### Backend (new files)
|
|
| File | Description |
|
|
|------|-------------|
|
|
| `model/SelfHostedConfig.java` | Entity |
|
|
| `repository/SelfHostedConfigRepository.java` | JPA repository |
|
|
| `dto/SelfHostedConfigRequest.java` | Request DTO |
|
|
| `dto/SelfHostedConfigResponse.java` | Response DTO |
|
|
| `service/Deployer.java` | Deployer interface |
|
|
| `service/SshRsyncDeployer.java` | SSH + rsync + nginx implementation |
|
|
| `controller/SelfHostedController.java` | REST endpoints |
|
|
|
|
### Frontend (modified)
|
|
| File | Change |
|
|
|------|--------|
|
|
| `core/models/site.ts` | Add `deploymentTarget` |
|
|
| `core/models/deployment.ts` | Add self-hosted config interfaces |
|
|
| `core/services/site.service.ts` | Add self-hosted API methods |
|
|
| `llm-workspace/settings/settings.component.ts` | Add target selector + self-hosted config card |
|
|
|
|
---
|
|
|
|
## Future Enhancements (Out of Scope)
|
|
|
|
- Automatic TLS via Let's Encrypt / Certbot integration
|
|
- Multiple server targets per site
|
|
- Health checks / monitoring
|
|
- Rollback on server (keep previous deploy versions)
|
|
- Staged deployments (staging → production)
|