diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts
index 85701ab3..9258582d 100644
--- a/create-a-container/client/src/lib/types.ts
+++ b/create-a-container/client/src/lib/types.ts
@@ -54,6 +54,11 @@ export interface ServiceTransport {
id: number;
protocol: 'tcp' | 'udp';
externalPort: number;
+ tls: boolean;
+ backendTls: boolean;
+ externalHostname: string | null;
+ externalDomainId: number | null;
+ domain?: string;
}
export interface ServiceDns {
id: number;
diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx
index 87ec7ebe..85b7f81b 100644
--- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx
+++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx
@@ -41,6 +41,7 @@ const SERVICE_TYPES = [
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS (backend TLS)' },
{ value: 'tcp', label: 'TCP' },
+ { value: 'tls', label: 'TLS (backend TLS)' },
{ value: 'udp', label: 'UDP' },
{ value: 'srv', label: 'DNS (SRV record)' },
];
@@ -48,12 +49,14 @@ const SERVICE_TYPES = [
const serviceSchema = z
.object({
id: z.number().optional(),
- type: z.enum(['http', 'https', 'tcp', 'udp', 'srv']),
+ type: z.enum(['http', 'https', 'tcp', 'tls', 'udp', 'srv']),
internalPort: z.string(),
+ externalPort: z.number().optional(),
externalHostname: z.string().optional(),
externalDomainId: z.string().optional(),
dnsName: z.string().optional(),
authRequired: z.boolean().optional(),
+ tls: z.boolean().optional(),
deleted: z.boolean().optional(),
})
.refine(
@@ -66,6 +69,18 @@ const serviceSchema = z
message: 'An external domain is required for HTTP services',
path: ['externalDomainId'],
},
+ )
+ .refine(
+ (s) =>
+ s.deleted ||
+ s.id !== undefined || // existing services are immutable here
+ (s.type !== 'tcp' && s.type !== 'tls') ||
+ !s.tls ||
+ !!s.externalDomainId,
+ {
+ message: 'An external domain is required when TLS termination is enabled',
+ path: ['externalDomainId'],
+ },
);
const envVarSchema = z.object({ key: z.string(), value: z.string() });
@@ -161,12 +176,21 @@ export function ContainerFormPage() {
? s.httpService?.backendProtocol === 'https'
? 'https'
: 'http'
- : (s.transportService?.protocol ?? 'tcp'),
+ : s.transportService?.backendTls
+ ? 'tls'
+ : (s.transportService?.protocol ?? 'tcp'),
internalPort: String(s.internalPort),
- externalHostname: s.httpService?.externalHostname || '',
- externalDomainId: s.httpService ? String(s.httpService.externalDomainId) : '',
+ externalPort: s.transportService?.externalPort,
+ externalHostname:
+ s.httpService?.externalHostname || s.transportService?.externalHostname || '',
+ externalDomainId: s.httpService
+ ? String(s.httpService.externalDomainId)
+ : s.transportService?.externalDomainId
+ ? String(s.transportService.externalDomainId)
+ : '',
dnsName: s.dnsService?.dnsName || '',
authRequired: !!s.httpService?.authRequired,
+ tls: !!s.transportService?.tls,
deleted: false,
})),
environmentVars: Object.entries(container.environmentVars || {}).map(([key, value]) => ({
@@ -215,6 +239,7 @@ export function ContainerFormPage() {
externalDomainId: '',
dnsName: '',
authRequired: false,
+ tls: false,
deleted: false,
});
added += 1;
@@ -252,14 +277,16 @@ export function ContainerFormPage() {
return;
}
if (s.deleted) return;
+ const externalHostname = s.externalHostname?.trim();
servicesObj[`s${idx}`] = {
id: s.id,
type: s.type,
internalPort: s.internalPort ? parseInt(s.internalPort, 10) : undefined,
- externalHostname: s.externalHostname,
+ externalHostname: externalHostname ? externalHostname : undefined,
externalDomainId: s.externalDomainId ? parseInt(s.externalDomainId, 10) : undefined,
dnsName: s.dnsName,
authRequired: s.authRequired,
+ tls: s.tls,
};
});
const payload = {
@@ -502,6 +529,7 @@ export function ContainerFormPage() {
externalDomainId: defaultExternalDomainId,
dnsName: '',
authRequired: false,
+ tls: false,
deleted: false,
})
}
@@ -524,7 +552,7 @@ export function ContainerFormPage() {
{isExisting && (
- Existing service — only Require authentication can be changed. Delete and re-add to modify other fields.
+ Existing service — the type cannot be changed. Delete and re-add to change it.
)}
@@ -552,7 +580,6 @@ export function ContainerFormPage() {
label="Internal port"
type="number"
inputMode="numeric"
- readOnly={isExisting}
{...register(`services.${idx}.internalPort`)}
/>
)}
+ {(svc.type === 'tcp' || svc.type === 'tls' || svc.type === 'udp') && (
+
+
+
+ External port
+
+
+ {svc.externalPort ?? (
+
+ Auto-assigned on save
+
+ )}
+
+
+ {(svc.type === 'tcp' || svc.type === 'tls') && (
+ <>
+
{
+ setValue(`services.${idx}.tls`, c);
+ // Ensure a domain is selected when enabling TLS so
+ // the load balancer has a certificate to use.
+ if (c && !svc.externalDomainId && defaultExternalDomainId) {
+ setValue(
+ `services.${idx}.externalDomainId`,
+ defaultExternalDomainId,
+ );
+ }
+ }}
+ />
+ {svc.tls && (
+
+
+
+ setValue(`services.${idx}.externalDomainId`, v)
+ }
+ options={domainOptions}
+ />
+
+ )}
+ >
+ )}
+
+ )}
{svc.type === 'srv' && (
)}
diff --git a/create-a-container/migrations/20260617000000-add-transport-service-domain.js b/create-a-container/migrations/20260617000000-add-transport-service-domain.js
new file mode 100644
index 00000000..3d19d5d8
--- /dev/null
+++ b/create-a-container/migrations/20260617000000-add-transport-service-domain.js
@@ -0,0 +1,34 @@
+'use strict';
+
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ // Subdomain label for TLS-terminated TCP services (e.g. "db" in
+ // "db.example.com"). Mirrors HTTPService.externalHostname. Nullable
+ // because plaintext TCP/UDP services don't use it.
+ await queryInterface.addColumn('TransportServices', 'externalHostname', {
+ type: Sequelize.STRING(255),
+ allowNull: true,
+ comment: 'Subdomain label used for TLS-enabled TCP services (e.g. "db")'
+ });
+
+ // External domain whose certificate (/etc/ssl/certs/
.crt) is used
+ // to terminate TLS at the load balancer. Mirrors HTTPService.externalDomainId.
+ await queryInterface.addColumn('TransportServices', 'externalDomainId', {
+ type: Sequelize.INTEGER,
+ allowNull: true,
+ references: {
+ model: 'ExternalDomains',
+ key: 'id'
+ },
+ onUpdate: 'CASCADE',
+ onDelete: 'SET NULL',
+ comment: 'External domain providing the TLS certificate for TCP TLS termination'
+ });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.removeColumn('TransportServices', 'externalDomainId');
+ await queryInterface.removeColumn('TransportServices', 'externalHostname');
+ }
+};
diff --git a/create-a-container/migrations/20260617010000-add-transport-service-backend-tls.js b/create-a-container/migrations/20260617010000-add-transport-service-backend-tls.js
new file mode 100644
index 00000000..70ddafd7
--- /dev/null
+++ b/create-a-container/migrations/20260617010000-add-transport-service-backend-tls.js
@@ -0,0 +1,21 @@
+'use strict';
+
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ // Whether the load balancer connects to the backend over TLS (nginx
+ // stream `proxy_ssl on`). This is the transport analog of an HTTPS
+ // service's backendProtocol=https. Independent of the client-side `tls`
+ // termination flag; a TCP service may do either or both.
+ await queryInterface.addColumn('TransportServices', 'backendTls', {
+ type: Sequelize.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ comment: 'Whether the load balancer re-encrypts to the backend via proxy_ssl'
+ });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.removeColumn('TransportServices', 'backendTls');
+ }
+};
diff --git a/create-a-container/models/transport-service.js b/create-a-container/models/transport-service.js
index 10397805..66be7109 100644
--- a/create-a-container/models/transport-service.js
+++ b/create-a-container/models/transport-service.js
@@ -5,6 +5,7 @@ module.exports = (sequelize, DataTypes) => {
class TransportService extends Model {
static associate(models) {
TransportService.belongsTo(models.Service, { foreignKey: 'serviceId', as: 'service' });
+ TransportService.belongsTo(models.ExternalDomain, { foreignKey: 'externalDomainId', as: 'externalDomain' });
}
// Find the next available external port for the given protocol in the specified range
@@ -68,10 +69,57 @@ module.exports = (sequelize, DataTypes) => {
type: DataTypes.BOOLEAN,
allowNull: true,
comment: 'Whether to use TLS for TCP connections'
+ },
+ backendTls: {
+ type: DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ comment: 'Whether the load balancer re-encrypts to the backend via proxy_ssl'
+ },
+ externalHostname: {
+ type: DataTypes.STRING(255),
+ allowNull: true,
+ validate: {
+ is: {
+ args: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/,
+ msg: 'Hostname must be 1–63 characters, only lowercase letters, digits, and hyphens, and must start and end with a letter or digit'
+ }
+ }
+ },
+ externalDomainId: {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ references: {
+ model: 'ExternalDomains',
+ key: 'id'
+ }
}
}, {
sequelize,
modelName: 'TransportService',
+ validate: {
+ // TLS termination is only supported for TCP (nginx stream `ssl`).
+ // UDP would require DTLS, which this load balancer does not provide.
+ tlsRequiresTcp() {
+ if (this.tls && this.protocol !== 'tcp') {
+ throw new Error('TLS can only be enabled for TCP services');
+ }
+ },
+ // A TLS-enabled TCP service must reference an external domain so the
+ // load balancer knows which certificate to terminate with.
+ tlsRequiresDomain() {
+ if (this.tls && !this.externalDomainId) {
+ throw new Error('A TLS-enabled TCP service must have an externalDomainId');
+ }
+ },
+ // Backend TLS re-encryption (nginx stream `proxy_ssl`) is only valid for
+ // TCP. UDP cannot use proxy_ssl.
+ backendTlsRequiresTcp() {
+ if (this.backendTls && this.protocol !== 'tcp') {
+ throw new Error('Backend TLS can only be enabled for TCP services');
+ }
+ }
+ },
indexes: [
{
name: 'transport_services_unique_protocol_port',
diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml
index e049dd16..7f8526fa 100644
--- a/create-a-container/openapi.v1.yaml
+++ b/create-a-container/openapi.v1.yaml
@@ -87,23 +87,185 @@ components:
type: object
properties:
id: { type: integer }
+ containerId:
+ type: integer
+ nullable: true
+ description: Proxmox LXC VMID. Null until the creation job provisions the container.
hostname: { type: string }
- containerId: { type: integer, nullable: true }
- status: { type: string }
- template: { type: string }
ipv4Address: { type: string, nullable: true }
macAddress: { type: string, nullable: true }
+ status:
+ type: string
+ description: Lifecycle status (e.g. `pending`, `running`, `stopped`).
+ template:
+ type: string
+ description: Docker image reference the container was created from.
creationJobId: { type: integer, nullable: true }
entrypoint: { type: string, nullable: true }
environmentVars:
type: object
additionalProperties: { type: string }
nvidiaRequested: { type: boolean }
- sshPort: { type: integer, nullable: true }
+ sshPort:
+ type: integer
+ nullable: true
+ description: External port of the TCP service whose internal port is 22, if present.
+ sshHost:
+ type: string
+ nullable: true
+ description: Hostname clients should use for SSH — the primary HTTP service's hostname, or the site's external IP.
+ httpEntries:
+ type: array
+ description: Convenience list of the container's HTTP services and their public URLs.
+ items:
+ type: object
+ properties:
+ port: { type: integer, description: Internal (container) port. }
+ externalUrl: { type: string, nullable: true, description: Public `https://` URL, or null if no domain is set. }
nodeName: { type: string, nullable: true }
+ nodeApiUrl: { type: string, nullable: true }
services:
type: array
- items: { type: object }
+ items: { $ref: '#/components/schemas/Service' }
+ createdAt: { type: string, format: date-time }
+ Service:
+ type: object
+ description: |
+ A container service. Exactly one of `httpService`, `transportService`,
+ or `dnsService` is populated, matching `type` (`http`→httpService,
+ `transport`→transportService, `dns`→dnsService).
+ properties:
+ id: { type: integer }
+ type:
+ type: string
+ enum: [http, transport, dns]
+ internalPort:
+ type: integer
+ description: Port the service listens on inside the container.
+ httpService:
+ allOf: [{ $ref: '#/components/schemas/HttpService' }]
+ nullable: true
+ transportService:
+ allOf: [{ $ref: '#/components/schemas/TransportService' }]
+ nullable: true
+ dnsService:
+ allOf: [{ $ref: '#/components/schemas/DnsService' }]
+ nullable: true
+ HttpService:
+ type: object
+ description: HTTP/HTTPS reverse-proxy service. The public side is always HTTPS; `backendProtocol` selects the proxy→container protocol.
+ properties:
+ id: { type: integer }
+ externalHostname:
+ type: string
+ description: Subdomain label (e.g. `app` for `app.example.com`).
+ externalDomainId:
+ type: integer
+ description: ID of the external domain (see GET /external-domains).
+ backendProtocol:
+ type: string
+ enum: [http, https]
+ description: Protocol the reverse proxy uses to reach the container.
+ authRequired:
+ type: boolean
+ description: When true, NGINX enforces `auth_request` against the domain's auth server.
+ domain:
+ type: string
+ nullable: true
+ description: Resolved external domain name (e.g. `example.com`).
+ TransportService:
+ type: object
+ description: Layer-4 TCP/UDP port mapping. TCP services may optionally terminate TLS at the load balancer.
+ properties:
+ id: { type: integer }
+ protocol:
+ type: string
+ enum: [tcp, udp]
+ externalPort:
+ type: integer
+ minimum: 1
+ maximum: 65535
+ description: Publicly exposed port, auto-assigned from the range 2000–65535.
+ tls:
+ type: boolean
+ description: >-
+ Whether the load balancer terminates TLS on `externalPort` for this
+ service. Only valid for `tcp`; always false for `udp`.
+ backendTls:
+ type: boolean
+ description: >-
+ Whether the load balancer re-encrypts the connection to the backend
+ (nginx stream `proxy_ssl`). Set for services created with the `tls`
+ type. The backend certificate is not verified. TCP only.
+ externalHostname:
+ type: string
+ nullable: true
+ description: Subdomain label used when `tls` is true (otherwise null).
+ externalDomainId:
+ type: integer
+ nullable: true
+ description: >-
+ External domain whose certificate terminates TLS when `tls` is true
+ (otherwise null). Uses the same certificate as HTTP services for that domain.
+ domain:
+ type: string
+ nullable: true
+ description: Resolved external domain name when `tls` is true (otherwise null).
+ DnsService:
+ type: object
+ description: DNS SRV record for service discovery.
+ properties:
+ id: { type: integer }
+ recordType:
+ type: string
+ enum: [SRV]
+ dnsName:
+ type: string
+ description: SRV record name (e.g. `_ldap._tcp`).
+ ServiceInput:
+ type: object
+ description: |
+ A service definition in a container create/update request. `type` selects
+ the kind of service; supply the fields relevant to that type. On update,
+ include `id` to reference an existing service and `deleted: true` to remove it.
+ required: [type, internalPort]
+ properties:
+ id:
+ type: integer
+ description: Existing service ID (update only). Omit when adding a new service.
+ deleted:
+ type: boolean
+ description: Update only — set true (with `id`) to remove the service.
+ type:
+ type: string
+ enum: [http, https, tcp, tls, udp, srv]
+ description: >-
+ Service type. `http`/`https` create an HTTP service (the value sets
+ backendProtocol); `tcp`/`udp` create a transport service; `tls`
+ creates a TCP transport service that re-encrypts to the backend
+ (proxy_ssl); `srv` creates a DNS service.
+ internalPort:
+ type: integer
+ description: Port the service listens on inside the container.
+ externalHostname:
+ type: string
+ description: Subdomain label. Required for `http`/`https`; used for `tcp`/`tls` when `tls` is true.
+ externalDomainId:
+ type: integer
+ description: External domain ID. Required for `http`/`https`; required for `tcp`/`tls` when `tls` is true.
+ authRequired:
+ type: boolean
+ description: HTTP only — require authentication via the domain's auth server.
+ tls:
+ type: boolean
+ description: >-
+ `tcp`/`tls` only — terminate client TLS at the load balancer using
+ the selected domain's certificate. Requires `externalDomainId`.
+ Rejected for `udp`. Independent of the `tls` service type
+ (backend re-encryption), so a `tls` service may also terminate.
+ dnsName:
+ type: string
+ description: Required for `srv` — the SRV record name (e.g. `_ldap._tcp`).
Node:
type: object
properties:
@@ -289,32 +451,76 @@ paths:
/sites/{siteId}/containers:
get:
tags: [Containers]
+ summary: List containers in a site
parameters:
- { in: path, name: siteId, required: true, schema: { type: integer } }
- { in: query, name: hostname, schema: { type: string } }
- responses: { '200': { description: Array of containers } }
+ responses:
+ '200':
+ description: Array of containers
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ data:
+ type: array
+ items: { $ref: '#/components/schemas/Container' }
post:
tags: [Containers]
+ summary: Create a container (enqueues a provisioning job)
parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }]
requestBody:
+ required: true
content:
application/json:
schema:
type: object
required: [hostname, template]
properties:
- hostname: { type: string }
- template: { type: string }
- customTemplate: { type: string }
+ hostname:
+ type: string
+ description: Lowercase letters, digits, and hyphens.
+ template:
+ type: string
+ description: >-
+ Docker image reference (e.g.
+ `ghcr.io/mieweb/opensource-server/base:latest`). Use the
+ literal string `custom` with `customTemplate` for an
+ arbitrary image. The provisioning node is auto-selected.
+ customTemplate:
+ type: string
+ description: Image reference used when `template` is `"custom"`.
entrypoint: { type: string }
- nvidiaRequested: { type: boolean }
+ nvidiaRequested:
+ type: boolean
+ description: Schedule on an NVIDIA-capable node and inject GPU env vars.
environmentVars:
type: array
items: { type: object, properties: { key: { type: string }, value: { type: string } } }
services:
type: object
- additionalProperties: { type: object }
- responses: { '201': { description: Creation job enqueued } }
+ description: >-
+ Map of arbitrary label → service definition. The keys are
+ ignored by the server; only the values matter.
+ additionalProperties: { $ref: '#/components/schemas/ServiceInput' }
+ responses:
+ '201':
+ description: Creation job enqueued
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ containerId: { type: integer }
+ jobId: { type: integer }
+ hostname: { type: string }
+ status: { type: string, example: pending }
+ '400': { description: Invalid request, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+ '409': { description: 'No eligible node available (code: no_node / no_nvidia_node)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
/sites/{siteId}/containers/new:
get:
tags: [Containers]
@@ -335,9 +541,80 @@ paths:
parameters:
- { in: path, name: siteId, required: true, schema: { type: integer } }
- { in: path, name: id, required: true, schema: { type: integer } }
- get: { tags: [Containers], responses: { '200': { description: Container } } }
- put: { tags: [Containers], responses: { '200': { description: Updated, optional restart job } } }
- delete: { tags: [Containers], responses: { '200': { description: Deleted, with DNS cleanup warnings } } }
+ get:
+ tags: [Containers]
+ summary: Get a single container
+ responses:
+ '200':
+ description: Container
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ data: { $ref: '#/components/schemas/Container' }
+ '404': { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+ put:
+ tags: [Containers]
+ summary: Update a container's services, env vars, or entrypoint (may enqueue a restart job)
+ description: >-
+ Accepts a partial body. Existing services can be edited in place by
+ including their `id` — all fields are updatable except the service
+ `type` and a transport service's protocol/auto-assigned external port
+ (delete and re-add to change those). Changing an HTTP service's
+ hostname/domain rewrites its cross-site DNS record. `hostname` and
+ `template` cannot be changed here.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ entrypoint: { type: string }
+ restart:
+ type: boolean
+ description: Force a restart. When sent alone, only restarts (no other changes).
+ environmentVars:
+ type: array
+ items: { type: object, properties: { key: { type: string }, value: { type: string } } }
+ services:
+ type: object
+ description: 'Map of label → service definition. Include `id` to reference existing services and `deleted: true` to remove them.'
+ additionalProperties: { $ref: '#/components/schemas/ServiceInput' }
+ responses:
+ '200':
+ description: Updated; optional restart job
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ containerId: { type: integer }
+ jobId: { type: integer, nullable: true }
+ message: { type: string }
+ dnsWarnings: { type: array, items: { type: string } }
+ '404': { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+ delete:
+ tags: [Containers]
+ summary: Delete a container (also cleans up cross-site DNS)
+ responses:
+ '200':
+ description: Deleted, with DNS cleanup warnings
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ deleted: { type: boolean, example: true }
+ dnsWarnings: { type: array, items: { type: string } }
+ '404': { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+ '409': { description: 'Hostname mismatch with Proxmox (code: hostname_mismatch)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
/sites/{siteId}/nodes:
parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }]
diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js
index 2ca64da8..676c1d92 100644
--- a/create-a-container/routers/api/v1/containers.js
+++ b/create-a-container/routers/api/v1/containers.js
@@ -121,6 +121,11 @@ function serializeContainer(c, site) {
id: s.transportService.id,
protocol: s.transportService.protocol,
externalPort: s.transportService.externalPort,
+ tls: !!s.transportService.tls,
+ backendTls: !!s.transportService.backendTls,
+ externalHostname: s.transportService.externalHostname,
+ externalDomainId: s.transportService.externalDomainId,
+ domain: s.transportService.externalDomain?.name,
}
: null,
dnsService: s.dnsService
@@ -131,6 +136,41 @@ function serializeContainer(c, site) {
};
}
+// Normalize an optional hostname: trim whitespace and treat blank as null.
+// The client sends `externalHostname` as a string (defaulting to ''), but the
+// model's hostname regex rejects empty strings — so blank means "unset".
+function normalizeHostname(hostname) {
+ if (typeof hostname !== 'string') return hostname ?? null;
+ const trimmed = hostname.trim();
+ return trimmed === '' ? null : trimmed;
+}
+
+// Map an incoming transport service `type` to its persisted shape.
+// The API exposes `tcp`, `udp`, and `tls` types, but the DB only stores
+// protocol `tcp`/`udp`; a `tls` type is a TCP service with `backendTls` set
+// (the load balancer re-encrypts to the backend via `proxy_ssl`).
+function parseTransportType(type) {
+ if (type === 'tls') return { protocol: 'tcp', backendTls: true };
+ return { protocol: type, backendTls: false };
+}
+
+// Validate and normalize the TLS flag for a transport (TCP/UDP) service.
+// Returns a boolean. Throws ApiError(400) when the request is inconsistent:
+// - TLS is only supported for TCP (nginx stream `ssl`; UDP would need DTLS).
+// - A TLS-enabled TCP service must reference an external domain so the load
+// balancer knows which certificate to terminate with.
+function parseTransportTls(tls, protocol, externalDomainId) {
+ const tlsEnabled = tls === true || tls === 'true';
+ if (!tlsEnabled) return false;
+ if (protocol !== 'tcp') {
+ throw new ApiError(400, 'invalid_service', 'TLS can only be enabled for TCP services');
+ }
+ if (!externalDomainId) {
+ throw new ApiError(400, 'invalid_service', 'TLS-enabled TCP services must have an externalDomainId');
+ }
+ return true;
+}
+
// GET /containers/metadata?image=...
router.get(
'/metadata',
@@ -179,7 +219,7 @@ router.get(
association: 'services',
include: [
{ association: 'httpService', include: [{ association: 'externalDomain' }] },
- { association: 'transportService' },
+ { association: 'transportService', include: [{ association: 'externalDomain' }] },
{ association: 'dnsService' },
],
},
@@ -209,7 +249,7 @@ router.get(
association: 'services',
include: [
{ association: 'httpService', include: [{ association: 'externalDomain' }] },
- { association: 'transportService' },
+ { association: 'transportService', include: [{ association: 'externalDomain' }] },
{ association: 'dnsService' },
],
},
@@ -305,15 +345,16 @@ router.post(
if (services && typeof services === 'object') {
for (const key in services) {
const svc = services[key];
- const { type, internalPort, externalHostname, externalDomainId, dnsName, authRequired } = svc;
+ const { type, internalPort, externalHostname, externalDomainId, dnsName, authRequired, tls } = svc;
if (!type || !internalPort) continue;
let serviceType;
let protocol = null;
+ let backendTls = false;
if (type === 'http' || type === 'https') serviceType = 'http';
else if (type === 'srv') serviceType = 'dns';
else {
serviceType = 'transport';
- protocol = type;
+ ({ protocol, backendTls } = parseTransportType(type));
}
const createdService = await Service.create(
{ containerId: container.id, type: serviceType, internalPort: parseInt(internalPort, 10) },
@@ -340,9 +381,18 @@ router.post(
{ transaction: t },
);
} else {
- const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65565, t);
+ const tlsEnabled = parseTransportTls(tls, protocol, externalDomainId);
+ const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65535, t);
await TransportService.create(
- { serviceId: createdService.id, protocol, externalPort },
+ {
+ serviceId: createdService.id,
+ protocol,
+ externalPort,
+ tls: tlsEnabled,
+ backendTls,
+ externalHostname: tlsEnabled ? normalizeHostname(externalHostname) : null,
+ externalDomainId: tlsEnabled ? parseInt(externalDomainId, 10) : null,
+ },
{ transaction: t },
);
}
@@ -434,6 +484,7 @@ router.put(
if (services && typeof services === 'object') {
const deletedHttp = [];
+ const newHttp = [];
for (const key in services) {
const { id, deleted } = services[key];
if ((deleted === true || deleted === 'true') && id) {
@@ -459,28 +510,105 @@ router.put(
});
}
}
+ // Update existing services in place. The service `type` (HTTP vs
+ // transport vs DNS) and a transport's protocol/externalPort are fixed
+ // for the life of the service; everything else is editable. HTTP
+ // hostname/domain changes also rewrite the cross-site DNS record.
for (const key in services) {
- const { id, deleted, authRequired } = services[key];
+ const svc = services[key];
+ const { id, deleted, type, internalPort } = svc;
if (deleted === true || deleted === 'true' || !id) continue;
- const svc = await Service.findByPk(parseInt(id, 10), {
- include: [{ model: HTTPService, as: 'httpService' }],
+ const existing = await Service.findByPk(parseInt(id, 10), {
+ include: [
+ { model: HTTPService, as: 'httpService', include: [{ model: ExternalDomain, as: 'externalDomain' }] },
+ { model: TransportService, as: 'transportService', include: [{ model: ExternalDomain, as: 'externalDomain' }] },
+ { model: DnsService, as: 'dnsService' },
+ ],
transaction: t,
});
- if (svc?.httpService) {
- const next = authRequired === true || authRequired === 'true';
- if (svc.httpService.authRequired !== next) {
- await svc.httpService.update({ authRequired: next }, { transaction: t });
+ // Only touch services that belong to this container.
+ if (!existing || existing.containerId !== container.id) continue;
+
+ // internalPort is common to all service types and lives on the base row.
+ if (internalPort !== undefined && internalPort !== null && String(internalPort).trim() !== '') {
+ const port = parseInt(internalPort, 10);
+ if (existing.internalPort !== port) {
+ await existing.update({ internalPort: port }, { transaction: t });
+ }
+ }
+
+ if (existing.httpService) {
+ if (type && type !== 'http' && type !== 'https') {
+ throw new ApiError(400, 'invalid_service', 'Cannot change the type of an existing service');
+ }
+ const { externalHostname, externalDomainId, authRequired } = svc;
+ const newHostname = normalizeHostname(externalHostname);
+ if (!newHostname || !externalDomainId) {
+ throw new ApiError(400, 'invalid_service', 'HTTP services must have an externalHostname and externalDomainId');
+ }
+ const newDomainId = parseInt(externalDomainId, 10);
+ const prevHostname = existing.httpService.externalHostname;
+ const prevDomain = existing.httpService.externalDomain;
+ const hostOrDomainChanged =
+ prevHostname !== newHostname || existing.httpService.externalDomainId !== newDomainId;
+ await existing.httpService.update(
+ {
+ externalHostname: newHostname,
+ externalDomainId: newDomainId,
+ backendProtocol: type === 'https' ? 'https' : 'http',
+ authRequired: authRequired === true || authRequired === 'true',
+ },
+ { transaction: t },
+ );
+ // Rewrite cross-site DNS when the public name changed: remove the
+ // old record, add the new one. Both are non-fatal (warnings only).
+ if (hostOrDomainChanged) {
+ if (prevDomain) {
+ deletedHttp.push({ externalHostname: prevHostname, ExternalDomain: prevDomain });
+ }
+ const newDomain = await ExternalDomain.findByPk(newDomainId, { transaction: t });
+ if (newDomain) newHttp.push({ externalHostname: newHostname, ExternalDomain: newDomain });
+ }
+ } else if (existing.transportService) {
+ // Protocol (and therefore the auto-assigned externalPort) is fixed.
+ // Allow tcp<->tls (backendTls toggle); reject changing protocol or
+ // crossing to a non-transport type.
+ const incomingProtocol =
+ type === undefined ? existing.transportService.protocol : parseTransportType(type).protocol;
+ if (type && (type === 'http' || type === 'https' || type === 'srv')) {
+ throw new ApiError(400, 'invalid_service', 'Cannot change the type of an existing service');
+ }
+ if (incomingProtocol !== existing.transportService.protocol) {
+ throw new ApiError(400, 'invalid_service', 'Cannot change the protocol of an existing service');
+ }
+ const backendTls = type === undefined ? existing.transportService.backendTls : parseTransportType(type).backendTls;
+ const tlsEnabled = parseTransportTls(svc.tls, existing.transportService.protocol, svc.externalDomainId);
+ await existing.transportService.update(
+ {
+ tls: tlsEnabled,
+ backendTls,
+ externalHostname: tlsEnabled ? normalizeHostname(svc.externalHostname) : null,
+ externalDomainId: tlsEnabled ? parseInt(svc.externalDomainId, 10) : null,
+ },
+ { transaction: t },
+ );
+ } else if (existing.dnsService) {
+ if (type && type !== 'srv') {
+ throw new ApiError(400, 'invalid_service', 'Cannot change the type of an existing service');
+ }
+ if (svc.dnsName && svc.dnsName !== existing.dnsService.dnsName) {
+ await existing.dnsService.update({ dnsName: svc.dnsName }, { transaction: t });
}
}
}
- const newHttp = [];
for (const key in services) {
- const { id, deleted, type, internalPort, externalHostname, externalDomainId, dnsName, authRequired } =
+ const { id, deleted, type, internalPort, externalHostname, externalDomainId, dnsName, authRequired, tls } =
services[key];
if (deleted === true || deleted === 'true' || id || !type || !internalPort) continue;
const serviceType =
type === 'srv' ? 'dns' : type === 'http' || type === 'https' ? 'http' : 'transport';
- const protocol = serviceType === 'transport' ? type : null;
+ const { protocol, backendTls } =
+ serviceType === 'transport' ? parseTransportType(type) : { protocol: null, backendTls: false };
const createdService = await Service.create(
{ containerId: container.id, type: serviceType, internalPort: parseInt(internalPort, 10) },
{ transaction: t },
@@ -504,9 +632,18 @@ router.put(
{ transaction: t },
);
} else {
- const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65565);
+ const tlsEnabled = parseTransportTls(tls, protocol, externalDomainId);
+ const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65535, t);
await TransportService.create(
- { serviceId: createdService.id, protocol, externalPort },
+ {
+ serviceId: createdService.id,
+ protocol,
+ externalPort,
+ tls: tlsEnabled,
+ backendTls,
+ externalHostname: tlsEnabled ? normalizeHostname(externalHostname) : null,
+ externalDomainId: tlsEnabled ? parseInt(externalDomainId, 10) : null,
+ },
{ transaction: t },
);
}
diff --git a/create-a-container/routers/templates.js b/create-a-container/routers/templates.js
index 4793ba3a..d1da2b3d 100644
--- a/create-a-container/routers/templates.js
+++ b/create-a-container/routers/templates.js
@@ -51,7 +51,11 @@ router.get('/sites/:siteId/nginx', requireLocalhostOrAdmin, async (req, res) =>
as: 'httpService',
include: [{ model: ExternalDomain, as: 'externalDomain' }],
},
- { model: TransportService, as: 'transportService' },
+ {
+ model: TransportService,
+ as: 'transportService',
+ include: [{ model: ExternalDomain, as: 'externalDomain' }],
+ },
],
}],
}],
diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs
index a638e4fe..d9636f4c 100644
--- a/create-a-container/views/nginx-conf.ejs
+++ b/create-a-container/views/nginx-conf.ejs
@@ -459,8 +459,28 @@ stream {
access_log /var/log/nginx/stream-access.log main;
<%_ streamServices.forEach((service, index) => { _%>
+ <%_ const transport = service.transportService; _%>
+ <%_ const tlsEnabled = transport.protocol === 'tcp' && transport.tls && transport.externalDomain; _%>
+ <%_ const backendTls = transport.protocol === 'tcp' && transport.backendTls; _%>
server {
- listen <%= service.transportService.externalPort %><%= service.transportService.protocol === 'udp' ? ' udp' : '' %>;
+ <%_ if (tlsEnabled) { _%>
+ listen <%= transport.externalPort %> ssl;
+
+ # TLS termination at the load balancer using the external domain's
+ # certificate (shared with HTTP services for the same domain).
+ ssl_certificate /etc/ssl/certs/<%= transport.externalDomain.name %>.crt;
+ ssl_certificate_key /etc/ssl/private/<%= transport.externalDomain.name %>.key;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ <%_ } else { _%>
+ listen <%= transport.externalPort %><%= transport.protocol === 'udp' ? ' udp' : '' %>;
+ <%_ } _%>
+ <%_ if (backendTls) { _%>
+
+ # Re-encrypt the connection to the backend (proxy_ssl). The backend
+ # certificate is not verified (backends often use self-signed certs).
+ proxy_ssl on;
+ proxy_ssl_protocols TLSv1.2 TLSv1.3;
+ <%_ } _%>
proxy_pass <%= service.Container.ipv4Address %>:<%= service.internalPort %>;
}
<%_ }) _%>
diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
index 256d753a..8d3dedb7 100644
--- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
+++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md
@@ -103,6 +103,8 @@ On container or service deletion, cross-site A records are cleaned up automatica
When creating a container service, users select an external domain and specify a subdomain (e.g., `app` for `app.example.com`). All external domains are available regardless of which site the container is on. See the [Web GUI guide](../../users/creating-containers/web-gui.md) for details.
+A domain's certificate is also used to terminate TLS for **TLS-enabled TCP services**. When a TCP service has TLS enabled and references an external domain, NGINX terminates TLS on that service's auto-assigned external port using the domain's `/etc/ssl/certs/.crt` (the same certificate as HTTP services), then proxies the decrypted stream to the container. No additional certificate setup is required beyond the domain cert described above. TLS termination is not supported for UDP services.
+
## Security
- Issue Cloudflare User API Tokens with minimum scope (`Zone:DNS:Edit` for the target zone only)
diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md
index c9b2ffd5..24e9ede9 100644
--- a/mie-opensource-landing/docs/developers/system-architecture.md
+++ b/mie-opensource-landing/docs/developers/system-architecture.md
@@ -54,7 +54,7 @@ graph TB
|-----------|------|
| **Proxmox VE 13+** | Hypervisor — manages LXC containers via REST API. [Nodes](../admins/core-concepts/nodes.md) are registered Proxmox servers. |
| **DNSMasq** | DHCP + DNS. Auto-assigns IPs to containers, provides internal name resolution (`container.cluster.internal`). |
-| **NGINX** | Reverse proxy — L7 (HTTP/HTTPS with auto TLS via ACME) and L4 (TCP port mapping). Config auto-generated from container services. |
+| **NGINX** | Reverse proxy — L7 (HTTP/HTTPS with auto TLS via ACME) and L4 (TCP/UDP port mapping, with optional TLS termination and backend re-encryption for TCP). Config auto-generated from container services. |
| **LDAP Gateway** | Node.js LDAP server ([source](https://github.com/mieweb/LDAPServer)). Reads users/groups from the DB; containers authenticate via PAM/SSSD. |
| **Push Notification Service** | 2FA via push notifications ([source](https://github.com/mieweb/mieweb_auth_app)). Configured in [Settings](../admins/settings.md). Used by LDAP gateway when `AUTH_BACKENDS` includes `notification`. |
| **Database** | PostgreSQL via Sequelize ORM. Stores users, groups, sites, nodes, containers, and service config. |
diff --git a/mie-opensource-landing/docs/users/creating-containers/command-line.md b/mie-opensource-landing/docs/users/creating-containers/command-line.md
index 06abb421..7f7ae74a 100644
--- a/mie-opensource-landing/docs/users/creating-containers/command-line.md
+++ b/mie-opensource-landing/docs/users/creating-containers/command-line.md
@@ -2,100 +2,204 @@
!!! tip "Interactive API Docs"
- Your server hosts interactive Swagger documentation at `/api` with a "Try it out" console, full request/response schemas, and authentication support.
+ Your server hosts interactive Swagger documentation at [`{{ manager_url }}/api`]({{ manager_url }}/api) with a "Try it out" console and full request/response schemas. The OpenAPI spec is served at `/api/v1/openapi.yaml`.
-!!! caution "Outdated Documentation"
+The API is a JSON-only REST API served under the **`/api/v1`** base path.
- This page predates the last major rewrite and may not be accurate. Check back weekly for updates.
+**Prerequisites:**
-API endpoint: {{ manager_url }}
+* An active account, an [API key](./api-keys.md), `curl`, and your **site ID** (visible in the web interface URL, e.g. `/sites/1/...`).
-**Prerequisites:**
+## Authentication
+
+Pass your API key as a Bearer token. All examples below set a reusable variable:
+
+```bash
+API="{{ manager_url }}/api/v1"
+KEY="your_api_key_here"
+```
-* Active account, an [API key](./api-keys.md), `curl`, and your site ID (from the web interface).
+Every request sends `Authorization: Bearer $KEY`. CSRF tokens are **not** required for Bearer-authenticated requests (they apply only to browser session auth).
+
+## Response Envelope
+
+Successful responses wrap the payload in a `data` key; errors use an `error` object:
+
+```json
+{ "data": { "id": 1, "hostname": "my-app" } }
+```
+
+```json
+{ "error": { "code": "invalid_request", "message": "hostname is required" } }
+```
-## 1. List Available Templates
+`curl ... | jq .data` is a convenient way to read results.
+
+## 1. List External Domains
+
+You need an external domain's `id` to expose HTTP services (and TLS-terminated TCP services). List the domains available to you:
```bash
-curl -X GET 'https://create-a-container.opensource.mieweb.org/api/templates' \
- -H "Authorization: Bearer YOUR_API_KEY"
+curl -s "$API/external-domains" -H "Authorization: Bearer $KEY" | jq '.data'
```
-## 2. Get External Domain IDs
+## 2. Discover Image Metadata (optional)
+
+Inspect a Docker image's exposed ports, entrypoint, and environment before creating a container:
```bash
-curl -X GET 'https://create-a-container.opensource.mieweb.org/api/external-domains' \
- -H "Authorization: Bearer YOUR_API_KEY"
+curl -s -G "$API/sites/1/containers/metadata" \
+ -H "Authorization: Bearer $KEY" \
+ --data-urlencode 'image=ghcr.io/mieweb/opensource-server/base:latest' | jq '.data'
```
## 3. Create a Container
+Send a JSON body to `POST /sites/{siteId}/containers`. A least-loaded node in the site is selected automatically — you do not choose a node.
+
```bash
-curl -X POST 'https://create-a-container.opensource.mieweb.org/sites/1/containers' \
- -H "Authorization: Bearer YOUR_API_KEY" \
- -H 'Content-Type: application/x-www-form-urlencoded' \
- --data-urlencode 'hostname=my-app' \
- --data-urlencode 'template=pve1,100' \
- --data-urlencode 'services[0][type]=tcp' \
- --data-urlencode 'services[0][internalPort]=22' \
- --data-urlencode 'services[1][type]=http' \
- --data-urlencode 'services[1][internalPort]=3000' \
- --data-urlencode 'services[1][externalHostname]=my-app' \
- --data-urlencode 'services[1][externalDomainId]=1'
+curl -s -X POST "$API/sites/1/containers" \
+ -H "Authorization: Bearer $KEY" \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "hostname": "my-app",
+ "template": "ghcr.io/mieweb/opensource-server/base:latest",
+ "services": {
+ "ssh": { "type": "tcp", "internalPort": 22 },
+ "web": { "type": "http", "internalPort": 3000, "externalHostname": "my-app", "externalDomainId": 1 }
+ }
+ }' | jq '.data'
```
-**Required:** `hostname` (letters/numbers/hyphens), `template` (`nodeName,templateVmid`).
+On success this returns `201` with the new container ID and the creation job ID:
+
+```json
+{ "data": { "containerId": 42, "jobId": 100, "hostname": "my-app", "status": "pending" } }
+```
-### Service Types
+Watch the job to completion — see [Track the Creation Job](#5-track-the-creation-job).
-Services use a zero-indexed array (`services[0]`, `services[1]`, etc.):
+### Request Fields
-| Type | Required Fields | Notes |
-|------|----------------|-------|
-| `tcp` | `internalPort` | External port auto-assigned |
-| `udp` | `internalPort` | External port auto-assigned (range 2000–65565) |
-| `http` | `internalPort`, `externalHostname`, `externalDomainId` | Subdomain + domain |
-| `srv` | `internalPort`, `dnsName` | e.g., `_ldap._tcp` |
+| Field | Required | Description |
+|-------|----------|-------------|
+| `hostname` | yes | Lowercase letters, digits, hyphens. |
+| `template` | yes | A Docker image reference (e.g. `ghcr.io/mieweb/opensource-server/base:latest`, `docker.io/library/nginx:latest`). Use the literal string `custom` together with `customTemplate` to supply an image not in the standard list. |
+| `customTemplate` | no | Image reference used when `template` is `"custom"`. |
+| `entrypoint` | no | Override the container entrypoint. |
+| `nvidiaRequested` | no | `true` to schedule on an NVIDIA-capable node and inject GPU env vars. |
+| `environmentVars` | no | Array of `{ "key": "...", "value": "..." }` objects. |
+| `services` | no | An **object** whose keys are arbitrary labels and whose values are service definitions (see below). |
+
+### Services
+
+`services` is a JSON object (a map), not an array. The keys (`"ssh"`, `"web"`, … above) are arbitrary labels you choose; only the values matter:
+
+| Type | Required fields | Notes |
+|------|-----------------|-------|
+| `tcp` | `internalPort` | External port auto-assigned. For TLS termination, also send `tls: true` with `externalHostname` + `externalDomainId` (see below). |
+| `tls` | `internalPort` | Like `tcp`, but the load balancer re-encrypts to the backend (`proxy_ssl`). May also terminate client TLS (`tls: true` + domain). |
+| `udp` | `internalPort` | External port auto-assigned (range 2000–65535). |
+| `http` | `internalPort`, `externalHostname`, `externalDomainId` | Public side is always HTTPS. |
+| `https` | `internalPort`, `externalHostname`, `externalDomainId` | Same as `http` but the proxy talks HTTPS to your backend. |
+| `srv` | `internalPort`, `dnsName` | DNS SRV record, e.g. `_ldap._tcp`. |
+
+!!! note
-## 4. Complete Example
+ Add a `tcp` service with `internalPort: 22` to expose SSH. Its external port is auto-assigned; read it back from the container detail (`sshPort`) once created.
+
+#### TLS Service (backend re-encryption)
+
+Use the `tls` type when your container's service speaks TLS — the load balancer connects to it over TLS (`proxy_ssl`; the backend certificate is not verified). The client still connects to the auto-assigned port over plain TCP unless you also enable termination.
```bash
-API_KEY="your_api_key_here"
-
-curl -X POST 'https://create-a-container.opensource.mieweb.org/sites/1/containers' \
- -H "Authorization: Bearer $API_KEY" \
- -H 'Content-Type: application/x-www-form-urlencoded' \
- --data-urlencode 'hostname=my-web-app' \
- --data-urlencode 'template=pve1,100' \
- --data-urlencode 'services[0][type]=tcp' \
- --data-urlencode 'services[0][internalPort]=22' \
- --data-urlencode 'services[1][type]=http' \
- --data-urlencode 'services[1][internalPort]=8080' \
- --data-urlencode 'services[1][externalHostname]=my-web-app' \
- --data-urlencode 'services[1][externalDomainId]=1' \
- --data-urlencode 'services[2][type]=tcp' \
- --data-urlencode 'services[2][internalPort]=5432'
+curl -s -X POST "$API/sites/1/containers" \
+ -H "Authorization: Bearer $KEY" \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "hostname": "my-svc",
+ "template": "docker.io/library/nginx:latest",
+ "services": { "secure": { "type": "tls", "internalPort": 8443 } }
+ }' | jq '.data'
```
-Returns 302 redirect on success, error message on failure.
+#### TLS Termination at the Load Balancer
-## 5. List Your Containers
+To have the load balancer terminate client TLS (using the same certificate as the matching HTTP domain), set `tls: true` and provide an external domain. This works for `tcp` and `tls` services (not `udp`); a `tls` service can both terminate client TLS and re-encrypt to the backend.
```bash
-curl -X GET 'https://create-a-container.opensource.mieweb.org/api/containers' \
- -H "Authorization: Bearer YOUR_API_KEY"
+curl -s -X POST "$API/sites/1/containers" \
+ -H "Authorization: Bearer $KEY" \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "hostname": "my-db",
+ "template": "docker.io/library/postgres:16",
+ "services": {
+ "pg": {
+ "type": "tcp",
+ "internalPort": 5432,
+ "tls": true,
+ "externalHostname": "db",
+ "externalDomainId": 1
+ }
+ }
+ }' | jq '.data'
```
-## 6. Access Your Container
+## 4. List Your Containers
-**SSH:** `ssh -p @.`
+```bash
+# All containers in a site
+curl -s "$API/sites/1/containers" -H "Authorization: Bearer $KEY" | jq '.data'
-**HTTP:** `https://.`
+# A single container (note the auto-assigned sshPort and service externalPorts)
+curl -s "$API/sites/1/containers/42" -H "Authorization: Bearer $KEY" | jq '.data'
+```
-**Proxmox:** Navigate to [{{ proxmox_url }}]({{ proxmox_url }}) — your container is listed with your username in the tags field.
+The container payload includes `status`, `ipv4Address`, `sshPort`, and a `services` array where each transport service reports its auto-assigned `externalPort`.
-
+## 5. Track the Creation Job
-!!! note
+Container creation is asynchronous. Use the `jobId` from the create response:
+
+```bash
+# Poll job metadata (status: pending | running | completed | failed)
+curl -s "$API/jobs/100" -H "Authorization: Bearer $KEY" | jq '.data.status'
+
+# Read job log lines
+curl -s "$API/jobs/100/status" -H "Authorization: Bearer $KEY" | jq '.data'
+
+# Or follow the live Server-Sent Events stream
+curl -N "$API/jobs/100/stream" -H "Authorization: Bearer $KEY"
+```
+
+## 6. Update or Delete a Container
+
+```bash
+# Update services, environment variables, or entrypoint. Existing services are
+# immutable (except auth) — delete and re-add to change them. Applying changes
+# may enqueue a restart job.
+curl -s -X PUT "$API/sites/1/containers/42" \
+ -H "Authorization: Bearer $KEY" \
+ -H 'Content-Type: application/json' \
+ -d '{ "services": { "extra": { "type": "tcp", "internalPort": 8080 } } }' | jq '.data'
+
+# Restart only (no other changes)
+curl -s -X PUT "$API/sites/1/containers/42" \
+ -H "Authorization: Bearer $KEY" \
+ -H 'Content-Type: application/json' \
+ -d '{ "restart": true }' | jq '.data'
+
+# Delete (also cleans up DNS; returns any warnings)
+curl -s -X DELETE "$API/sites/1/containers/42" -H "Authorization: Bearer $KEY" | jq '.data'
+```
+
+## 7. Access Your Container
- You can start, stop, and reboot through Proxmox. To delete, contact an administrator.
+**SSH:** `ssh -p @.`
+
+**HTTP:** `https://.`
+
+**Proxmox console:** [{{ proxmox_url }}]({{ proxmox_url }}) — your container is listed with your username in the tags field.
+
+
diff --git a/mie-opensource-landing/docs/users/creating-containers/web-gui.md b/mie-opensource-landing/docs/users/creating-containers/web-gui.md
index 9aaef8c1..55f02a6e 100644
--- a/mie-opensource-landing/docs/users/creating-containers/web-gui.md
+++ b/mie-opensource-landing/docs/users/creating-containers/web-gui.md
@@ -43,11 +43,22 @@ Expose container ports. Click **Add Service**, select a type, and enter the inte
| **HTTP** | Reverse-proxies to the container over plain HTTP. Use when the backend listens on an unencrypted port. |
| **HTTPS** | Reverse-proxies to the container over HTTPS. Use when the backend terminates TLS itself. |
| **TCP / UDP** | Maps an auto-assigned external port to the container's internal port. |
+| **TLS** | Like TCP, but the load balancer connects to your container over TLS (re-encrypts to the backend). Use when the backend listens on a TLS port. |
| **SRV** | Creates a DNS SRV record for service discovery. |
Both HTTP and HTTPS services are served to the public over HTTPS with automatic TLS certificates — the type controls the protocol used between the reverse proxy and your container.
-HTTP and HTTPS services require selecting an external domain; the hostname defaults to the container hostname. TCP/UDP services are auto-assigned an external port.
+HTTP and HTTPS services require selecting an external domain; the hostname defaults to the container hostname. TCP/UDP/TLS services are auto-assigned an external port — once the container is created, the assigned port is shown (read-only) in the service row when you edit the container.
+
+#### TLS service type (backend re-encryption)
+
+The **TLS** type is the layer-4 equivalent of HTTPS: the client connects to the auto-assigned port over plain TCP, and the load balancer opens a TLS connection to your container (NGINX `proxy_ssl`). The backend certificate is not verified, so self-signed certificates work. Use this when your container's service speaks TLS.
+
+#### Terminating TLS at the load balancer
+
+TCP and TLS services can optionally enable **TLS termination at the load balancer**. Toggle **Terminate TLS at load balancer** and select an external domain (and optional hostname, just like an HTTP service). The load balancer then terminates TLS on the assigned external port using that domain's certificate — the same certificate used by HTTP services. This lets clients connect over an encrypted connection without the container managing certificates. A TLS service can both terminate client TLS and re-encrypt to the backend.
+
+TLS is not available for UDP services.
!!! note