Platform Layer
Cloud infrastructure abstraction for managing servers, volumes, and DNS across providers.
Design Pattern
The platform layer follows the same struct-embedding-interface pattern as the database layer:
| Database | Platform |
|---|---|
Database struct embeds Engine |
Platform struct embeds Backend |
engines.NewSQLite() → *Database |
providers.NewMock() → *Platform |
engines.NewRemote() → *Database |
providers.NewDigitalOcean() → *Platform |
// Platform provides cloud infrastructure operations.
type Platform struct {
Backend // Embedded interface
}
// Backend is the interface that providers implement.
type Backend interface {
CreateServer(opts ServerOptions) (*Server, error)
GetServer(name string) (*Server, error)
DeleteServer(id string) error
// ... volumes, DNS
}
Quick Start
import (
"github.com/readysite/readysite/pkg/platform"
"github.com/readysite/readysite/pkg/platform/providers/mock"
"github.com/readysite/readysite/pkg/platform/providers/digitalocean"
)
// Testing
p := mock.New()
// Production
p, err := digitalocean.New(os.Getenv("DO_TOKEN"))
// Same API regardless of provider
server, err := p.CreateServer(platform.ServerOptions{
Name: "app-1",
Size: platform.Small,
Region: platform.NYC,
Image: "docker-20-04",
})
server.WaitForSSH(5 * time.Minute)
server.Start(&platform.Service{
Name: "web",
Image: "nginx:latest",
Ports: []platform.Port{{Host: 80, Container: 80}},
})
Regions and Sizes
Provider-agnostic constants that map to provider-specific values internally.
Regions
| Constant | Location |
|---|---|
platform.NYC |
New York |
platform.SFO |
San Francisco |
platform.TOR |
Toronto |
platform.LON |
London |
platform.AMS |
Amsterdam |
platform.FRA |
Frankfurt |
platform.SGP |
Singapore |
platform.SYD |
Sydney |
platform.BLR |
Bangalore |
Sizes
| Constant | Specs |
|---|---|
platform.Micro |
1 vCPU, 1GB RAM |
platform.Small |
1 vCPU, 2GB RAM |
platform.Medium |
2 vCPU, 4GB RAM |
platform.Large |
4 vCPU, 8GB RAM |
platform.XLarge |
8 vCPU, 16GB RAM |
Each provider maps these to their equivalents:
- DigitalOcean:
s-1vcpu-1gb,s-1vcpu-2gb, etc. - AWS:
t3.micro,t3.small, etc. - GCP:
e2-micro,e2-small, etc.
Core Types
Server
Cloud virtual machine with SSH operations.
type Server struct {
ID string
Name string
IP string
Size string // Provider-specific size (e.g., "s-1vcpu-2gb")
Region string // Provider-specific region (e.g., "nyc1")
Status string // creating, active, off
}
SSH Operations:
| Method | Purpose |
|---|---|
SSH(args...) |
Execute command, return output |
Copy(local, remote) |
Upload file via SCP |
Interactive() |
Open interactive SSH shell |
WaitForSSH(timeout) |
Block until SSH is ready |
// Run a command
output, err := server.SSH("docker", "ps")
// Upload a file
server.Copy("./app", "/usr/local/bin/app")
// Wait for server to be ready
server.WaitForSSH(5 * time.Minute)
// Interactive session (for debugging)
server.Interactive()
Service
Docker container configuration for running on servers.
type Service struct {
Name string
Image string
Command []string
Ports []Port
Volumes []Mount
Env map[string]string
Network string
Restart string // "always", "unless-stopped", etc.
}
type Port struct {
Host int
Container int
Bind string // Optional: "127.0.0.1"
}
type Mount struct {
Source string // Host path
Target string // Container path
}
Container Operations (on Server):
| Method | Purpose |
|---|---|
Start(service) |
Run container via docker run |
Stop(name) |
Kill and remove container |
server.Start(&platform.Service{
Name: "app",
Image: "myapp:latest",
Restart: "always",
Ports: []platform.Port{{Host: 8080, Container: 8080}},
Volumes: []platform.Mount{{Source: "/data", Target: "/app/data"}},
Env: map[string]string{"PORT": "8080"},
})
server.Stop("app")
Volume
Block storage that can be attached to servers.
type Volume struct {
ID string
Name string
Size int // Gigabytes
Region string
ServerID string // Empty if detached
}
Operations (via Platform):
vol, _ := p.CreateVolume("data", 100, platform.NYC)
p.AttachVolume(vol.ID, server.ID)
p.DetachVolume(vol.ID)
DNS
DNS zone and record management (not domain registration).
type DNSZone struct {
Name string // e.g., "example.com"
TTL int
}
type DNSRecord struct {
ID string
Type string // A, AAAA, CNAME, TXT, MX
Name string // Subdomain or "@" for root
Value string
TTL int
Priority int // For MX records
}
Operations (via Platform):
zone, _ := p.CreateDNSZone("example.com")
p.AddDNSRecord("example.com", platform.DNSRecord{
Type: "A",
Name: "@",
Value: server.IP,
TTL: 300,
})
Errors
var (
ErrNotFound = errors.New("resource not found")
ErrInvalidState = errors.New("invalid resource state")
ErrTimeout = errors.New("operation timed out")
ErrUnsupportedRegion = errors.New("unsupported region")
ErrUnsupportedSize = errors.New("unsupported size")
)
Usage:
server, err := p.GetServer("app-1")
if errors.Is(err, platform.ErrNotFound) {
// Create it
}
CLI Commands
launch
Deploy application to cloud infrastructure. Always use this tool for deployments - it handles building, uploading, and container restarts.
# Build the launch tool (once)
go build -o build/launch ./cmd/launch
# Deploy to existing servers (rebuilds binaries, uploads, restarts containers)
./build/launch
# Create new server and deploy
./build/launch --new myapp
The launch tool reads infra.json, builds binaries for the target platform, uploads via SCP, and restarts Docker containers.
connect
SSH to a deployed server.
# Build
go build -o build/connect ./cmd/connect
# Interactive SSH (auto-selects if only one server)
./build/connect
# Connect to specific server
./build/connect --server website-1
# Run command on server
./build/connect docker ps
./build/connect docker logs -f website
# Multiple servers? Interactive prompt appears
./build/connect
# > Multiple servers found. Select one:
# > [1] website-1 (24.199.127.118)
# > [2] website-2 (24.199.127.119)
# > Enter choice: 1
publish
Build and push Docker images to registries.
# Build
go build -o build/publish ./cmd/publish
# Build locally and push to both registries
./build/publish
# With specific tag
./build/publish --tag v1.0.0
# Private registry only
./build/publish --skip-dockerhub
# Docker Hub only
./build/publish --skip-registry
# Build on remote server (when local Docker unavailable)
./build/publish --build-server website-1
Flags:
| Flag | Default | Description |
|---|---|---|
--tag |
latest |
Image tag |
--dockerhub |
ccutch/readysite |
Docker Hub repository |
--skip-registry |
false |
Skip pushing to private registry |
--skip-dockerhub |
false |
Skip pushing to Docker Hub |
--build-server |
(none) | Server to build on (uses launch) |
--push-server |
readysite-org-1 |
Server to push from |
Workflow:
- If local Docker available: build locally, push to both registries
- If no local Docker: build on
--build-servervia launch, push from--push-server
The private registry runs on the push server at port 5001.
infra.json
Infrastructure configuration file in project root:
{
"platform": {
"provider": "digitalocean",
"token": "$DIGITAL_OCEAN_API_KEY",
"project": "$DIGITAL_OCEAN_PROJECT",
"region": "sfo"
},
"servers": {
"app": {
"size": "small",
"binaries": ["website"],
"services": ["registry"],
"volumes": [
{"name": "data", "size": 10, "mount": "/data", "filesystem": "ext4"}
]
}
},
"binaries": {
"website": {
"source": "./website",
"ports": [{"host": 80, "container": 5000}],
"env": {
"ENV": "production",
"DB_PATH": "/data/website.db"
},
"env_files": {"AUTH_SECRET": "/data/secrets.env"},
"volumes": [{"source": "/data", "target": "/data"}]
}
},
"services": {
"registry": {
"image": "registry:2",
"ports": [{"host": 5001, "container": 5000}],
"volumes": [{"source": "/data/registry", "target": "/var/lib/registry"}],
"restart": "always"
}
},
"instances": {
"app": [
{"id": "abc123", "name": "website-1", "ip": "24.199.127.118", "region": "sfo3"}
]
}
}
Sections:
| Section | Purpose |
|---|---|
platform |
Cloud provider credentials and defaults |
servers |
Server types with size, binaries, services, and volumes |
binaries |
Go apps to build and deploy (need Dockerfile) |
services |
Pre-built Docker images to run |
instances |
Deployed server instances (auto-populated) |
Binary Options:
| Field | Description |
|---|---|
source |
Path to Go application directory (must contain Dockerfile) |
ports |
Port mappings (host:container) |
env |
Environment variables to set |
env_files |
Map of env var name to server file path (e.g., {"AUTH_SECRET": "/data/secrets.env"}) |
volumes |
Volume mounts from host to container |
Common Environment Variables:
| Variable | Description |
|---|---|
ENV |
Set to production to disable HMR and development features |
DB_PATH |
Local SQLite file path (e.g., /data/app.db) |
DB_URL |
Remote libSQL server URL for replicas (e.g., libsql://mydb.turso.io) |
DB_TOKEN |
Authentication token for remote libSQL server |
The engines.NewAuto() database engine selects based on these variables:
DB_URL+DB_TOKEN→ Remote replica (Turso/libSQL)DB_PATHonly → Local file database- Neither → In-memory database
Service Options:
| Field | Description |
|---|---|
image |
Docker image to pull |
ports |
Port mappings (host:container, optional bind for localhost-only) |
volumes |
Volume mounts |
restart |
Restart policy (always, unless-stopped, etc.) |
Setup Scripts:
If a binary's source directory contains a setup.sh file, it runs on the server before deployment. Use this for:
- Creating directories
- Installing dependencies
- Configuring services
Health Checks and Rollback:
Services with exposed ports automatically get health checks. If a container fails to start or become healthy, the launch tool:
- Stops the failing container
- Restarts the previous version
- Reports the failure
Notes:
- Environment variables (prefixed with
$) are expanded at runtime but preserved when saving binariesrequire aDockerfilein the source directoryinstancesis managed by the launch tool - don't edit manuallyenv_filesare read from the server, not the local machine (for secrets)
SSHKeyProvider
Optional interface for providers that support SSH key management:
// SSHKeyProvider is an optional interface for SSH key management.
type SSHKeyProvider interface {
// GetSSHKeyFingerprint finds or registers an SSH key, returns fingerprint.
GetSSHKeyFingerprint(publicKey string) (string, error)
}
// Usage
if sshProvider, ok := p.Backend.(platform.SSHKeyProvider); ok {
fingerprint, err := sshProvider.GetSSHKeyFingerprint(publicKey)
}
Keys are compared by type and data (ignoring comments) to handle duplicates gracefully.
Providers
Mock
In-memory provider for testing. No cloud calls.
import "github.com/readysite/readysite/pkg/platform/providers/mock"
p := mock.New()
DigitalOcean
Uses Droplets, Block Storage, and DNS.
import "github.com/readysite/readysite/pkg/platform/providers/digitalocean"
p, err := digitalocean.New(os.Getenv("DO_TOKEN"))
Images: docker-20-04, ubuntu-22-04-x64
Note: Implements SSHKeyProvider for fingerprint-based SSH key lookup.
AWS
Uses EC2, EBS, and Route 53.
import "github.com/readysite/readysite/pkg/platform/providers/aws"
p := aws.New(accessKey, secretKey, "us-east-1")
Note: SSH keys are uploaded to EC2 Key Pairs automatically.
GCP
Uses Compute Engine, Persistent Disks, and Cloud DNS.
import "github.com/readysite/readysite/pkg/platform/providers/gcp"
p, err := gcp.New(projectID, option.WithCredentialsFile("key.json"))
Note: SSH keys are added via instance metadata.
File Structure
pkg/platform/
├── platform.go # Platform struct, Backend interface, ServerOptions
├── server.go # Server struct + SSH, Copy, Interactive, WaitForSSH
├── service.go # Service, Port, Mount + Start, Stop methods
├── volume.go # Volume struct
├── domain.go # DNSZone, DNSRecord
├── region.go # Region constants
├── size.go # Size constants
├── errors.go # Error definitions
└── providers/
├── mock/
│ └── mock.go # Testing provider
├── digitalocean/
│ └── digitalocean.go # DigitalOcean provider
├── aws/
│ └── aws.go # AWS provider
└── gcp/
└── gcp.go # GCP provider
Deployment Pattern
Typical deployment flow:
// 1. Get or create server
server, err := p.GetServer("app-1")
if errors.Is(err, platform.ErrNotFound) {
server, err = p.CreateServer(platform.ServerOptions{
Name: "app-1",
Size: platform.Small,
Region: platform.NYC,
Image: "docker-20-04",
SSHKey: string(pubKey),
})
}
// 2. Wait for SSH
server.WaitForSSH(5 * time.Minute)
// 3. Upload binary
server.Copy("./build/app-linux", "/usr/local/bin/app")
// 4. Run as container (or configure systemd)
server.Start(&platform.Service{
Name: "app",
Image: "caddy:latest",
Restart: "always",
Ports: []platform.Port{{Host: 443, Container: 443}},
})
// 5. Configure DNS
p.AddDNSRecord("example.com", platform.DNSRecord{
Type: "A",
Name: "app",
Value: server.IP,
TTL: 300,
})
Network Security
Recommended firewall rules on servers:
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # HTTPS
ufw limit 22/tcp # Rate limit SSH
ufw enable
For internal services, use Docker networks:
// Only Caddy exposes ports
server.SSH("docker", "network", "create", "internal")
server.Start(&platform.Service{
Name: "app",
Image: "myapp:latest",
Network: "internal",
// No ports exposed
})
server.Start(&platform.Service{
Name: "caddy",
Image: "caddy:latest",
Network: "internal",
Ports: []platform.Port{{Host: 443, Container: 443}},
})