Skip to content

Instantly share code, notes, and snippets.

@jensens
Created June 9, 2026 21:28
Show Gist options
  • Select an option

  • Save jensens/2a24ce088d46a33942f9c066c123c058 to your computer and use it in GitHub Desktop.

Select an option

Save jensens/2a24ce088d46a33942f9c066c123c058 to your computer and use it in GitHub Desktop.
Self-hosting Solidtime on Kubernetes with CDK8S (Kube-Hetzner / CloudNativePG / ESO) — opinionated reference + gotchas

Solidtime on Kubernetes with CDK8S

How we self-host Solidtime on a k3s cluster (Hetzner), deployed with CDK8S (TypeScript) and synced by ArgoCD.

This is opinionated and cluster-specific — not a generic helm install. It's here as a reference: steal the patterns and the gotchas. Everything is driven by config.yaml; no secrets are hardcoded (they come from External Secrets Operator).

The files below are flattened out of a normal CDK8S project. Original layout:

solidtime/
├── config.yaml                 # 01 — all the knobs
├── main.ts                     # 02 — load config, synth
├── charts/
│   ├── solidtime-chart.ts      # 03 — orchestrates constructs by ArgoCD sync-wave
│   ├── types.ts                # 04 — config schema
│   └── constructs/
│       ├── namespace-construct.ts            # 05
│       ├── cluster-secret-store-construct.ts # 06 — ESO bridge
│       ├── app-secrets-construct.ts          # 07 — APP_KEY + Passport keys, SMTP
│       ├── s3-credentials-construct.ts       # 08 — S3 creds via ESO
│       ├── s3-buckets-construct.ts           # 09 — Crossplane buckets (hot/cold)
│       ├── postgres-construct.ts             # 10 — CloudNativePG + barman backups
│       ├── app-construct.ts                  # 11 — the 3 roles + Gotenberg
│       └── ingress-construct.ts              # 12 — Traefik + cert-manager

imports/ (generated CRD types: k8s, CloudNativePG, ESO, Crossplane s3.aws.upbound.io) is omitted — run cdk8s import against your own CRD versions.

The stack

  • One image, three Deployments via CONTAINER_MODE (http / scheduler / worker)
    • a Gotenberg sidecar for PDF/report export.
  • Postgres via CloudNativePG (2 replicas, barman-cloud backups to S3).
  • App files on S3 (Hetzner Object Storage); cache/queue/sessions on the DB, no Redis.
  • Mailjet for mail, Traefik + cert-manager for ingress/TLS, all secrets via ESO.

Gotchas (the actually useful part)

  1. S3 env vars are S3_*, not AWS_*. config/filesystems.php reads S3_REGION / S3_BUCKET / S3_ENDPOINT / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY / S3_USE_PATH_STYLE_ENDPOINT (path-style = true for Hetzner). Setting AWS_* leaves them empty → AWS-SDK "Missing required client configuration options".
  2. Health probe: /login (200), not / or /health. There's no /health//up. / 302-redirects to the absolute https:// APP_URL; kubelet follows it over HTTPS against the plain-HTTP container → "HTTP response to HTTPS client" → probe never passes.
  3. Worker needs WORKER_COMMAND (php artisan queue:work …) — unset → crashloop.
  4. Migrations: AUTO_DB_MIGRATE=true on the scheduler only (single replica), no separate Job.
  5. Passport keys via env (PASSPORT_PRIVATE_KEY / PASSPORT_PUBLIC_KEY) + APP_KEY, generated once, never rotated — keeps pods stateless.

Adapt to your cluster

  • s3.*.providerConfigRef (hetzner-s3-hel1, hetzner-s3) are our Crossplane ProviderConfig names — replace with yours, or swap the bucket construct for however you provision S3.
  • The ESO ClusterSecretStore reads from an application-secrets namespace via an eso-app-secrets-reader ServiceAccount, and S3 creds from a cluster-wide hetzner-s3-cluster-store. Point these at your own secret backend.
  • Replace time.example.com / example.com / bucket names with yours.
  • cert-manager.io/cluster-issuer: letsencrypt-cluster-issuer and ingressClassName: traefik assume those exist on the cluster.
# Solidtime Deployment Configuration for example cluster
namespace: solidtime
domain: time.example.com
versions:
# Current stable Solidtime release (https://hub.docker.com/r/solidtime/solidtime/tags).
# amd64 image, pushed 2026-06-03. Bump deliberately; never use "latest".
solidtime: "0.14.0"
postgresMajor: 18
gotenberg: "8"
# Super-admin email(s), comma-separated. These accounts are granted admin rights
# on registration (SUPER_ADMINS). The first signup uses an email listed here.
superAdmins: "admin@example.com"
# Allow self-registration. "false" = invite-only (super-admin exists; team migrated
# and invited 2026-06-09). Invite acceptance is NOT gated by this (separate route).
enableRegistration: "false"
storage:
class: longhorn
postgres: 10Gi
replicas:
http: 2
postgres: 2
# Bucket naming: LOCALPART-NAMESPACE (globally unique across Hetzner).
# Each bucket pins its Crossplane ProviderConfig; region MUST match the
# ProviderConfig endpoint. Both ProviderConfigs share secret hetzner-s3-creds.
# NEVER use hetzner-s3-dr (etcd-endpoint-driven).
s3:
files:
endpoint: https://hel1.your-objectstorage.com
region: hel1
providerConfigRef: hetzner-s3-hel1
bucket: solidtime-files
backups:
endpoint: https://fsn1.your-objectstorage.com
region: fsn1
providerConfigRef: hetzner-s3
bucket: solidtime-backups
smtp:
host: in-v3.mailjet.com
port: 587
useTls: true
from: "noreply@example.com"
# Daily CNPG backup at 02:30 (CNPG cron format: seconds field first).
backupSchedule: "0 30 2 * * *"
resources:
http:
requests: { cpu: 250m, memory: 512Mi }
limits: { cpu: 1000m, memory: 1Gi }
worker:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
scheduler:
requests: { cpu: 50m, memory: 128Mi }
limits: { cpu: 250m, memory: 256Mi }
gotenberg:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
postgres:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
#!/usr/bin/env ts-node
import { App } from 'cdk8s';
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { SolidtimeChart } from './charts/solidtime-chart';
import { SolidtimeConfig } from './charts/types';
const config = yaml.load(fs.readFileSync('./config.yaml', 'utf8')) as SolidtimeConfig;
if (process.env.SOLIDTIME_DOMAIN) {
config.domain = process.env.SOLIDTIME_DOMAIN;
}
const app = new App();
new SolidtimeChart(app, 'solidtime', config);
app.synth();
import { Construct } from 'constructs';
import { Chart, ChartProps } from 'cdk8s';
import { SolidtimeConfig } from './types';
import { NamespaceConstruct } from './constructs/namespace-construct';
import { ClusterSecretStoreConstruct } from './constructs/cluster-secret-store-construct';
import { AppSecretsConstruct } from './constructs/app-secrets-construct';
import { S3CredentialsConstruct } from './constructs/s3-credentials-construct';
import { S3BucketsConstruct } from './constructs/s3-buckets-construct';
import { PostgresConstruct } from './constructs/postgres-construct';
import { AppConstruct } from './constructs/app-construct';
import { IngressConstruct } from './constructs/ingress-construct';
/**
* Solidtime chart. Sync-wave order:
* 0 namespace + ClusterSecretStore | 1 ExternalSecrets | 2 buckets + ObjectStore
* 3 CNPG cluster + ScheduledBackup | 4 env ConfigMap + migrate Job
* 5 deployments + services + gotenberg | 6 ingress
*/
export class SolidtimeChart extends Chart {
constructor(scope: Construct, id: string, config: SolidtimeConfig, props?: ChartProps) {
super(scope, id, props);
new NamespaceConstruct(this, 'namespace', { name: config.namespace });
const store = new ClusterSecretStoreConstruct(this, 'cluster-secret-store', {
namespace: config.namespace,
});
const appSecrets = new AppSecretsConstruct(this, 'app-secrets', {
namespace: config.namespace,
storeName: store.storeName,
});
const s3Creds = new S3CredentialsConstruct(this, 's3-credentials', {
namespace: config.namespace,
});
new S3BucketsConstruct(this, 's3-buckets', {
files: config.s3.files,
backups: config.s3.backups,
});
const postgres = new PostgresConstruct(this, 'postgres', {
namespace: config.namespace,
storageClass: config.storage.class,
storageSize: config.storage.postgres,
instances: config.replicas.postgres,
postgresMajor: config.versions.postgresMajor,
backup: {
s3CredentialsSecret: s3Creds.secretName,
s3Endpoint: config.s3.backups.endpoint,
s3Bucket: config.s3.backups.bucket,
retentionPolicy: '30d',
schedule: config.backupSchedule,
},
resources: config.resources.postgres,
});
const app = new AppConstruct(this, 'app', {
config,
dbAppSecret: postgres.appSecretName,
dbHost: postgres.rwService,
appSecret: appSecrets.appSecretName,
smtpSecret: appSecrets.smtpSecretName,
s3Secret: s3Creds.secretName,
});
new IngressConstruct(this, 'ingress', {
namespace: config.namespace,
domain: config.domain,
serviceName: app.httpServiceName,
});
}
}
/**
* Configuration types for Solidtime deployment.
*/
export interface ResourceRequirements {
requests?: { cpu?: string; memory?: string };
limits?: { cpu?: string; memory?: string };
}
export interface BucketConfig {
endpoint: string;
region: string;
providerConfigRef: string;
bucket: string;
}
export interface S3Config {
files: BucketConfig;
backups: BucketConfig;
}
export interface StorageConfig {
class: string;
postgres: string;
}
export interface ReplicasConfig {
http: number;
postgres: number;
}
export interface VersionsConfig {
solidtime: string;
postgresMajor: number;
gotenberg: string;
}
export interface SmtpConfig {
host: string;
port: number;
useTls: boolean;
from: string;
}
export interface SolidtimeResources {
http: ResourceRequirements;
worker: ResourceRequirements;
scheduler: ResourceRequirements;
gotenberg: ResourceRequirements;
postgres: ResourceRequirements;
}
export interface SolidtimeConfig {
namespace: string;
domain: string;
versions: VersionsConfig;
storage: StorageConfig;
replicas: ReplicasConfig;
s3: S3Config;
smtp: SmtpConfig;
resources: SolidtimeResources;
backupSchedule: string;
superAdmins: string;
enableRegistration: string;
}
import { Construct } from 'constructs';
import { KubeNamespace } from '../../imports/k8s';
export interface NamespaceConstructProps {
name: string;
}
/** Creates the solidtime namespace. Wave 0. */
export class NamespaceConstruct extends Construct {
constructor(scope: Construct, id: string, props: NamespaceConstructProps) {
super(scope, id);
new KubeNamespace(this, 'namespace', {
metadata: {
name: props.name,
annotations: { 'argocd.argoproj.io/sync-wave': '0' },
labels: {
'app.kubernetes.io/name': 'solidtime',
'app.kubernetes.io/managed-by': 'cdk8s',
},
},
});
}
}
import { Construct } from 'constructs';
import {
ClusterSecretStore,
ClusterSecretStoreSpecProviderKubernetesServerCaProviderType,
} from '../../imports/external-secrets.io';
export interface ClusterSecretStoreConstructProps {
readonly namespace: string;
}
/**
* ClusterSecretStore bridging application-secrets (source) -> solidtime namespace.
* Uses the shared eso-app-secrets-reader ServiceAccount. Wave 0.
*/
export class ClusterSecretStoreConstruct extends Construct {
public readonly storeName: string = 'solidtime-app-secrets-store';
constructor(scope: Construct, id: string, props: ClusterSecretStoreConstructProps) {
super(scope, id);
new ClusterSecretStore(this, 'store', {
metadata: {
name: this.storeName,
annotations: { 'argocd.argoproj.io/sync-wave': '0' },
},
spec: {
conditions: [{ namespaces: [props.namespace] }],
provider: {
kubernetes: {
remoteNamespace: 'application-secrets',
server: {
caProvider: {
type: ClusterSecretStoreSpecProviderKubernetesServerCaProviderType.CONFIG_MAP,
name: 'kube-root-ca.crt',
key: 'ca.crt',
namespace: 'kube-system',
},
},
auth: {
serviceAccount: {
name: 'eso-app-secrets-reader',
namespace: 'application-secrets',
},
},
},
},
},
});
}
}
import { Construct } from 'constructs';
import {
ExternalSecret,
ExternalSecretSpecTargetCreationPolicy,
ExternalSecretSpecTargetTemplateEngineVersion,
ExternalSecretSpecSecretStoreRefKind,
} from '../../imports/external-secrets.io';
export interface AppSecretsConstructProps {
readonly namespace: string;
readonly storeName: string;
}
/**
* ExternalSecrets for Solidtime APP_KEY and Mailjet SMTP credentials. Wave 1.
*
* Target secrets (in solidtime namespace):
* - solidtime-app-secrets: keys "app-key", "passport-private-key",
* "passport-public-key" -> APP_KEY / PASSPORT_PRIVATE_KEY / PASSPORT_PUBLIC_KEY
* - solidtime-smtp-secrets: keys "username"/"password" -> MAIL_USERNAME/MAIL_PASSWORD
*
* Prerequisites in application-secrets namespace (see scripts/bootstrap-secrets.sh):
* - solidtime-app-secrets: APP_KEY, PASSPORT_PRIVATE_KEY, PASSPORT_PUBLIC_KEY
* - solidtime-smtp-secrets: SMTP_USERNAME, SMTP_PASSWORD
*/
export class AppSecretsConstruct extends Construct {
public readonly appSecretName: string = 'solidtime-app-secrets';
public readonly smtpSecretName: string = 'solidtime-smtp-secrets';
constructor(scope: Construct, id: string, props: AppSecretsConstructProps) {
super(scope, id);
const { namespace, storeName } = props;
new ExternalSecret(this, 'app-external-secret', {
metadata: {
name: 'solidtime-app-secrets-es',
namespace,
annotations: { 'argocd.argoproj.io/sync-wave': '1' },
},
spec: {
refreshInterval: '1h',
secretStoreRef: {
name: storeName,
kind: ExternalSecretSpecSecretStoreRefKind.CLUSTER_SECRET_STORE,
},
target: {
name: this.appSecretName,
creationPolicy: ExternalSecretSpecTargetCreationPolicy.OWNER,
template: {
engineVersion: ExternalSecretSpecTargetTemplateEngineVersion.V2,
data: {
'app-key': '{{ .APP_KEY }}',
'passport-private-key': '{{ .PASSPORT_PRIVATE_KEY }}',
'passport-public-key': '{{ .PASSPORT_PUBLIC_KEY }}',
},
},
},
dataFrom: [{ extract: { key: 'solidtime-app-secrets' } }],
},
});
new ExternalSecret(this, 'smtp-external-secret', {
metadata: {
name: 'solidtime-smtp-secrets-es',
namespace,
annotations: { 'argocd.argoproj.io/sync-wave': '1' },
},
spec: {
refreshInterval: '1h',
secretStoreRef: {
name: storeName,
kind: ExternalSecretSpecSecretStoreRefKind.CLUSTER_SECRET_STORE,
},
target: {
name: this.smtpSecretName,
creationPolicy: ExternalSecretSpecTargetCreationPolicy.OWNER,
template: {
engineVersion: ExternalSecretSpecTargetTemplateEngineVersion.V2,
data: {
'username': '{{ .SMTP_USERNAME }}',
'password': '{{ .SMTP_PASSWORD }}',
},
},
},
dataFrom: [{ extract: { key: 'solidtime-smtp-secrets' } }],
},
});
}
}
import { Construct } from 'constructs';
import {
ExternalSecret,
ExternalSecretSpecTargetCreationPolicy,
ExternalSecretSpecTargetTemplateEngineVersion,
ExternalSecretSpecSecretStoreRefKind,
} from '../../imports/external-secrets.io';
export interface S3CredentialsConstructProps {
namespace: string;
}
/**
* ExternalSecret for Hetzner S3 credentials (project-wide, both regions).
* Pulls from the cluster-wide hetzner-s3-cluster-store. Wave 1.
* Target secret solidtime-s3-credentials provides AWS_ACCESS_KEY_ID /
* AWS_SECRET_ACCESS_KEY for both the app (FILESYSTEM_DISK=s3, hel1) and CNPG
* barman backups (fsn1). The same credentials work across Hetzner regions.
*/
export class S3CredentialsConstruct extends Construct {
public readonly secretName: string = 'solidtime-s3-credentials';
constructor(scope: Construct, id: string, props: S3CredentialsConstructProps) {
super(scope, id);
new ExternalSecret(this, 'solidtime-s3-external-secret', {
metadata: {
name: 'solidtime-s3-credentials-es',
namespace: props.namespace,
annotations: { 'argocd.argoproj.io/sync-wave': '1' },
},
spec: {
refreshInterval: '1h',
secretStoreRef: {
name: 'hetzner-s3-cluster-store',
kind: ExternalSecretSpecSecretStoreRefKind.CLUSTER_SECRET_STORE,
},
target: {
name: this.secretName,
creationPolicy: ExternalSecretSpecTargetCreationPolicy.OWNER,
template: {
engineVersion: ExternalSecretSpecTargetTemplateEngineVersion.V2,
data: {
'AWS_ACCESS_KEY_ID': '{{ .AWS_ACCESS_KEY_ID }}',
'AWS_SECRET_ACCESS_KEY': '{{ .AWS_SECRET_ACCESS_KEY }}',
},
},
},
dataFrom: [{ extract: { key: 'hetzner-s3-creds-standard' } }],
},
});
}
}
import { Construct } from 'constructs';
import {
BucketV1Beta2,
BucketV1Beta2SpecManagementPolicies,
BucketV1Beta2SpecDeletionPolicy,
} from '../../imports/s3.aws.upbound.io';
import { BucketConfig } from '../types';
export interface S3BucketsConstructProps {
files: BucketConfig;
backups: BucketConfig;
}
/**
* Crossplane Bucket resources for Solidtime. Wave 2.
* - files (hot) -> hel1 via providerConfigRef hetzner-s3-hel1
* - backups (cold) -> fsn1 via providerConfigRef hetzner-s3
* Buckets live in crossplane-system per cluster standard. Update is skipped
* (Hetzner S3 has no tagging). deletionPolicy Orphan = keep data on delete.
*/
export class S3BucketsConstruct extends Construct {
constructor(scope: Construct, id: string, props: S3BucketsConstructProps) {
super(scope, id);
const buckets = [
{ cfg: props.files, purpose: 'Solidtime app file storage (hot)' },
{ cfg: props.backups, purpose: 'Solidtime PostgreSQL CNPG backups (cold)' },
];
buckets.forEach(({ cfg, purpose }) => {
new BucketV1Beta2(this, cfg.bucket, {
metadata: {
name: cfg.bucket,
namespace: 'crossplane-system',
labels: {
'app.kubernetes.io/managed-by': 'cdk8s',
'app.kubernetes.io/part-of': 'solidtime',
'app.kubernetes.io/component': 'storage',
},
annotations: {
'argocd.argoproj.io/sync-wave': '2',
'crossplane.io/external-name': cfg.bucket,
'description': purpose,
},
},
spec: {
forProvider: { region: cfg.region },
providerConfigRef: { name: cfg.providerConfigRef },
managementPolicies: [
BucketV1Beta2SpecManagementPolicies.OBSERVE,
BucketV1Beta2SpecManagementPolicies.CREATE,
BucketV1Beta2SpecManagementPolicies.DELETE,
],
deletionPolicy: BucketV1Beta2SpecDeletionPolicy.ORPHAN,
},
});
});
}
}
import { Construct } from 'constructs';
import { ApiObject } from 'cdk8s';
import {
Cluster,
ClusterSpecPrimaryUpdateStrategy,
ClusterSpecBackupTarget,
} from '../../imports/cnpg-cluster-postgresql.cnpg.io';
import {
ScheduledBackup,
ScheduledBackupSpecMethod,
ScheduledBackupSpecBackupOwnerReference,
} from '../../imports/cnpg-scheduledbackup-postgresql.cnpg.io';
import * as k8s from '../../imports/k8s';
import { ResourceRequirements } from '../types';
const BARMAN_PLUGIN_NAME = 'barman-cloud.cloudnative-pg.io';
export interface PostgresConstructProps {
namespace: string;
storageClass: string;
storageSize: string;
instances: number;
postgresMajor: number;
backup: {
s3CredentialsSecret: string;
s3Endpoint: string;
s3Bucket: string;
retentionPolicy: string;
schedule: string;
};
resources?: ResourceRequirements;
}
/**
* CloudNativePG Cluster + barman-cloud ObjectStore + daily ScheduledBackup.
* Wave 2: ObjectStore. Wave 3: Cluster + ScheduledBackup.
* Full barman recipe (barmanObjectName + ScheduledBackup) per
* dp-infra/mailu/charts/postgres-construct.ts.
* Database/owner = "solidtime". App connects via solidtime-postgres-rw.
*/
export class PostgresConstruct extends Construct {
public readonly clusterName: string = 'solidtime-postgres';
public readonly objectStoreName: string = 'solidtime-postgres-backup';
public readonly appSecretName: string = 'solidtime-postgres-app';
public readonly rwService: string = 'solidtime-postgres-rw';
constructor(scope: Construct, id: string, props: PostgresConstructProps) {
super(scope, id);
const { namespace, storageClass, storageSize, instances, postgresMajor, backup, resources } = props;
// Wave 2: barman ObjectStore (ApiObject — no typed import, matches mailu)
new ApiObject(this, 'object-store', {
apiVersion: 'barmancloud.cnpg.io/v1',
kind: 'ObjectStore',
metadata: {
name: this.objectStoreName,
namespace,
annotations: { 'argocd.argoproj.io/sync-wave': '2' },
},
spec: {
configuration: {
destinationPath: `s3://${backup.s3Bucket}/`,
endpointURL: backup.s3Endpoint,
s3Credentials: {
accessKeyId: { name: backup.s3CredentialsSecret, key: 'AWS_ACCESS_KEY_ID' },
secretAccessKey: { name: backup.s3CredentialsSecret, key: 'AWS_SECRET_ACCESS_KEY' },
},
wal: { compression: 'gzip', maxParallel: 2 },
data: { compression: 'gzip', jobs: 2 },
},
},
});
// Wave 3: CNPG Cluster
new Cluster(this, 'cluster', {
metadata: {
name: this.clusterName,
namespace,
annotations: { 'argocd.argoproj.io/sync-wave': '3' },
labels: {
'app.kubernetes.io/name': 'postgresql',
'app.kubernetes.io/component': 'database',
'app.kubernetes.io/part-of': 'solidtime',
},
},
spec: {
instances,
// Pin the major; CNPG uses its default image catalog for this major.
imageName: `ghcr.io/cloudnative-pg/postgresql:${postgresMajor}`,
primaryUpdateStrategy: ClusterSpecPrimaryUpdateStrategy.UNSUPERVISED,
postgresql: {
parameters: {
max_connections: '100',
shared_buffers: '128MB',
effective_cache_size: '384MB',
work_mem: '8MB',
maintenance_work_mem: '64MB',
},
},
bootstrap: {
initdb: { database: 'solidtime', owner: 'solidtime' },
},
storage: { storageClass, size: storageSize },
resources: {
requests: {
cpu: k8s.Quantity.fromString(resources?.requests?.cpu || '100m'),
memory: k8s.Quantity.fromString(resources?.requests?.memory || '256Mi'),
},
limits: {
cpu: k8s.Quantity.fromString(resources?.limits?.cpu || '500m'),
memory: k8s.Quantity.fromString(resources?.limits?.memory || '512Mi'),
},
},
backup: {
target: ClusterSpecBackupTarget.PRIMARY,
retentionPolicy: backup.retentionPolicy,
},
plugins: [
{ name: BARMAN_PLUGIN_NAME, parameters: { barmanObjectName: this.objectStoreName } },
],
monitoring: { enablePodMonitor: true },
affinity: {
enablePodAntiAffinity: true,
topologyKey: 'kubernetes.io/hostname',
},
},
});
// Wave 3: daily ScheduledBackup via plugin
new ScheduledBackup(this, 'scheduled-backup', {
metadata: {
name: 'solidtime-postgres-daily',
namespace,
annotations: { 'argocd.argoproj.io/sync-wave': '3' },
},
spec: {
schedule: backup.schedule,
backupOwnerReference: ScheduledBackupSpecBackupOwnerReference.SELF,
cluster: { name: this.clusterName },
method: ScheduledBackupSpecMethod.PLUGIN,
pluginConfiguration: { name: BARMAN_PLUGIN_NAME },
immediate: true,
},
});
}
}
import { Construct } from 'constructs';
import * as k8s from '../../imports/k8s';
import { ResourceRequirements, SolidtimeConfig } from '../types';
export interface AppConstructProps {
config: SolidtimeConfig;
dbAppSecret: string; // solidtime-postgres-app (CNPG-generated)
dbHost: string; // solidtime-postgres-rw
appSecret: string; // solidtime-app-secrets (key: app-key)
smtpSecret: string; // solidtime-smtp-secrets (keys: username/password)
s3Secret: string; // solidtime-s3-credentials (AWS_ACCESS_KEY_ID/_SECRET)
}
const APP_LABELS = { 'app.kubernetes.io/name': 'solidtime', 'app.kubernetes.io/part-of': 'solidtime' };
const IMAGE_BASE = 'solidtime/solidtime';
const HTTP_PORT = 8000;
const GOTENBERG_PORT = 3000;
const GOTENBERG_SVC = 'solidtime-gotenberg';
export class AppConstruct extends Construct {
public readonly httpServiceName: string = 'solidtime';
constructor(scope: Construct, id: string, props: AppConstructProps) {
super(scope, id);
const { config, dbAppSecret, dbHost, appSecret, smtpSecret, s3Secret } = props;
const ns = config.namespace;
const image = `${IMAGE_BASE}:${config.versions.solidtime}`;
// ---- Non-secret env shared by all three Solidtime roles (ConfigMap) ----
const envConfig = new k8s.KubeConfigMap(this, 'env', {
metadata: { name: 'solidtime-env', namespace: ns, annotations: { 'argocd.argoproj.io/sync-wave': '4' } },
data: {
APP_ENV: 'production',
APP_DEBUG: 'false',
APP_URL: `https://${config.domain}`,
// Log to stderr (visible via `kubectl logs`) instead of storage/logs/laravel.log.
LOG_CHANNEL: 'stderr',
// DB
DB_CONNECTION: 'pgsql',
DB_HOST: dbHost,
DB_PORT: '5432',
DB_DATABASE: 'solidtime',
// Cache/queue/sessions via database (no Redis)
CACHE_STORE: 'database',
QUEUE_CONNECTION: 'database',
SESSION_DRIVER: 'database',
// Mail (Mailjet)
MAIL_MAILER: 'smtp',
MAIL_HOST: config.smtp.host,
MAIL_PORT: String(config.smtp.port),
MAIL_ENCRYPTION: config.smtp.useTls ? 'tls' : 'null',
MAIL_FROM_ADDRESS: config.smtp.from,
MAIL_FROM_NAME: 'Solidtime',
// Registration / admin (flip APP_ENABLE_REGISTRATION to "false" after
// the first super-admin signs up).
APP_ENABLE_REGISTRATION: config.enableRegistration,
SUPER_ADMINS: config.superAdmins,
// Filesystem -> hel1 S3. Solidtime's config/filesystems.php reads S3_*
// (NOT AWS_*) env vars for the s3 disk.
FILESYSTEM_DISK: 's3',
S3_BUCKET: config.s3.files.bucket,
S3_ENDPOINT: config.s3.files.endpoint,
S3_REGION: config.s3.files.region,
S3_USE_PATH_STYLE_ENDPOINT: 'true',
// Gotenberg (PDF export)
GOTENBERG_URL: `http://${GOTENBERG_SVC}:${GOTENBERG_PORT}`,
},
});
// ---- Secret-derived env shared by all roles ----
const secretEnv = (): k8s.EnvVar[] => [
{ name: 'APP_KEY', valueFrom: { secretKeyRef: { name: appSecret, key: 'app-key' } } },
{ name: 'PASSPORT_PRIVATE_KEY', valueFrom: { secretKeyRef: { name: appSecret, key: 'passport-private-key' } } },
{ name: 'PASSPORT_PUBLIC_KEY', valueFrom: { secretKeyRef: { name: appSecret, key: 'passport-public-key' } } },
{ name: 'DB_USERNAME', valueFrom: { secretKeyRef: { name: dbAppSecret, key: 'username' } } },
{ name: 'DB_PASSWORD', valueFrom: { secretKeyRef: { name: dbAppSecret, key: 'password' } } },
{ name: 'MAIL_USERNAME', valueFrom: { secretKeyRef: { name: smtpSecret, key: 'username' } } },
{ name: 'MAIL_PASSWORD', valueFrom: { secretKeyRef: { name: smtpSecret, key: 'password' } } },
{ name: 'S3_ACCESS_KEY_ID', valueFrom: { secretKeyRef: { name: s3Secret, key: 'AWS_ACCESS_KEY_ID' } } },
{ name: 'S3_SECRET_ACCESS_KEY', valueFrom: { secretKeyRef: { name: s3Secret, key: 'AWS_SECRET_ACCESS_KEY' } } },
];
const res = (r: ResourceRequirements): k8s.ResourceRequirements => ({
requests: {
cpu: k8s.Quantity.fromString(r.requests?.cpu || '100m'),
memory: k8s.Quantity.fromString(r.requests?.memory || '128Mi'),
},
limits: {
cpu: k8s.Quantity.fromString(r.limits?.cpu || '500m'),
memory: k8s.Quantity.fromString(r.limits?.memory || '512Mi'),
},
});
// ---- Helper to build a Solidtime role deployment ----
const roleDeployment = (
role: 'http' | 'scheduler' | 'worker',
replicas: number,
resources: ResourceRequirements,
extras?: { ports?: k8s.ContainerPort[]; probe?: boolean; extraEnv?: k8s.EnvVar[] },
) => {
const name = `solidtime-${role}`;
const roleLabels = { ...APP_LABELS, 'app.kubernetes.io/component': role };
new k8s.KubeDeployment(this, name, {
metadata: { name, namespace: ns, annotations: { 'argocd.argoproj.io/sync-wave': '5' }, labels: roleLabels },
spec: {
replicas,
selector: { matchLabels: roleLabels },
// scheduler must never run >1 concurrently; Recreate avoids overlap
strategy: role === 'scheduler' ? { type: 'Recreate' } : undefined,
template: {
metadata: { labels: roleLabels },
spec: {
containers: [{
name: 'solidtime',
image,
imagePullPolicy: 'IfNotPresent',
env: [{ name: 'CONTAINER_MODE', value: role }, ...secretEnv(), ...(extras?.extraEnv ?? [])],
envFrom: [{ configMapRef: { name: envConfig.name } }],
ports: extras?.ports,
resources: res(resources),
...(extras?.probe && {
// Solidtime has no /health or /up route. "/" returns a 302 to the
// absolute https:// URL (APP_URL), which kubelet follows over HTTPS
// against the plain-HTTP server -> probe fails. "/login" returns 200
// directly with no redirect.
readinessProbe: {
httpGet: { path: '/login', port: k8s.IntOrString.fromNumber(HTTP_PORT) },
initialDelaySeconds: 20, periodSeconds: 15, timeoutSeconds: 5, failureThreshold: 6,
},
livenessProbe: {
httpGet: { path: '/login', port: k8s.IntOrString.fromNumber(HTTP_PORT) },
initialDelaySeconds: 40, periodSeconds: 20, timeoutSeconds: 5, failureThreshold: 6,
},
}),
}],
},
},
},
});
};
roleDeployment('http', config.replicas.http, config.resources.http, {
ports: [{ containerPort: HTTP_PORT, name: 'http' }],
probe: true,
});
// AUTO_DB_MIGRATE on the scheduler only (single replica) runs `artisan migrate`
// on startup — the documented self-host migration path. Setting it solely here
// avoids a migration race across the http/worker replicas.
roleDeployment('scheduler', 1, config.resources.scheduler, {
extraEnv: [{ name: 'AUTO_DB_MIGRATE', value: 'true' }],
});
// The image's CONTAINER_MODE=worker execs $WORKER_COMMAND — it must be set.
roleDeployment('worker', 1, config.resources.worker, {
extraEnv: [{ name: 'WORKER_COMMAND', value: 'php artisan queue:work --tries=3 --sleep=3 --max-time=3600' }],
});
// ---- http Service ----
new k8s.KubeService(this, 'service', {
metadata: { name: this.httpServiceName, namespace: ns, annotations: { 'argocd.argoproj.io/sync-wave': '5' }, labels: APP_LABELS },
spec: {
selector: { ...APP_LABELS, 'app.kubernetes.io/component': 'http' },
ports: [{ name: 'http', port: 80, targetPort: k8s.IntOrString.fromNumber(HTTP_PORT) }],
},
});
// ---- Gotenberg (PDF export) — listens on 3000 by default ----
const gotenbergLabels = { ...APP_LABELS, 'app.kubernetes.io/component': 'gotenberg' };
new k8s.KubeDeployment(this, 'gotenberg', {
metadata: { name: GOTENBERG_SVC, namespace: ns, annotations: { 'argocd.argoproj.io/sync-wave': '5' }, labels: gotenbergLabels },
spec: {
replicas: 1,
selector: { matchLabels: gotenbergLabels },
template: {
metadata: { labels: gotenbergLabels },
spec: {
containers: [{
name: 'gotenberg',
image: `gotenberg/gotenberg:${config.versions.gotenberg}`,
ports: [{ containerPort: GOTENBERG_PORT, name: 'http' }],
resources: res(config.resources.gotenberg),
}],
},
},
},
});
new k8s.KubeService(this, 'gotenberg-service', {
metadata: { name: GOTENBERG_SVC, namespace: ns, annotations: { 'argocd.argoproj.io/sync-wave': '5' }, labels: gotenbergLabels },
spec: {
selector: gotenbergLabels,
ports: [{ name: 'http', port: GOTENBERG_PORT, targetPort: k8s.IntOrString.fromNumber(GOTENBERG_PORT) }],
},
});
// DB migrations run on scheduler startup via AUTO_DB_MIGRATE=true (set above) —
// the documented Solidtime self-host migration path. No separate Job needed.
}
}
import { Construct } from 'constructs';
import { KubeIngress } from '../../imports/k8s';
export interface IngressConstructProps {
namespace: string;
domain: string;
serviceName: string;
}
/**
* Traefik Ingress + cert-manager TLS for time.example.com. Wave 6.
* The global WAF default-middleware applies automatically to all Traefik routers,
* and HTTP->HTTPS redirect is handled at the Traefik entrypoint — so no per-router
* middleware annotation is set here (referencing a non-existent middleware such as
* default-redirect-https would make Traefik drop the router -> 404).
*/
export class IngressConstruct extends Construct {
constructor(scope: Construct, id: string, props: IngressConstructProps) {
super(scope, id);
new KubeIngress(this, 'ingress', {
metadata: {
name: 'solidtime',
namespace: props.namespace,
labels: {
'app.kubernetes.io/name': 'solidtime',
'app.kubernetes.io/component': 'ingress',
'app.kubernetes.io/part-of': 'solidtime',
},
annotations: {
'argocd.argoproj.io/sync-wave': '6',
'cert-manager.io/cluster-issuer': 'letsencrypt-cluster-issuer',
},
},
spec: {
ingressClassName: 'traefik',
tls: [{ hosts: [props.domain], secretName: 'solidtime-tls' }],
rules: [{
host: props.domain,
http: {
paths: [{
path: '/',
pathType: 'Prefix',
backend: { service: { name: props.serviceName, port: { number: 80 } } },
}],
},
}],
},
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment