# 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 { Optional findBySiteId(UUID siteId); Optional 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 @ -p -i "mkdir -p "` 5. `rsync -avz --delete -e "ssh -p -i -o StrictHostKeyChecking=accept-new" / @:/` 6. Generate nginx config string for the domain 7. Write nginx config to temp file, rsync it to `/` on server 8. `ssh @ -i "sudo ln -sf / /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 ; root ; 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 ` 2. SSH `sudo rm / /etc/nginx/sites-enabled/` 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 { return this.http.get(`/api/sites/${siteId}/self-hosted`); } saveSelfHostedConfig(siteId: string, config: SelfHostedConfigRequest): Observable { return this.http.put(`/api/sites/${siteId}/self-hosted`, config); } deleteSelfHostedConfig(siteId: string): Observable { return this.http.delete(`/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)