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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- `docker_auth:` top-level block for declaring host-level container registry credentials. On `up`, dokku-compose runs `dokku registry:login` for each entry so the docker daemon can pull from private registries — useful with `dokku git:from-image` flows. Passwords are masked in `--dry-run` output. Distinct from the per-app `apps.<app>.registry:` block, which configures push behavior. See `docs/reference/docker_auth.md`.

## [0.9.3] - 2026-04-08

### Fixed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ All features are idempotent — running `up` twice produces no changes.
| Zero-Downtime Checks | Configure deploy checks, disable per process type | [checks](docs/reference/checks.md) |
| Log Management | Log retention and vector sink configuration | [logs](docs/reference/logs.md) |
| Plugins | Install Dokku plugins declaratively | [plugins](docs/reference/plugins.md) |
| Docker Auth | Authenticate the host docker daemon to private registries (e.g. ghcr.io) for `git:from-image` flows | [docker_auth](docs/reference/docker_auth.md) |
| Postgres | Postgres services with optional S3 backups | [postgres](docs/reference/postgres.md) |
| Redis | Redis service instances | [redis](docs/reference/redis.md) |
| Service Links | Link postgres/redis services to apps | [plugins](docs/reference/plugins.md#linking-services-to-apps-appsapplinks) |
Expand Down
59 changes: 59 additions & 0 deletions docs/reference/docker_auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Docker Auth

Dokku docs: https://dokku.com/docs/deployment/registry-management/

Module: `src/modules/docker-auth.ts`

## YAML Keys

### Registry Login (`docker_auth.<server>`)

Authenticate the host docker daemon to one or more container registries. Useful when an app is deployed via `dokku git:from-image` against a private registry — without prior `docker login`, the daemon can't pull the image.

```yaml
docker_auth:
ghcr.io:
username: "${GHCR_USERNAME}"
password: "${GHCR_PAT}"

registry.example.com:
username: "ci-user"
password: "${REGISTRY_PASSWORD}"
```

On each `up` run, dokku-compose runs `dokku registry:login <server> <username> <password>` for every entry. Docker stores credentials in `~/.docker/config.json` and overwrites in place, so re-running is idempotent and rotation is just an `up` away.

| Field | Required | Description |
|-------|----------|-------------|
| `username` | yes | Registry username. For GHCR, the GitHub user the PAT is scoped to. |
| `password` | yes | Registry password or PAT. Use `${VAR}` interpolation and an `op://` reference rather than committing the value. |

## Distinct from `apps.<app>.registry`

This top-level `docker_auth:` is host-global pull authentication. It is **not** the same as the per-app `apps.<app>.registry:` block, which sets `registry:set <app>` properties (push destinations and image-build behavior). The two work together: `docker_auth` lets the daemon pull, `registry` configures where built images get pushed.

## Removal Semantics

Removing an entry from `docker_auth:` does **not** automatically log out — host credentials persist. To revoke, run `dokku registry:logout <server>` manually on the host. Same posture as `plugins:` (installed plugins aren't auto-removed).

## Secrets

Passwords are masked in `--dry-run` output (last 4 characters preserved). Pass `--sensitive` to dump the unmasked command list — useful for debugging, dangerous to copy/paste.

## Common Use Case: Private GHCR

```yaml
docker_auth:
ghcr.io:
username: "${GHCR_USERNAME}"
password: "${GHCR_PAT}"
```

With a `qlustr.env`-style file:

```
GHCR_USERNAME=op://strates-${APP_ENV}/github/user
GHCR_PAT=op://strates-${APP_ENV}/github/packages-pat
```

And `op run --env-file=qlustr.env -- dokku-compose up`, the values are resolved from 1Password at apply time and never written to disk on the host.
7 changes: 7 additions & 0 deletions dokku-compose.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ plugins:
url: https://github.com/dokku/dokku-letsencrypt.git
script: scripts/letsencrypt.sh # custom handler (not a service plugin)

# Authenticate host docker daemon to private registries (for `git:from-image`).
# See docs/reference/docker_auth.md.
docker_auth:
ghcr.io:
username: "${GHCR_USERNAME}"
password: "${GHCR_PAT}"

# Shared Docker networks for inter-app communication
networks:
- backend-net
Expand Down
4 changes: 3 additions & 1 deletion src/commands/up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Git } from '../resources/git.js'
import { Checks } from '../resources/checks.js'
import { Networks, NetworkProps } from '../resources/network.js'
import { ensurePlugins } from '../modules/plugins.js'
import { ensureDockerAuth } from '../modules/docker-auth.js'
import { ensureNetworks } from '../modules/network.js'
import { ensurePostgres, ensurePostgresBackups } from '../modules/postgres.js'
import { ensureRedis } from '../modules/redis.js'
Expand All @@ -31,8 +32,9 @@ export async function runUp(
? appFilter
: Object.keys(config.apps)

// Phase 1: Plugins
// Phase 1: Plugins & host-level auth
if (config.plugins) await ensurePlugins(ctx, config.plugins)
if (config.docker_auth) await ensureDockerAuth(ctx, config.docker_auth)

// Phase 2: Global config
if (config.domains !== undefined) await ensureGlobalDomains(ctx, config.domains)
Expand Down
15 changes: 15 additions & 0 deletions src/core/mask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,21 @@ describe('maskSensitiveArgs', () => {
expect(maskSensitiveArgs('apps:create myapp'))
.toBe('apps:create myapp')
})

it('masks positional password in registry:login', () => {
expect(maskSensitiveArgs('registry:login ghcr.io octocat ghp_abcdefgh'))
.toBe('registry:login ghcr.io octocat ****efgh')
})

it('masks short positional password in registry:login', () => {
expect(maskSensitiveArgs('registry:login ghcr.io octocat pw'))
.toBe('registry:login ghcr.io octocat ****')
})

it('does not affect other registry: subcommands', () => {
expect(maskSensitiveArgs('registry:set myapp server registry.example.com'))
.toBe('registry:set myapp server registry.example.com')
})
})

describe('maskSensitiveData', () => {
Expand Down
11 changes: 10 additions & 1 deletion src/core/mask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,18 @@ function maskValue(value: string): string {

/** Mask KEY=VALUE pairs in a command string */
export function maskSensitiveArgs(cmd: string): string {
return cmd.replace(/([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(\S+)/gi, (_, key, value) => {
let masked = cmd.replace(/([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(\S+)/gi, (_, key, value) => {
return `${key}=${maskValue(value)}`
})

// Special-case `registry:login <server> <username> <password>` — password is
// a positional arg, not KEY=VALUE, so the regex above can't catch it.
masked = masked.replace(
/^(registry:login\s+\S+\s+\S+\s+)(\S+)/,
(_, prefix, password) => `${prefix}${maskValue(password)}`
)

return masked
}

/** Deep-mask sensitive values in data structures (for diff/export output) */
Expand Down
27 changes: 27 additions & 0 deletions src/core/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,33 @@ describe('PostgresSchema backup', () => {
})
})

describe('docker_auth in schema', () => {
it('accepts docker_auth with username and password', () => {
const result = parseConfig({
apps: {},
docker_auth: {
'ghcr.io': { username: 'octocat', password: 'ghp_secret' },
},
})
expect(result.docker_auth?.['ghcr.io']?.username).toBe('octocat')
expect(result.docker_auth?.['ghcr.io']?.password).toBe('ghp_secret')
})

it('rejects docker_auth entry missing password', () => {
expect(() => parseConfig({
apps: {},
docker_auth: { 'ghcr.io': { username: 'octocat' } },
})).toThrow()
})

it('rejects docker_auth entry with empty password', () => {
expect(() => parseConfig({
apps: {},
docker_auth: { 'ghcr.io': { username: 'octocat', password: '' } },
})).toThrow()
})
})

describe('parseConfig', () => {
it('parses minimal config', () => {
const result = parseConfig({
Expand Down
7 changes: 7 additions & 0 deletions src/core/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,17 @@ const PluginSchema = z.object({
version: z.string().optional(),
})

const DockerAuthEntrySchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
})

export const ConfigSchema = z.object({
dokku: z.object({
version: z.string().optional(),
}).optional(),
plugins: z.record(z.string(), PluginSchema).optional(),
docker_auth: z.record(z.string(), DockerAuthEntrySchema).optional(),
networks: z.array(z.string()).optional(),
postgres: z.record(z.string(), PostgresSchema).optional(),
redis: z.record(z.string(), RedisSchema).optional(),
Expand All @@ -112,6 +118,7 @@ export type PostgresConfig = z.infer<typeof PostgresSchema>
export type RedisConfig = z.infer<typeof RedisSchema>
export type ServiceBackupConfig = z.infer<typeof ServiceBackupSchema>
export type PluginConfig = z.infer<typeof PluginSchema>
export type DockerAuthEntry = z.infer<typeof DockerAuthEntrySchema>
export type GitConfig = z.infer<typeof GitSchema>

export function parseConfig(raw: unknown): Config {
Expand Down
31 changes: 31 additions & 0 deletions src/modules/docker-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, it, expect, vi } from 'vitest'
import { createRunner } from '../core/dokku.js'
import { createContext } from '../core/context.js'
import { ensureDockerAuth } from './docker-auth.js'

describe('ensureDockerAuth', () => {
it('logs in to each configured registry', async () => {
const runner = createRunner({ dryRun: false })
runner.run = vi.fn()
const ctx = createContext(runner)
await ensureDockerAuth(ctx, {
'ghcr.io': { username: 'octocat', password: 'ghp_secret' },
'registry.example.com': { username: 'svc', password: 'pw' },
})
expect(runner.run).toHaveBeenCalledWith(
'registry:login', 'ghcr.io', 'octocat', 'ghp_secret'
)
expect(runner.run).toHaveBeenCalledWith(
'registry:login', 'registry.example.com', 'svc', 'pw'
)
expect(runner.run).toHaveBeenCalledTimes(2)
})

it('does nothing for an empty config', async () => {
const runner = createRunner({ dryRun: false })
runner.run = vi.fn()
const ctx = createContext(runner)
await ensureDockerAuth(ctx, {})
expect(runner.run).not.toHaveBeenCalled()
})
})
14 changes: 14 additions & 0 deletions src/modules/docker-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Context } from '../core/context.js'
import type { DockerAuthEntry } from '../core/schema.js'
import { logAction, logDone } from '../core/logger.js'

export async function ensureDockerAuth(
ctx: Context,
config: Record<string, DockerAuthEntry>
): Promise<void> {
for (const [server, creds] of Object.entries(config)) {
logAction('docker-auth', `Logging in to ${server} as ${creds.username}`)
await ctx.run('registry:login', server, creds.username, creds.password)
logDone()
}
}
5 changes: 5 additions & 0 deletions src/tests/fixtures/full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ plugins:
redis:
url: https://github.com/dokku/dokku-redis.git

docker_auth:
ghcr.io:
username: "ci-user"
password: "test-pat"

networks:
- studio-net
- qultr-net
Expand Down
Loading