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
2 changes: 1 addition & 1 deletion docs/concepts/apps-and-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Two services, one endpoint, one app named after the directory.

## Lifecycle

Created when you (or the API) writes the compose file into `apps_dir/<slug>/`. Updated when the file content changes (SHA-256 hash compare). Removed when the directory disappears: the next reconcile pass calls `docker compose down --remove-orphans`. Deploy history is recorded in the `compose_versions` and `deploy_events` tables (migration 009).
Created when you (or the API) writes the compose file into `apps_dir/<slug>/`. Updated when the file content changes (SHA-256 hash compare). Removed when the directory disappears: the next reconcile pass calls `docker compose down --remove-orphans`. Named volumes are kept, on this path and on Delete (see [Archived apps](/operations/archived-apps/#difference-from-ui-delete)). Deploy history is recorded in the `compose_versions` and `deploy_events` tables (migration 009).

## App status values

Expand Down
4 changes: 2 additions & 2 deletions docs/first-deploy/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ The fastest way to deploy if you already have the dashboard open.
3. The deploy wizard opens on a **Start** chooser with two primary options:

- **Upload docker-compose file**: pick a `docker-compose.yml` from disk.
- **Build it yourself**: open the visual builder and add services, ports, env vars, and volumes step by step. You can switch to a raw YAML editor at any time.
- **Build it yourself**: open the visual builder and add services, ports, env vars, and volumes step by step. You can switch to a raw YAML editor at any time. Adding a service from a quick-start template (e.g. PostgreSQL) also creates the storage volume it needs; if that name is already used, a number is appended (e.g. `pgdata2`) so two services never share data.

If you just want a quick start or an example to learn from, click **Browse templates** below the two options to pick a preconfigured app or service template.

Templates ask you to pick an **Access mode** before continuing:

- **Quick test** (default): SimpleDeploy auto-generates a `<slug>.<server-ip>.sslip.io` domain and issues a self-signed cert via Caddy's internal CA. No DNS setup needed. Browsers will warn about the certificate until you install the root cert from the **Trust** page on your SimpleDeploy server (`/trust` on the server URL). Best for trying a template in minutes on a homelab, LAN, or VPS without public DNS.
- **Quick test** (default): SimpleDeploy auto-generates a `<slug>.<server-ip>.sslip.io` domain (templates with more than one web address get one per address, e.g. `minio.<server-ip>.sslip.io` and `minio-console.<server-ip>.sslip.io`) and issues a self-signed cert via Caddy's internal CA. No DNS setup needed. Browsers will warn about the certificate until you install the root cert from the **Trust** page on your SimpleDeploy server (`/trust` on the server URL). Best for trying a template in minutes on a homelab, LAN, or VPS without public DNS.
- **Custom domain**: You supply a real public domain and point its DNS at the server. TLS is provisioned automatically via Let's Encrypt. Best for production.
- **Port only**: Skips the Caddy proxy entirely. Docker picks a random host port; you reach the app at `http://<server>:<port>`. Disabled for multi-endpoint templates. Best for SSH-tunnel or LAN-only testing.

Expand Down
12 changes: 7 additions & 5 deletions docs/operations/archived-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,13 @@ The **Delete** action on a live app is a full purge in one step: it tears down c

The archive flow only triggers when the directory disappears **without** going through the Delete action: a manual `rm -rf`, a git sync pulling a delete, or any other out-of-band removal. The reasoning is that intentional UI deletes are deliberate; out-of-band removals are often accidents or git-driven config moves where preserving history is the safer default.

| Path | Containers | App dir | DB row | History | Tombstone |
|------|------------|---------|--------|---------|-----------|
| UI Delete | removed | removed | removed | removed | none |
| Filesystem removal | removed | already gone | retained (`archived_at` set) | retained | written |
| Purge from archive | already gone | already gone | removed | removed | removed |
| Path | Containers | App dir | DB row | History | Tombstone | Data volumes |
|------|------------|---------|--------|---------|-----------|--------------|
| UI Delete | removed | removed | removed | removed | none | kept |
| Filesystem removal | removed | already gone | retained (`archived_at` set) | retained | written | kept |
| Purge from archive | already gone | already gone | removed | removed | removed | kept |

Containers are stopped with `docker compose down` (no `-v`), so named volumes (database files, uploads) are never deleted by SimpleDeploy. To free the space, delete them from the **Docker** page under **Networks & Volumes**, or run `docker volume rm` on the server. An app's volumes are named `simpledeploy-<slug>_<volume>`.

## Tombstone files

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli-handwritten.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Remove a deployed app.
simpledeploy remove --name myapp
```

Stops containers, removes network, deletes from store and apps directory.
Stops containers, removes network, deletes from store and apps directory. Named data volumes are kept; list them with `docker volume ls --filter name=simpledeploy-myapp_` and delete with `docker volume rm`.

### `simpledeploy list`

Expand Down
6 changes: 3 additions & 3 deletions ui/src/components/SettingsTab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,8 @@
{#if showDanger}
<div class="px-5 pb-5 border-t border-red-500/20">
<div class="pt-4 flex items-center justify-between gap-4">
<p class="text-sm text-text-secondary">
This will permanently remove <span class="font-medium text-text-primary">{app?.Name}</span> and all its data.
<p class="text-sm text-text-secondary" data-testid="delete-app-summary">
Permanently removes <span class="font-medium text-text-primary">{app?.Name}</span>: its containers, configuration and history. Its data volumes (databases, uploaded files) are kept on the server.
</p>
<Button variant="danger" size="sm" onclick={() => showDeleteModal = true} loading={deleting}>
Delete App
Expand All @@ -502,7 +502,7 @@
{#if showDeleteModal}
<Modal
title="Delete App"
message="This will permanently remove {app?.Name} and all its data. This cannot be undone."
message="This stops {app?.Name} and permanently removes its containers, configuration and history (deploys, backup schedules, alerts). This cannot be undone. Its data volumes (databases, uploaded files) are kept on the server. To free that space, delete them on the Docker page under Networks & Volumes; their names start with simpledeploy-{slug}_."
onConfirm={confirmDelete}
onCancel={() => showDeleteModal = false}
/>
Expand Down
36 changes: 24 additions & 12 deletions ui/src/components/TemplatePicker.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script>
import { generateSecret, validateVars, countEndpoints, computeQuickTestDomain, isValidIPv4, DEFAULT_ACCESS_MODE } from '../lib/appTemplates.js'
import { generateSecret, validateVars, countEndpoints, computeQuickTestDomain, computeQuickTestDomains, isValidIPv4, DEFAULT_ACCESS_MODE } from '../lib/appTemplates.js'
import { api } from '../lib/api.js'
import CommunityRecipeBrowser from './CommunityRecipeBrowser.svelte'

Expand Down Expand Up @@ -40,6 +40,14 @@
let quickDomain = $derived(
selected ? computeQuickTestDomain(selected.nameSuggestion, quickHost || 'localhost') : ''
)
// One auto domain per domain variable, so multi-endpoint templates get distinct hostnames.
let quickDomainRows = $derived.by(() => {
if (!selected) return []
const domains = computeQuickTestDomains(selected.variables, selected.nameSuggestion, quickHost || 'localhost')
return (selected.variables || [])
.filter((v) => domains[v.key])
.map((v) => ({ key: v.key, label: v.label || v.key, domain: domains[v.key] }))
})
let hostValid = $derived(isValidIPv4(quickHost))
let hostChanged = $derived(quickHost !== loadedHost)

Expand Down Expand Up @@ -206,8 +214,7 @@
// Inject domain values based on access mode.
const domainVars = (selected.variables || []).filter((v) => v.type === 'domain')
if (accessMode === 'quick-test') {
const d = computeQuickTestDomain(selected.nameSuggestion, quickHost)
for (const v of domainVars) merged[v.key] = d
Object.assign(merged, computeQuickTestDomains(domainVars, selected.nameSuggestion, quickHost))
} else if (accessMode === 'port-only') {
for (const v of domainVars) merged[v.key] = 'localhost' // placeholder; labels stripped anyway
}
Expand Down Expand Up @@ -401,15 +408,20 @@

{#if accessMode === 'quick-test'}
<div class="bg-surface-3/40 border border-border/30 rounded-lg px-3 py-3 flex flex-col gap-2">
<div>
<label class="block text-xs text-text-muted mb-1">Domain (auto)</label>
<input
type="text"
readonly
value={quickDomain}
class="w-full px-3 py-2 bg-input-bg/50 border border-border/30 rounded-lg text-sm text-text-primary font-mono"
/>
</div>
{#each quickDomainRows as row (row.key)}
<div>
<label class="block text-xs text-text-muted mb-1" for="tpl-quick-{row.key}">
{quickDomainRows.length > 1 ? `${row.label} (auto)` : 'Domain (auto)'}
</label>
<input
id="tpl-quick-{row.key}"
type="text"
readonly
value={row.domain}
class="w-full px-3 py-2 bg-input-bg/50 border border-border/30 rounded-lg text-sm text-text-primary font-mono"
/>
</div>
{/each}
<div>
<label class="block text-xs text-text-muted mb-1">Server IP (IPv4 required by sslip.io)</label>
<div class="flex items-center gap-1.5">
Expand Down
10 changes: 3 additions & 7 deletions ui/src/components/VisualEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { api } from '../lib/api.js'
import AccordionSection from './AccordionSection.svelte'
import RepeatableField from './RepeatableField.svelte'
import { serviceTemplates } from '../lib/serviceTemplates.js'
import { serviceTemplates, addServiceToCompose } from '../lib/serviceTemplates.js'
import { getImageDefaults, getHealthcheckSuggestion } from '../lib/imageDefaults.js'
import { parseEndpointLabels, writeEndpointLabels } from '../lib/endpointLabels.js'
import { MASKED_ENV_PLACEHOLDER, envDisplayValue, hasMaskedEnv } from '../lib/env.js'
Expand Down Expand Up @@ -325,12 +325,8 @@
// ---- Add service ----
function addService(template = null) {
const updated = deepClone(compose)
if (!updated.services) updated.services = {}
const baseName = template?.id === 'blank' || !template ? 'service' : template.id.replace(/-/g, '_')
let name = baseName
let n = 1
while (updated.services[name]) { n++; name = `${baseName}${n}` }
updated.services[name] = template?.config ? deepClone(template.config) : { image: '', restart: 'unless-stopped' }
// Also declares the template's named volumes at the top level.
const name = addServiceToCompose(updated, template)
emitChange(updated)
showTemplatesPicker = false
editingServices = new Set([...editingServices, name])
Expand Down
14 changes: 14 additions & 0 deletions ui/src/components/__tests__/SettingsTab.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,20 @@ describe('SettingsTab', () => {
return r;
}

// Teardown runs `docker compose down` without -v, so named volumes survive.
it('says data volumes are kept when deleting the app, and where to remove them', async () => {
const r = renderTab();
await fireEvent.click(r.getByRole('button', { name: /Danger Zone/i }));
expect(r.container.textContent).not.toMatch(/all its data/);
expect(r.getByTestId('delete-app-summary').textContent).toMatch(/data volumes .* kept/i);
await fireEvent.click(r.getByRole('button', { name: /Delete App/i }));
const msg = r.getByRole('dialog').textContent;
expect(msg).not.toMatch(/all its data/);
expect(msg).toMatch(/kept on the server/);
expect(msg).toMatch(/Docker page/);
expect(msg).toMatch(/simpledeploy-foo_/);
});

it('stays on the page and shows the error when deleting the app fails', async () => {
routes['DELETE /api/apps/foo'] = () => json({ error: 'compose down failed: daemon unreachable' }, 500);
const { findByTestId } = await deleteApp();
Expand Down
62 changes: 61 additions & 1 deletion ui/src/components/__tests__/TemplatePicker.test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/svelte';
import { render, fireEvent, waitFor } from '@testing-library/svelte';

vi.mock('../../lib/api.js', async () => {
const { makeApiMock } = await import('../../test-mocks/api.js');
return { api: makeApiMock() };
});

import TemplatePicker from '../TemplatePicker.svelte';
import {
appTemplates,
categories as appCategories,
applyVars,
applyAccessMode,
} from '../../lib/appTemplates.js';
import { parseEndpointLabels } from '../../lib/endpointLabels.js';

const templates = [
{
Expand Down Expand Up @@ -77,3 +84,56 @@ describe('TemplatePicker (grid)', () => {
expect(getByRole('button', { name: /browse community recipes/i })).toBeInTheDocument();
});
});

describe('TemplatePicker (quick test defaults)', () => {
// The server rejects endpoints sharing domain + path + grpc matcher, so
// Quick test must give every endpoint of a template its own hostname.
it.each(appTemplates.map((t) => [t.id, t]))('%s applies with distinct endpoints', async (_id, t) => {
const onapply = vi.fn();
const { container, getByRole, getByPlaceholderText } = render(TemplatePicker, {
templates: appTemplates,
categories: appCategories,
initialTemplateId: t.id,
onapply,
});
// Wait for the public host lookup to settle on the browser fallback IP.
await waitFor(() => expect(getByPlaceholderText(/203\.0\.113\.10/)).toHaveValue('127.0.0.1'));
// Fill required fields that have no default (e.g. admin email).
for (const v of t.variables || []) {
if (!v.required || v.type === 'domain' || v.default != null || v.generate) continue;
const input = container.querySelector(`#tpl-var-${v.key}`);
await fireEvent.input(input, { target: { value: v.type === 'email' ? 'admin@example.com' : 'admin' } });
}
await fireEvent.click(getByRole('button', { name: /apply/i }));
expect(onapply).toHaveBeenCalledTimes(1);
const { vars, accessMode } = onapply.mock.calls[0][0];
expect(accessMode).toBe('quick-test');
const resolved = applyAccessMode(applyVars(t.compose, vars), accessMode);
const eps = parseEndpointLabels(resolved.services);
expect(eps.length).toBeGreaterThan(0);
const keys = eps.map((e) => `${e.domain}|${e.protocol === 'grpc'}|${e.path || ''}`);
expect(new Set(keys).size).toBe(keys.length);
for (const e of eps) expect(e.domain).toMatch(/\.127\.0\.0\.1\.sslip\.io$/);
});

it('shows one auto domain per endpoint for multi-domain templates', async () => {
const { getByLabelText, getByPlaceholderText } = render(TemplatePicker, {
templates: appTemplates,
categories: appCategories,
initialTemplateId: 'minio',
});
await waitFor(() => expect(getByPlaceholderText(/203\.0\.113\.10/)).toHaveValue('127.0.0.1'));
expect(getByLabelText('API domain (auto)')).toHaveValue('minio.127.0.0.1.sslip.io');
expect(getByLabelText('Console domain (auto)')).toHaveValue('minio-console.127.0.0.1.sslip.io');
});

it('keeps a single "Domain (auto)" field for single-domain templates', async () => {
const { getByLabelText, getByPlaceholderText } = render(TemplatePicker, {
templates: appTemplates,
categories: appCategories,
initialTemplateId: 'uptime-kuma',
});
await waitFor(() => expect(getByPlaceholderText(/203\.0\.113\.10/)).toHaveValue('127.0.0.1'));
expect(getByLabelText('Domain (auto)')).toHaveValue('uptime.127.0.0.1.sslip.io');
});
});
29 changes: 29 additions & 0 deletions ui/src/components/__tests__/VisualEditor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,32 @@ describe('VisualEditor (smoke)', () => {
});
});
});

describe('VisualEditor add service from template', () => {
// Regression: the service was copied but its named volume was never
// declared, so `docker compose up` failed with "undefined volume pgdata".
it('declares the named volumes the new service references', async () => {
const onchange = vi.fn();
const { getByText } = render(VisualEditor, { compose: { services: {} }, slug: '', onchange });
await fireEvent.click(getByText('Add Service'));
await fireEvent.click(getByText('PostgreSQL'));
expect(onchange).toHaveBeenCalledTimes(1);
const next = onchange.mock.calls[0][0];
expect(next.services.postgresql.volumes).toEqual(['pgdata:/var/lib/postgresql/data']);
expect(next.volumes).toEqual({ pgdata: {} });
});

it('gives a second copy its own volume and keeps existing volume config', async () => {
const onchange = vi.fn();
const compose = {
services: { postgresql: { image: 'postgres:16-alpine', volumes: ['pgdata:/var/lib/postgresql/data'] } },
volumes: { pgdata: { driver: 'local' } },
};
const { getByText } = render(VisualEditor, { compose, slug: '', onchange });
await fireEvent.click(getByText('Add Service'));
await fireEvent.click(getByText('PostgreSQL'));
const next = onchange.mock.calls[0][0];
expect(next.services.postgresql2.volumes).toEqual(['pgdata2:/var/lib/postgresql/data']);
expect(next.volumes).toEqual({ pgdata: { driver: 'local' }, pgdata2: {} });
});
});
47 changes: 46 additions & 1 deletion ui/src/lib/__tests__/appTemplates.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
applyAccessMode,
countEndpoints,
computeQuickTestDomain,
computeQuickTestDomains,
isValidHost,
isValidIPv4,
} from '../appTemplates.js';
Expand Down Expand Up @@ -111,6 +112,50 @@ describe('computeQuickTestDomain', () => {
});
});

describe('computeQuickTestDomains', () => {
const d = (key, extra = {}) => ({ key, type: 'domain', ...extra });

it('gives the main domain var the plain slug', () => {
expect(computeQuickTestDomains([d('domain'), { key: 'x', type: 'text' }], 'app', '1.2.3.4'))
.toEqual({ domain: 'app.1.2.3.4.sslip.io' });
});

it('prefixes other domain vars by key so hostnames are distinct', () => {
expect(computeQuickTestDomains([d('domain'), d('console_domain'), d('ui_domain')], 'minio', '1.2.3.4'))
.toEqual({
domain: 'minio.1.2.3.4.sslip.io',
console_domain: 'minio-console.1.2.3.4.sslip.io',
ui_domain: 'minio-ui.1.2.3.4.sslip.io',
});
});

it('uses the first domain var as main when none is keyed "domain"', () => {
expect(computeQuickTestDomains([d('web_domain'), d('api_domain')], 'app', '1.2.3.4'))
.toEqual({ web_domain: 'app.1.2.3.4.sslip.io', api_domain: 'app-api.1.2.3.4.sslip.io' });
});

it('dedupes labels that sanitize to the same name', () => {
const out = computeQuickTestDomains([d('domain'), d('a_domain'), d('A_domain'), d('_domain')], 'app', '1.2.3.4');
expect(out.a_domain).toBe('app-a.1.2.3.4.sslip.io');
expect(out.A_domain).toBe('app-a-2.1.2.3.4.sslip.io');
expect(out._domain).toBe('app-alt.1.2.3.4.sslip.io');
expect(new Set(Object.values(out)).size).toBe(4);
});

it('every domain var of every app template gets a unique valid hostname', () => {
for (const t of appTemplates) {
const out = computeQuickTestDomains(t.variables, t.nameSuggestion, '1.2.3.4');
const keys = (t.variables || []).filter((v) => v.type === 'domain').map((v) => v.key);
expect(Object.keys(out).sort()).toEqual([...keys].sort());
expect(new Set(Object.values(out)).size).toBe(keys.length);
const vars = Object.fromEntries(keys.map((k) => [k, out[k]]));
for (const k of keys) {
expect(validateVars(t.variables.filter((v) => v.key === k), vars)).toEqual({});
}
}
});
});

describe('isValidHost', () => {
it('accepts IPv4', () => {
expect(isValidHost('192.168.1.1')).toBe(true);
Expand Down Expand Up @@ -317,7 +362,7 @@ describe('appTemplates integrity', () => {
const categoryIds = new Set(categories.map((c) => c.id));

it('has the expected number of templates', () => {
expect(appTemplates.length).toBe(20);
expect(appTemplates.length).toBe(19);
});

it('every template has required fields', () => {
Expand Down
Loading
Loading