{ if (o) focusFirstInput(); else onClose(); }}> {#if !isEditing} Add environment {:else} Edit environment {/if} {#if environment} {environment.name} {/if} {#if formError}
{formError}
{/if} General Updates Activity Security {#if $page.data.backupsEnabled} Backups {/if} Notifications
{#if isCustomIcon(formIcon) || pendingIconData} {:else} formIcon = icon} /> {/if} formErrors.name = undefined} />
{#if formErrors.name}

{formErrors.name}

{/if}
({formLabels.length}/{MAX_LABELS})
{#if formLabels.length > 0}
{#each formLabels as label} {label} {/each}
{/if} {#if formLabels.length < MAX_LABELS}
showLabelDropdown = true} onblur={() => setTimeout(() => showLabelDropdown = false, 150)} onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); const trimmed = newLabelInput.trim().toLowerCase(); if (trimmed && !formLabels.includes(trimmed)) { formLabels = [...formLabels, trimmed]; newLabelInput = ''; } } else if (e.key === 'Escape') { showLabelDropdown = false; } }} /> {#if showLabelDropdown && filteredLabelSuggestions.length > 0}
{#each filteredLabelSuggestions as suggestion} {@const colors = { color: getLabelColor(suggestion, $labelColorOverrides), bgColor: getLabelBgColor(suggestion, $labelColorOverrides) }} {/each}
{/if}
{:else}

Maximum labels reached

{/if}

Unix socket

Connect via Docker socket on the same machine. Default path: /var/run/docker.sock. Also works with Docker Desktop and OrbStack.

Direct connection

Connect directly to Docker Engine API. Requires Docker to expose its API on a TCP port (default 2375/2376). Best for LAN environments.

Hawser standard

Hawser agent listens on a port and Dockhand connects to it. Good for LAN with static IPs.

Hawser edge

Hawser agent initiates outbound WebSocket to Dockhand. No port forwarding needed. Perfect for VPS, NAT, or dynamic IPs.

Learn more about Hawser
{ formConnectionType = v as ConnectionType; // Set default port based on connection type if (v === 'direct') { formPort = 2375; } else if (v === 'hawser-standard') { formPort = 2376; } }}> {#if formConnectionType === 'socket'} Unix socket {:else if formConnectionType === 'direct'} Direct connection {:else if formConnectionType === 'hawser-standard'} Hawser agent (standard) {:else} Hawser agent (edge) {/if} Unix socket Direct connection Hawser agent (standard) Hawser agent (edge)

{#if formConnectionType === 'socket'} Connect via Unix socket on the same machine. {:else if formConnectionType === 'direct'} Connect directly to Docker Engine API on TCP port. {:else if formConnectionType === 'hawser-standard'} Hawser agent listens, Dockhand connects. {:else} Hawser agent connects out to Dockhand. No port forwarding needed. {/if}

{#if formConnectionType === 'socket'}
{#if showSocketDropdown && detectedSockets.length > 1}
{#each detectedSockets as socket} {/each}
{/if}

Click to auto-detect available Docker sockets

{/if} {#if formConnectionType === 'direct'}
{ formErrors.host = undefined; handleHostInput(); }} onblur={() => handleHostInput(true)} /> {#if formErrors.host}

{formErrors.host}

{/if}
formProtocol = v}> {#if formProtocol === 'https'} HTTPS (TLS) {:else} HTTP {/if} HTTP HTTPS (TLS)
{#if formProtocol === 'https'}

TLS certificates for mTLS authentication (RSA or ECDSA). Paste the PEM content or upload a file.

loadPemFromFile(e, (t) => (formTlsCa = t), 'CA certificate')} />
loadPemFromFile(e, (t) => (formTlsCert = t), 'client certificate')} />
loadPemFromFile(e, (t) => (formTlsKey = t), 'client key')} />
{/if} {/if} {#if formConnectionType === 'hawser-standard'}
{ formErrors.host = undefined; handleHostInput(); }} onblur={() => handleHostInput(true)} /> {#if formErrors.host}

{formErrors.host}

{/if}
formProtocol = v}> {#if formProtocol === 'https'} HTTPS (TLS) {:else} HTTP {/if} HTTP HTTPS (TLS)
{#if formProtocol === 'https'}
loadPemFromFile(e, (t) => (formTlsCa = t), 'CA certificate')} />

Paste the CA certificate or upload a file if agent uses self-signed TLS (RSA or ECDSA).

Disable certificate validation (insecure)

{/if}
{#if !formHawserToken} {/if}
generatedStandardToken = null} /> {#if generatedStandardToken}

Copy this token now — it won't be shown again!

{:else}

Enter a token manually or generate one. Set the same token as TOKEN env var on your Hawser agent.

{/if}
Run Hawser agent on the target host:
{formHawserToken ? `TOKEN=${formHawserToken} ` : ''}hawser standard --port {formPort} {#if formHawserToken} {/if}
{/if} {#if formConnectionType === 'hawser-edge'}
{#if isEditing && environment}
{#if environment.hawserAgentId} Connected {:else} Waiting for agent {/if}
{#if environment.hawserAgentId}

Agent: {environment.hawserAgentName || environment.hawserAgentId}

{#if environment.hawserVersion}

Version: {environment.hawserVersion}

{/if} {#if environment.hawserLastSeen}

Last seen: {formatDateTime(environment.hawserLastSeen, true)}

{/if}
{/if} {/if}
{#if isEditing && hawserToken} {:else if isEditing && !hawserToken && !hawserTokenLoading} {/if}
{#if !isEditing} {#if !pendingToken}

Generate a token now. It will be saved when you add the environment.

{:else}

Copy this token now - you'll need it for the Hawser agent!

Run on your host:
DOCKHAND_SERVER_URL={getConnectionUrl()} TOKEN={pendingToken} hawser
{/if} {/if} {#if isEditing} {#if hawserTokenLoading}
{:else if generatedToken}

Save this token now - it won't be shown again!

Run on your host:
DOCKHAND_SERVER_URL={getConnectionUrl()} TOKEN={generatedToken} hawser
{:else if hawserToken}
{hawserToken.tokenPrefix}... {#if hawserToken.lastUsed} Last used: {formatDate(hawserToken.lastUsed)} {/if}
{:else}

No token generated yet. Click Generate above.

{/if} {/if}
{/if}

IP address or hostname where container ports are accessible from your browser. For local Docker, use the server's LAN IP.

Used for clickable port links on the containers page

Vulnerability scanning
{#if !isEditing}

Scan images for known security vulnerabilities

{#if formEnableScanner}

Choose vulnerability scanner

{ formScannerType = v as ScannerType; }} />
Scanner images will be pulled automatically on first scan. Vulnerability databases are cached in Docker volumes for faster subsequent scans.
{/if} {:else if scannerLoading}
{:else}

Scan images for known security vulnerabilities

{#if scannerEnabled}

Choose vulnerability scanner

{ selectedScanner = v as ScannerType; }} />
{#if selectedScanner === 'grype' || selectedScanner === 'both'}
Grype {#if loadingScannerVersions} {:else if scannerAvailability.grype && scannerVersions.grype} v{scannerVersions.grype} {:else if scannerAvailability.grype} Ready {:else} Not installed {/if} {#if !loadingScannerVersions} {#if !scannerAvailability.grype} reloadScannerAvailability(environment?.id)}> {:else} {#if grypeUpdateStatus === 'up-to-date'} Latest {:else if grypeUpdateStatus === 'update-available' || pullingGrype} {:else} {/if} {/if} {/if}
{/if} {#if selectedScanner === 'trivy' || selectedScanner === 'both'}
Trivy {#if loadingScannerVersions} {:else if scannerAvailability.trivy && scannerVersions.trivy} v{scannerVersions.trivy} {:else if scannerAvailability.trivy} Ready {:else} Not installed {/if} {#if !loadingScannerVersions} {#if !scannerAvailability.trivy} reloadScannerAvailability(environment?.id)}> {:else} {#if trivyUpdateStatus === 'up-to-date'} Latest {:else if trivyUpdateStatus === 'update-available' || pullingTrivy} {:else} {/if} {/if} {/if}
{/if} {#if ((selectedScanner === 'grype' || selectedScanner === 'both') && !scannerAvailability.grype) || ((selectedScanner === 'trivy' || selectedScanner === 'both') && !scannerAvailability.trivy)}
Scanner images will be pulled automatically on first scan. Vulnerability databases are cached in Docker volumes for faster subsequent scans.
{/if}
{/if} {/if}
{#if environment?.id} {:else}

Save the environment first to configure backups.

{/if}
Notification channels
{#if !isEditing}

Select which notification channels should send alerts for events from this environment.

{#if notifications.length === 0}

No notification channels configured yet.

Create notification channels in the Notifications settings tab first.

{:else}
{#each notifications as channel (channel.id)} {@const isSelected = formSelectedNotifications.some(n => n.id === channel.id)} {@const selectedNotif = formSelectedNotifications.find(n => n.id === channel.id)}
{#if channel.type === 'smtp'} {:else} {/if} {channel.name} ({channel.type})
{ if (isSelected) { formSelectedNotifications = formSelectedNotifications.filter(n => n.id !== channel.id); } else { formSelectedNotifications = [...formSelectedNotifications, { id: channel.id, eventTypes: NOTIFICATION_EVENT_TYPES.map(e => e.id) }]; } }} />
{#if !channel.enabled}

Channel disabled globally

{/if} {#if isSelected && selectedNotif} {@const isCollapsed = collapsedChannels.has(channel.id)}
toggleChannelCollapse(channel.id)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleChannelCollapse(channel.id); } }} > {#if isCollapsed} {:else} {/if} Event types ({selectedNotif.eventTypes.length})
{#if !isCollapsed} { formSelectedNotifications = formSelectedNotifications.map(n => n.id === channel.id ? { ...n, eventTypes: newTypes } : n ); }} /> {/if}
{/if}
{/each}
{/if} {:else}

Configure which notification channels should send alerts for events from this environment. {#if environment && !environment.collectActivity} Activity collection will be enabled automatically when you add a channel. {/if}

{#if envNotifLoading}
{:else} {#if envNotifications.length > 0}
{#each envNotifications as notif (notif.id)}
toggleChannelCollapse(notif.notificationId)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleChannelCollapse(notif.notificationId); } }} >
{#if collapsedChannels.has(notif.notificationId)} {:else} {/if} {#if notif.channelType === 'smtp'} {:else} {/if} {notif.channelName} ({notif.eventTypes.length} events)
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> environment && updateEnvNotification(environment.id, notif.notificationId, { enabled: !notif.enabled, eventTypes: notif.eventTypes })} />
{#if !notif.channelEnabled}

Channel disabled globally

{/if} {#if !collapsedChannels.has(notif.notificationId)}
{ environment && updateEnvNotification(environment.id, notif.notificationId, { enabled: notif.enabled, eventTypes: newTypes }); }} />
{/if}
{/each}
{:else}

No notification channels configured

Add a channel below to receive alerts for this environment

{/if} {@const availableChannels = notifications.filter(n => !envNotifications.some(en => en.notificationId === n.id))} {#if availableChannels.length > 0}
{#each availableChannels as channel} {/each}
{:else if notifications.length === 0}
{#if !$licenseStore.isEnterprise || $canAccess('notifications', 'create')} No notification channels have been created yet. Go to Settings → Notifications to add channels first. {:else} No notification channels have been created yet. Contact your administrator to configure notification channels. {/if}
{/if} {/if} {/if}
{#if !isEditing} {:else} {/if}
showIconCropper = false} onSave={handleIconCropSave} />
Rename environment?

The following directories will be moved on the Dockhand host:

$DATA_DIR/stacks/{renameConfirmFrom}/ $DATA_DIR/stacks/{renameConfirmTo}/
$DATA_DIR/git-repos/{renameConfirmFrom}/ $DATA_DIR/git-repos/{renameConfirmTo}/
{#if renameCountsUnknown}

Couldn't list the stacks on this environment — proceed only if you're sure what's deployed here. {#if renameAffectsContainers} Any existing stacks will need to be redeployed after the rename or their next container restart will fail. {/if}

{:else if renameStackCount === 0 && renameGitStackCount === 0}

No stacks are currently deployed on this environment, so the rename is safe.

{:else if renameAffectsContainers}

{#if renameStackCount > 0 && renameGitStackCount > 0} {renameStackCount} stack{renameStackCount === 1 ? '' : 's'} and {renameGitStackCount} git stack{renameGitStackCount === 1 ? '' : 's'} on this environment will need to be redeployed after the rename. {:else if renameStackCount > 0} {renameStackCount} stack{renameStackCount === 1 ? '' : 's'} on this environment will need to be redeployed after the rename. {:else} {renameGitStackCount} git stack{renameGitStackCount === 1 ? '' : 's'} on this environment will need to be redeployed after the rename. {/if}

Running containers will keep working, but their compose project labels still reference the old path. Without a redeploy, the next container restart will fail because the old path no longer exists.

{:else}

{#if renameStackCount > 0 && renameGitStackCount > 0} {renameStackCount} stack{renameStackCount === 1 ? '' : 's'} and {renameGitStackCount} git stack{renameGitStackCount === 1 ? '' : 's'} are tracked on this environment. {:else if renameStackCount > 0} {renameStackCount} stack{renameStackCount === 1 ? '' : 's'} {renameStackCount === 1 ? 'is' : 'are'} tracked on this environment. {:else} {renameGitStackCount} git stack{renameGitStackCount === 1 ? '' : 's'} {renameGitStackCount === 1 ? 'is' : 'are'} tracked on this environment. {/if}

Their containers run on the Hawser agent host, where the deploy directory doesn't include the env name — those keep working without a redeploy. Only the local editor source and git clone caches move.

{/if}