Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions content/docs/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Deploy your first application step by step.
Projects are containers that organize your pods.

In the sidebar:

1. Select the **Projects** panel
2. Press `n` to create a new project

Expand All @@ -19,6 +20,7 @@ In the sidebar:
A pod is a single deployable application. One pod = one container.

**Required fields:**

- **Title** - A name for your pod
- **Repository URL** - Your Git repository (e.g., `https://github.com/user/repo`)
- **Branch** - The branch to deploy (default: `main`)
Expand All @@ -40,9 +42,10 @@ If your app needs runtime variables:

Then restart or redeploy the pod to apply changes.

## 4. Add a Domain
## 4. Add a Domain (Optional)

Your pod needs a domain to be accessible. You have two options:
Add a domain only if this pod should be reachable publicly (from browser/users).
You have two options:

- **Auto-generated** - Instant subdomain like `pod-abc123.1.2.3.4.sslip.io`
- **Custom** - Your own domain like `myapp.example.com` (requires [DNS setup](/docs/domains))
Expand All @@ -58,7 +61,30 @@ Hit "Deploy" and watch the build logs. The process:
3. Start the container
4. Route traffic via Traefik

Once complete, your app is live at the domain URL.
Once complete:

- with domain: your app is live at the domain URL
- without domain: your app is internal-only (pod-to-pod on the project network)

## 6. Internal Pod-to-Pod Communication

Pods can communicate internally without public domains.

- Internal network aliases are created automatically on deploy/restart
- Current format: `<pod-slug>-<podID8>`

Use internal calls like:

```bash
http://api-gateway-9b77346e:8080
```

Important:

- Hostname and port are separate. You must call `host:port`.
- Browser clients cannot resolve internal Docker DNS names directly.
- Internal DNS name (NetworkAlias) is for server-side/container-side calls only.
- Existing running containers get the new aliases after deploy/restart.

## Managing Your Pod

Expand Down
25 changes: 24 additions & 1 deletion content/docs/domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ order: 6
1. **Point your domain to your server**

Add an A record in your DNS provider:

```
Type: A
Name: @ (or subdomain like "deploy")
Expand All @@ -39,11 +40,31 @@ After setting the server domain:

## Pod Domains

Each pod needs at least one domain to be accessible.
Pod domains are optional.

- If a pod should be public, add at least one domain.
- If a pod is internal-only (pod-to-pod), you can run it without any domain.

Domain changes are not applied to a running container automatically.
After adding, editing, or deleting pod domains, run **Deploy** or **Restart** to apply routing changes.

### Internal-Only Pods

Internal-only pods communicate over the project Docker network using internal DNS aliases.

- Network alias format: `<pod-slug>-<podID8>`

Example:

```bash
http://api-gateway-9b77346e:8080
```

Note:

- Internal aliases are intended for container/server-side communication.
- Browser clients cannot resolve these internal DNS names directly.

### Auto-Generated Domains

The quickest way to get started. Deeploy generates a subdomain using [sslip.io](https://sslip.io):
Expand All @@ -59,6 +80,7 @@ Works instantly, no DNS configuration needed.
For production apps, use your own domain:

1. **Add DNS record**

```
Type: A
Name: myapp (for myapp.example.com)
Expand All @@ -76,5 +98,6 @@ For production apps, use your own domain:
### Multiple Domains

A single pod can have multiple domains. Useful for:

- `www.example.com` and `example.com`
- Different subdomains pointing to the same app
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/docker/go-connections v0.6.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/gosimple/slug v1.15.0
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/pressly/goose/v3 v3.24.3
Expand Down Expand Up @@ -52,6 +53,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
Expand Down
2 changes: 1 addition & 1 deletion internal/server/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func New(cfg *config.Config) (*App, error) {
podEnvVarService := service.NewPodEnvVarService(podEnvVarRepo, encryptor)
podDomainService := service.NewPodDomainService(podDomainRepo)
gitTokenService := service.NewGitTokenService(gitTokenRepo, encryptor)
deployService := service.NewDeployService(podRepo, podDomainRepo, podEnvVarService, gitTokenService, dockerService)
deployService := service.NewDeployService(podRepo, projectRepo, podDomainRepo, podEnvVarService, gitTokenService, dockerService)
traefikService := service.NewTraefikService(serverSettingsRepo, cfg.TraefikConfigDir, cfg.IsDevelopment())

return &App{
Expand Down
122 changes: 79 additions & 43 deletions internal/server/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,6 @@ func (d *DockerService) RunContainer(ctx context.Context, opts RunContainerOptio
//─────────────────────────────────────────────────────────────────────────

labels := map[string]string{
// Enable Traefik for this container
// Without this, Traefik ignores the container completely
"traefik.enable": "true",

// Deeploy metadata for container identification
"deeploy.pod.id": opts.PodID,
}
Expand All @@ -209,44 +205,49 @@ func (d *DockerService) RunContainer(ctx context.Context, opts RunContainerOptio
if d.isDevelopment {
entrypoint = "web"
}
port := 8080
if len(opts.Domains) > 0 {
port = opts.Domains[0].Port
}

if opts.EnablePublicAccess {
// Enable Traefik only for public pods.
labels["traefik.enable"] = "true"

// Create a router for each domain
// Each domain gets its own router but shares the same service (load balancer)
for i, domain := range opts.Domains {
routerName := fmt.Sprintf("%s-%d", opts.PodID, i)
// Create a router for each domain
// Each domain gets its own router but shares the same service (load balancer)
for i, domain := range opts.Domains {
routerName := fmt.Sprintf("%s-%d", opts.PodID, i)

// Routing rule: Which domain goes to this container?
// Host(`example.com`) matches requests with that exact Host header
labels["traefik.http.routers."+routerName+".rule"] = fmt.Sprintf("Host(`%s`)", domain.Domain)
// Routing rule: Which domain goes to this container?
// Host(`example.com`) matches requests with that exact Host header
labels["traefik.http.routers."+routerName+".rule"] = fmt.Sprintf("Host(`%s`)", domain.Domain)

// Service: Where to forward the traffic
// @docker suffix is required because Traefik auto-appends it to Docker services
labels["traefik.http.routers."+routerName+".service"] = opts.PodID + "@docker"
// Service: Where to forward the traffic
// @docker suffix is required because Traefik auto-appends it to Docker services
labels["traefik.http.routers."+routerName+".service"] = opts.PodID + "@docker"

// Entrypoint: Which port to listen on (web=80, websecure=443)
labels["traefik.http.routers."+routerName+".entrypoints"] = entrypoint
// Entrypoint: Which port to listen on (web=80, websecure=443)
labels["traefik.http.routers."+routerName+".entrypoints"] = entrypoint

// SSL/TLS: Only in production (not development)
// certresolver=letsencrypt tells Traefik to automatically get a certificate
// from Let's Encrypt using the HTTP challenge
if !d.isDevelopment {
labels["traefik.http.routers."+routerName+".tls.certresolver"] = "letsencrypt"
// SSL/TLS: Only in production (not development)
// certresolver=letsencrypt tells Traefik to automatically get a certificate
// from Let's Encrypt using the HTTP challenge
if !d.isDevelopment {
labels["traefik.http.routers."+routerName+".tls.certresolver"] = "letsencrypt"
}
}
}

// Service configuration: One service for all routers
// All domains for this pod route to the same container/port
port := 8080
if len(opts.Domains) > 0 {
port = opts.Domains[0].Port
}
labels["traefik.http.services."+opts.PodID+".loadbalancer.server.port"] = fmt.Sprintf("%d", port)
// Service configuration: One service for all routers
// All domains for this pod route to the same container/port
labels["traefik.http.services."+opts.PodID+".loadbalancer.server.port"] = fmt.Sprintf("%d", port)

// Health checks: Traefik pings each container every 2 seconds
// Only containers that respond get traffic. This ensures zero-downtime
// during redeploys - new container only gets traffic once it's ready.
labels["traefik.http.services."+opts.PodID+".loadbalancer.healthcheck.path"] = "/"
labels["traefik.http.services."+opts.PodID+".loadbalancer.healthcheck.interval"] = "2s"
// Health checks: Traefik pings each container every 2 seconds
// Only containers that respond get traffic. This ensures zero-downtime
// during redeploys - new container only gets traffic once it's ready.
labels["traefik.http.services."+opts.PodID+".loadbalancer.healthcheck.path"] = "/"
labels["traefik.http.services."+opts.PodID+".loadbalancer.healthcheck.interval"] = "2s"
}

// Container config
config := &container.Config{
Expand All @@ -264,12 +265,25 @@ func (d *DockerService) RunContainer(ctx context.Context, opts RunContainerOptio
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
}

// Network config - join the deeploy network so Traefik can reach this container
networkConfig := &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
NetworkName: {},
if opts.ProjectNetwork == "" {
return "", fmt.Errorf("project network is required")
}
if err := d.EnsureNetwork(ctx, opts.ProjectNetwork); err != nil {
return "", fmt.Errorf("failed to ensure project network: %w", err)
}

endpoints := map[string]*network.EndpointSettings{
opts.ProjectNetwork: {
Aliases: opts.NetworkAliases,
},
}
if opts.EnablePublicAccess {
endpoints[NetworkName] = &network.EndpointSettings{}
}

networkConfig := &network.NetworkingConfig{
EndpointsConfig: endpoints,
}

// Create container
resp, err := d.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, opts.ContainerName)
Expand All @@ -286,6 +300,25 @@ func (d *DockerService) RunContainer(ctx context.Context, opts RunContainerOptio
return resp.ID, nil
}

// EnsureNetwork creates a Docker network if it does not already exist.
func (d *DockerService) EnsureNetwork(ctx context.Context, name string) error {
_, err := d.client.NetworkInspect(ctx, name, network.InspectOptions{})
if err == nil {
return nil
}

_, err = d.client.NetworkCreate(ctx, name, network.CreateOptions{})
if err != nil {
// Another deploy may have created the network in the meantime.
if _, inspectErr := d.client.NetworkInspect(ctx, name, network.InspectOptions{}); inspectErr == nil {
return nil
}
return err
}

return nil
}

// StopContainer stops a running container.
func (d *DockerService) StopContainer(ctx context.Context, containerID string) error {
timeout := 30
Expand Down Expand Up @@ -409,11 +442,14 @@ type DomainConfig struct {

// RunContainerOptions holds options for running a container.
type RunContainerOptions struct {
ImageName string
ContainerName string
PodID string
Domains []DomainConfig
EnvVars map[string]string
ImageName string
ContainerName string
PodID string
Domains []DomainConfig
EnvVars map[string]string
ProjectNetwork string
EnablePublicAccess bool
NetworkAliases []string
}

func mapToEnvSlice(m map[string]string) []string {
Expand Down
17 changes: 17 additions & 0 deletions internal/server/naming/naming.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package naming

import (
"github.com/gosimple/slug"
)

func NetworkAliasForPod(podID, podTitle string) string {
return slug.Make(podTitle) + "-" + podID[:8]
}

func ProjectNetworkName(projectTitle, projectID string) string {
return "deeploy-" + slug.Make(projectTitle) + "-" + projectID[:8]
}

func ContainerNameForPod(projectTitle, podTitle, podID string) string {
return "deeploy-" + slug.Make(projectTitle) + "-" + slug.Make(podTitle) + "-" + podID[:8]
}
Loading