Skip to content

Commit 7545420

Browse files
committed
Refactor Docker registry parameters for consistency and clarity in deployment workflow
Enhance README with detailed usage scenarios and examples for SSH deployment Update DockerSSHPipeline to streamline deployment steps and remove unnecessary dependencies
1 parent 5d571e4 commit 7545420

6 files changed

Lines changed: 350 additions & 259 deletions

File tree

.github/workflows/deploy-Production.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ jobs:
3939
env:
4040
DockerSSH__TargetHost: ${{ secrets.TARGET_HOST }}
4141
DockerSSH__SshUsername: ${{ secrets.SSH_USERNAME }}
42-
Parameters__registry_endpoint: ghcr.io
43-
Parameters__registry_repository: ${{ github.repository }}
44-
Parameters__registry_username: ${{ github.actor }}
45-
Parameters__registry_password: ${{ secrets.GITHUB_TOKEN }}
42+
DockerRegistry__RegistryUrl: ghcr.io
43+
DockerRegistry__RepositoryPrefix: ${{ github.repository }}
44+
DockerRegistry__RegistryUsername: ${{ github.actor }}
45+
DockerRegistry__RegistryPassword: ${{ secrets.GITHUB_TOKEN }}
4646
IMAGE_TAG_SUFFIX: build.${{ github.run_number }}.${{ env.SHORT_SHA }}
4747
Parameters__p: ${{ vars.PARAMETERS_P }}
4848
Parameters__cache-password: ${{ secrets.PARAMETERS_CACHE_PASSWORD }}

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,41 @@ flowchart LR
1313
A[Dev Machine / CI<br/>aspire deploy] -->|SSH| B[Target Server<br/>docker compose up]
1414
```
1515

16+
## How It Works
17+
18+
The deployment pipeline executes in phases, with steps running in parallel where possible:
19+
20+
```mermaid
21+
flowchart TD
22+
subgraph Configure
23+
A[Establish SSH] --> B[Configure Deployment]
24+
C[Process Parameters] --> D[Build Prerequisites]
25+
end
26+
27+
subgraph Build & Push
28+
D --> E[Build Images]
29+
E --> F[Push to Registry]
30+
end
31+
32+
subgraph Deploy
33+
F --> G[Prepare Compose Files]
34+
G --> H[Transfer Files via SCP]
35+
H --> I[docker compose up]
36+
I --> J[Health Checks]
37+
end
38+
39+
J --> K[Done]
40+
```
41+
42+
**Phase breakdown:**
43+
1. **Configure** - Establish SSH connection, gather parameters (registry, credentials, deploy path)
44+
2. **Build** - Build container images for each project in parallel
45+
3. **Push** - Push images to the configured container registry
46+
4. **Deploy** - Transfer compose files and `.env` to the remote server, run `docker compose up`
47+
5. **Verify** - Run health checks, extract dashboard token, cleanup SSH
48+
49+
Run `aspire do diagnostics` to see the full dependency graph for your application.
50+
1651
## Quick Start
1752

1853
1. Add the package feed:

samples/DockerPipelinesSample/DockerPipelinesSample.AppHost/AppHost.cs

Lines changed: 19 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,22 @@
11
#pragma warning disable ASPIREPIPELINES003 // WithDeploymentImageTag is experimental
2-
#pragma warning disable ASPIRECOMPUTE003 // ContainerRegistryResource is experimental
32

4-
// =============================================================================
53
// Sample: SSH Deployment to Remote Docker Host
6-
// =============================================================================
4+
// Demonstrates: custom image tagging, YARP reverse proxy, optional HTTPS with Let's Encrypt
75
//
8-
// This sample demonstrates deploying an Aspire application to a remote Docker
9-
// host via SSH with the following features:
10-
//
11-
// - Custom image tagging from CI/CD pipelines
12-
// - Container registry integration with automatic login
13-
// - YARP reverse proxy as the public entry point
14-
// - Optional HTTPS with automatic Let's Encrypt certificate generation
15-
//
16-
// Configuration (appsettings.json or environment variables):
17-
// ----------------------------------------------------------
18-
// - EnableHttps: Set to "true" to enable HTTPS with Let's Encrypt certificates.
19-
// When false (default), only HTTP on port 80 is exposed.
20-
//
21-
// Required parameters for deployment:
22-
// - Parameters__registry-endpoint: Registry URL (e.g., "registry.digitalocean.com")
23-
// - Parameters__registry-repository: Repository prefix (e.g., "my-project")
24-
// - Parameters__registry-username: Registry username
25-
// - Parameters__registry-password: Registry password (secret)
26-
//
27-
// Required parameters when EnableHttps=true:
28-
// - Parameters__domain: The domain name for the certificate (e.g., "example.com")
29-
// - Parameters__letsencrypt_email: Email for Let's Encrypt registration/notifications
30-
//
31-
// How container registry integration works:
32-
// -----------------------------------------
33-
// 1. A ContainerRegistryResource is created with endpoint and repository
34-
// 2. WithCredentialsLogin adds a login step that runs before push-prereq
35-
// 3. Built-in push steps (push-apiservice, push-webfrontend) push to the registry
36-
// 4. Remote deployment authenticates with the same credentials to pull images
37-
//
38-
// How HTTPS works:
39-
// ----------------
40-
// When EnableHttps is true, a certbot container is added that:
41-
// 1. Runs the Let's Encrypt ACME HTTP-01 challenge on port 80
42-
// 2. Obtains/renews certificates and stores them in a shared Docker volume
43-
// 3. Sets permissions so non-root containers can read the certificates
44-
// 4. Exits after certificate generation
45-
//
46-
// YARP then starts (after certbot completes) and:
47-
// - Mounts the shared certificate volume (read-only)
48-
// - Serves HTTPS on port 443 using the Let's Encrypt certificates
49-
// - Serves HTTP on port 80
50-
//
51-
// On subsequent deployments, certbot skips certificate generation if valid
52-
// certificates already exist (--keep-until-expiring flag).
53-
//
54-
// =============================================================================
6+
// Key settings:
7+
// - IMAGE_TAG_SUFFIX: Custom image tag (e.g., "build.42.abc1234")
8+
// - EnableHttps: Enable HTTPS with Let's Encrypt (requires domain + email parameters)
9+
// - DockerRegistry__*: Registry URL, prefix, and optional credentials
5510

5611
var builder = DistributedApplication.CreateBuilder(args);
5712

58-
// Custom image tag from CI/CD (e.g., IMAGE_TAG_SUFFIX=build.42.abc1234)
5913
var imageTag = builder.Configuration["IMAGE_TAG_SUFFIX"];
6014

61-
// Container registry configuration for push/pull operations
62-
// These parameters are prompted during deployment or can be set via config
63-
var registryEndpoint = builder.AddParameter("registry-endpoint");
64-
var registryRepository = builder.AddParameter("registry-repository");
65-
66-
var registryUsername = builder.AddParameter("registry-username");
67-
var registryPassword = builder.AddParameter("registry-password", secret: true);
68-
69-
// Create container registry with automatic login
70-
// This creates a login-to-registry step that runs before built-in push steps
71-
var registry = builder.AddContainerRegistry("registry", registryEndpoint, registryRepository)
72-
.WithCredentialsLogin(registryUsername, registryPassword);
73-
74-
// Configure Docker Compose environment with SSH deployment support
15+
// Configure Docker Compose environment with SSH deployment
16+
// Registry is automatically configured from DockerRegistry:* settings
7517
builder.AddDockerComposeEnvironment("env")
7618
.WithDashboard(db => db.WithHostPort(8085))
77-
.WithSshDeploySupport()
78-
.WithContainerRegistry(registry);
19+
.WithSshDeploySupport();
7920

8021
var p = builder.AddParameter("p");
8122

@@ -99,7 +40,7 @@
9940
c.AddRoute(frontend);
10041
});
10142

102-
// Apply the image tag to all services if specified
43+
// Apply custom image tag from CI/CD to all services
10344
if (!string.IsNullOrEmpty(imageTag))
10445
{
10546
apiService.WithImagePushOptions(c => c.Options.RemoteImageTag = imageTag);
@@ -113,28 +54,18 @@
11354

11455
if (enableHttps)
11556
{
116-
// Let's Encrypt parameters (required when EnableHttps=true)
117-
// Set via: Parameters__domain and Parameters__letsencrypt_email
11857
var domain = builder.AddParameter("domain");
11958
var letsEncryptEmail = builder.AddParameter("letsencrypt-email");
12059

121-
// Certbot container for automatic Let's Encrypt certificate generation
122-
// Uses the official certbot/certbot image to obtain and renew certificates.
123-
//
124-
// Certbot arguments explained:
125-
// - certonly: Only obtain the certificate, don't install it
126-
// - --standalone: Run a temporary webserver for the ACME HTTP-01 challenge
127-
// - --non-interactive: Don't prompt for user input
128-
// - --agree-tos: Agree to Let's Encrypt Terms of Service
129-
// - --keep-until-expiring: Skip if cert exists and is not within 30 days of expiry
130-
// - --deploy-hook: Command to run after successful cert issuance (fix permissions)
131-
// - --email: Email for urgent renewal/security notices
132-
// - -d: Domain name for the certificate
133-
//
60+
// Certbot container for automatic Let's Encrypt certificate generation.
61+
// How it works:
62+
// 1. Certbot starts and binds to port 80 for the ACME HTTP-01 challenge
63+
// 2. Let's Encrypt verifies domain ownership by requesting /.well-known/acme-challenge/
64+
// 3. Certificates are stored in a shared Docker volume (/etc/letsencrypt)
65+
// 4. Certbot fixes permissions so non-root containers can read the certs, then exits
66+
// 5. On subsequent deploys, --keep-until-expiring skips renewal if certs are still valid
13467
var certbot = builder.AddContainer("certbot", "certbot/certbot")
135-
// Shared volume for certificates - both certbot and YARP mount this
13668
.WithVolume("letsencrypt", "/etc/letsencrypt")
137-
// Port 80 must be published to host for Let's Encrypt to reach the ACME challenge
13869
.WithHttpEndpoint(port: 80, targetPort: 80)
13970
.WithExternalHttpEndpoints()
14071
.WithArgs(context =>
@@ -145,7 +76,6 @@
14576
context.Args.Add("--agree-tos");
14677
context.Args.Add("-v");
14778
context.Args.Add("--keep-until-expiring");
148-
// Fix permissions so non-root containers (like YARP) can read the certs
14979
context.Args.Add("--deploy-hook");
15080
context.Args.Add("chmod -R 755 /etc/letsencrypt/live && chmod -R 755 /etc/letsencrypt/archive");
15181
context.Args.Add("--email");
@@ -154,28 +84,21 @@
15484
context.Args.Add(domain.Resource);
15585
});
15686

157-
// Certbot and YARP both need port 80, but at different times:
158-
// - Certbot needs port 80 during the ACME challenge (runs first, then exits)
159-
// - YARP needs port 80 for HTTP traffic (starts after certbot completes)
160-
// WaitForCompletion ensures YARP doesn't start until certbot exits successfully
87+
// YARP waits for certbot to complete since both need port 80.
88+
// Certbot runs the ACME challenge first, then YARP takes over for traffic.
16189
yarp.WaitForCompletion(certbot);
162-
16390
yarp.WithHostPort(80);
16491
yarp.WithHttpsEndpoint(443);
165-
166-
// Mount the shared certificate volume (read-only since YARP only reads certs)
16792
yarp.WithVolume("letsencrypt", "/etc/letsencrypt", isReadOnly: true);
16893

16994
// Configure Kestrel to use the Let's Encrypt certificates
170-
// Certbot stores certs at: /etc/letsencrypt/live/{domain}/
17195
yarp.WithEnvironment(context =>
17296
{
17397
context.EnvironmentVariables["Kestrel__Certificates__Default__Path"] =
17498
ReferenceExpression.Create($"/etc/letsencrypt/live/{domain}/fullchain.pem");
17599
context.EnvironmentVariables["Kestrel__Certificates__Default__KeyPath"] =
176100
ReferenceExpression.Create($"/etc/letsencrypt/live/{domain}/privkey.pem");
177101

178-
// Configure URLs for both HTTP and HTTPS
179102
var httpEndpoint = yarp.GetEndpoint("http");
180103
var httpsEndpoint = yarp.GetEndpoint("https");
181104

@@ -189,7 +112,7 @@
189112
}
190113
else
191114
{
192-
// HTTP only
115+
// HTTP only mode - expose YARP on port 80
193116
yarp.WithHostPort(80);
194117
}
195118

src/Aspire.Hosting.Docker.SshDeploy/DockerPipelineExtensions.cs

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
#pragma warning disable ASPIREPIPELINES004
2+
#pragma warning disable ASPIRECOMPUTE003
23

34
using Aspire.Hosting.ApplicationModel;
45
using Aspire.Hosting.Docker;
56
using Aspire.Hosting.Docker.SshDeploy.Abstractions;
67
using Aspire.Hosting.Docker.SshDeploy.Infrastructure;
78
using Aspire.Hosting.Docker.SshDeploy.Services;
9+
using Aspire.Hosting.Eventing;
10+
using Aspire.Hosting.Lifecycle;
811
using Aspire.Hosting.Pipelines;
912
using Aspire.Hosting.Utils;
1013
using Microsoft.Extensions.Configuration;
@@ -65,20 +68,22 @@ public static class DockerPipelineExtensions
6568
public static IResourceBuilder<DockerComposeEnvironmentResource> WithSshDeploySupport(
6669
this IResourceBuilder<DockerComposeEnvironmentResource> resourceBuilder)
6770
{
71+
var builder = resourceBuilder.ApplicationBuilder;
72+
6873
// Register infrastructure services (shared across all environments)
69-
resourceBuilder.ApplicationBuilder.Services.TryAddSingleton<IProcessExecutor, ProcessExecutor>();
70-
resourceBuilder.ApplicationBuilder.Services.TryAddSingleton<IFileSystem, FileSystemAdapter>();
71-
resourceBuilder.ApplicationBuilder.Services.TryAddSingleton<DockerCommandExecutor>();
72-
resourceBuilder.ApplicationBuilder.Services.TryAddSingleton<SSHConfigurationDiscovery>();
73-
resourceBuilder.ApplicationBuilder.Services.TryAddSingleton<GitHubActionsGeneratorService>();
74-
resourceBuilder.ApplicationBuilder.Services.TryAddSingleton<ISshKeyDiscoveryService, SshKeyDiscoveryService>();
74+
builder.Services.TryAddSingleton<IProcessExecutor, ProcessExecutor>();
75+
builder.Services.TryAddSingleton<IFileSystem, FileSystemAdapter>();
76+
builder.Services.TryAddSingleton<DockerCommandExecutor>();
77+
builder.Services.TryAddSingleton<SSHConfigurationDiscovery>();
78+
builder.Services.TryAddSingleton<GitHubActionsGeneratorService>();
79+
builder.Services.TryAddSingleton<ISshKeyDiscoveryService, SshKeyDiscoveryService>();
7580

7681
// Register both SSH connection factory implementations
77-
resourceBuilder.ApplicationBuilder.Services.TryAddSingleton<NativeSSHConnectionFactory>();
78-
resourceBuilder.ApplicationBuilder.Services.TryAddSingleton<SSHNetConnectionFactory>();
82+
builder.Services.TryAddSingleton<NativeSSHConnectionFactory>();
83+
builder.Services.TryAddSingleton<SSHNetConnectionFactory>();
7984

8085
// Register the ISSHConnectionFactory interface - selects native ssh by default, SSH.NET as fallback
81-
resourceBuilder.ApplicationBuilder.Services.TryAddSingleton<ISSHConnectionFactory>(sp =>
86+
builder.Services.TryAddSingleton<ISSHConnectionFactory>(sp =>
8287
{
8388
var config = sp.GetRequiredService<IConfiguration>();
8489
var useLegacy = config.GetValue<bool>("DockerSSH:UseLegacySshNet", false);
@@ -92,7 +97,7 @@ public static IResourceBuilder<DockerComposeEnvironmentResource> WithSshDeploySu
9297
});
9398

9499
// Register DockerSSHPipeline as a keyed service (one per resource)
95-
resourceBuilder.ApplicationBuilder.Services.AddKeyedSingleton(
100+
builder.Services.AddKeyedSingleton(
96101
resourceBuilder.Resource,
97102
(sp, key) => new DockerSSHPipeline(
98103
(DockerComposeEnvironmentResource)key,
@@ -106,6 +111,63 @@ public static IResourceBuilder<DockerComposeEnvironmentResource> WithSshDeploySu
106111
sp.GetRequiredService<IHostEnvironment>(),
107112
sp.GetRequiredService<ILoggerFactory>()));
108113

114+
// Only configure registry in publish mode to avoid prompts during run mode
115+
if (builder.ExecutionContext.IsPublishMode)
116+
{
117+
// Create default registry with parameters upfront
118+
var config = builder.Configuration;
119+
var registryUrlConfig = config["DockerRegistry:RegistryUrl"];
120+
var repositoryPrefixConfig = config["DockerRegistry:RepositoryPrefix"];
121+
var registryUsername = config["DockerRegistry:RegistryUsername"];
122+
var registryPassword = config["DockerRegistry:RegistryPassword"];
123+
124+
var registryUrlParam = string.IsNullOrEmpty(registryUrlConfig)
125+
? builder.AddParameter($"registryUrl")
126+
: builder.AddParameter($"registryUrl", registryUrlConfig);
127+
128+
var repositoryPrefixParam = string.IsNullOrEmpty(repositoryPrefixConfig)
129+
? builder.AddParameter($"repositoryPrefix")
130+
: builder.AddParameter($"repositoryPrefix", repositoryPrefixConfig);
131+
132+
var defaultRegistry = builder.AddContainerRegistry(
133+
$"default-registry-{resourceBuilder.Resource.Name}",
134+
registryUrlParam,
135+
repositoryPrefixParam);
136+
137+
// Add credentials login only if both username and password are configured
138+
IResourceBuilder<ParameterResource>? usernameParam = null;
139+
IResourceBuilder<ParameterResource>? passwordParam = null;
140+
if (!string.IsNullOrEmpty(registryUsername) && !string.IsNullOrEmpty(registryPassword))
141+
{
142+
usernameParam = builder.AddParameter($"default-registry-username-{resourceBuilder.Resource.Name}", registryUsername);
143+
passwordParam = builder.AddParameter($"default-registry-password-{resourceBuilder.Resource.Name}", registryPassword, secret: true);
144+
145+
defaultRegistry.WithCredentialsLogin(usernameParam, passwordParam);
146+
}
147+
148+
// Subscribe to BeforeStartEvent to attach or remove default registry based on user configuration
149+
var dockerEnvResource = resourceBuilder.Resource;
150+
builder.Eventing.Subscribe<BeforeStartEvent>((@event, ct) =>
151+
{
152+
// Check if user already attached a registry via WithContainerRegistry()
153+
if (dockerEnvResource.TryGetLastAnnotation<ContainerRegistryReferenceAnnotation>(out _))
154+
{
155+
// User specified their own registry - remove our default resources
156+
builder.Resources.Remove(defaultRegistry.Resource);
157+
builder.Resources.Remove(registryUrlParam.Resource);
158+
builder.Resources.Remove(repositoryPrefixParam.Resource);
159+
if (usernameParam != null) builder.Resources.Remove(usernameParam.Resource);
160+
if (passwordParam != null) builder.Resources.Remove(passwordParam.Resource);
161+
return Task.CompletedTask;
162+
}
163+
164+
// Attach the default registry
165+
dockerEnvResource.Annotations.Add(new ContainerRegistryReferenceAnnotation(defaultRegistry.Resource));
166+
167+
return Task.CompletedTask;
168+
});
169+
}
170+
109171
return resourceBuilder.WithPipelineStepFactory(context =>
110172
{
111173
var pipeline = context.PipelineContext.Services.GetRequiredKeyedService<DockerSSHPipeline>(resourceBuilder.Resource);

src/Aspire.Hosting.Docker.SshDeploy/DockerSSHPipeline.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,26 @@ public IEnumerable<PipelineStep> CreateSteps(PipelineStepFactoryContext context)
131131

132132
public Task ConfigurePipelineAsync(PipelineConfigurationContext context)
133133
{
134-
var dockerComposeUpStep = context.Steps.FirstOrDefault(s => s.Name == $"docker-compose-up-{DockerComposeEnvironment.Name}");
134+
var dockerComposeUpStepName = $"docker-compose-up-{DockerComposeEnvironment.Name}";
135+
var sshDeployStepName = $"deploy-docker-ssh-{DockerComposeEnvironment.Name}";
136+
137+
var dockerComposeUpStep = context.Steps.FirstOrDefault(s => s.Name == dockerComposeUpStepName);
135138
var deployStep = context.Steps.FirstOrDefault(s => s.Name == WellKnownPipelineSteps.Deploy);
136139
var prepareStep = context.Steps.FirstOrDefault(s => s.Name == $"prepare-{DockerComposeEnvironment.Name}");
137140

138-
// Remove docker compose up from the deployment pipeline
139-
// not needed for SSH deployment
140-
deployStep?.DependsOnSteps.Remove($"docker-compose-up-{DockerComposeEnvironment.Name}");
141+
// Remove docker compose up from the deployment pipeline - not needed for SSH deployment
142+
deployStep?.DependsOnSteps.Remove(dockerComposeUpStepName);
141143
dockerComposeUpStep?.RequiredBySteps.Remove(WellKnownPipelineSteps.Deploy);
144+
dockerComposeUpStep?.DependsOnSteps.Clear();
145+
dockerComposeUpStep?.RequiredBySteps.Clear();
146+
147+
// Remove print-summary steps from the deploy graph - they're for local output only
148+
foreach (var step in context.Steps.Where(s => s.Tags.Contains("print-summary")))
149+
{
150+
step.DependsOnSteps.Clear();
151+
step.RequiredBySteps.Clear();
152+
deployStep?.DependsOnSteps.Remove(step.Name);
153+
}
142154

143155
// Make the built-in prepare step depend on our prerequisites check
144156
// This ensures Docker is available before building images

0 commit comments

Comments
 (0)