Files
indie/PLAN-selfhosted.md
Krrish Ghimire 51d887a005 add tests
2026-07-06 01:39:18 +05:45

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

  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:

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():

  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():

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:

  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:
    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)