13 KiB
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
- Git push always happens when a
GitRemoteis configured — regardless of deployment target. - Self-hosted deploy is additive — rsyncs to the server on top of the git push.
- User selects
deploymentTargeton the Site (GIT_PAGES,SELF_HOSTED, orNONE) — controls which URL is the primary published URL and which prereqs are validated on publish. - Custom domain lives in
SelfHostedConfig, not onSite. Custom domains for git pages are handled outside Indie.
Implementation Steps
Step 1 — Site Model: Add DeploymentTarget
File: model/Site.java — add enum + field:
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:
private String deploymentTarget; // "GIT_PAGES", "SELF_HOSTED", "NONE"
File: dto/SiteResponse.java — add:
private String deploymentTarget;
Update from(Site site) to map the field.
Step 3 — SelfHostedConfig Entity (New)
File: model/SelfHostedConfig.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:
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:
public String encryptSshKey(String rawKey);
public String decryptSshKey(String encryptedKey);
Step 6 — Deployer Interface (New)
File: service/Deployer.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():
- Render site files to temp directory
- Expand
{domain}placeholder indeployPathtoconfig.getDomain() - Decrypt SSH key to temp file (chmod 0600, delete after)
ssh <user>@<host> -p <port> -i <keyfile> "mkdir -p <deployPath>"rsync -avz --delete -e "ssh -p <port> -i <keyfile> -o StrictHostKeyChecking=accept-new" <tempDir>/ <user>@<host>:<deployPath>/- Generate nginx config string for the domain
- Write nginx config to temp file, rsync it to
<nginxSitesPath>/<domain>on server ssh <user>@<host> -i <keyfile> "sudo ln -sf <nginxSitesPath>/<domain> /etc/nginx/sites-enabled/ && sudo nginx -s reload"- Clean up temp files
- 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():
- SSH
rm -rf <deployPath> - SSH
sudo rm <nginxSitesPath>/<domain> /etc/nginx/sites-enabled/<domain> - SSH
sudo nginx -s reload
Step 8 — DeploymentService: Dispatch Logic
File: service/DeploymentService.java — inject SelfHostedConfigRepository, Deployer, SshRsyncDeployer.
Update publish():
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:
@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:
@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:
deploymentTarget: 'GIT_PAGES' | 'SELF_HOSTED' | 'NONE';
File: frontend/src/app/core/models/deployment.ts — add:
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:
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:
-
Deployment Target selector (dropdown) — between "Git Pages", "Self-Hosted", "None". Shows status indicators for which targets are configured.
-
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.
-
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:
- Write access to the
deployPathdirectory - sudo NOPASSWD for:
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)