Deploying to a Server You Can’t Reach: Building a CI/CD Pipeline with AWS SSM and OIDC

In modern software engineering, the evolution from manual server management to automated Continuous Integration and Continuous Delivery (CI/CD) pipelines represents a foundational shift in how applications are built, tested, and released. While automated deployments are standard practice for cloud-native architectures, engineering teams frequently encounter complex network topologies where standard deployment methodologies fail. A prime example of this challenge arises when target deployment environments reside in isolated, private subnets completely shielded from the public internet. Traditional deployment vectors—such as Secure Shell (SSH) access, public IP mapping, or intermediary bastion hosts—introduce significant attack surfaces, contravening the core tenets of modern zero-trust security frameworks.
Addressing this architectural bottleneck requires innovative integration between cloud infrastructure providers and version control systems. By combining infrastructure-as-code principles, short-lived authentication mechanisms, and native cloud management tools, developers can safely orchestrate deployments to unreachable servers without ever opening inbound ports or storing vulnerable credentials.
The Architectural Challenge: Isolation, Interdependence, and Security
Deploying multi-container applications to isolated cloud infrastructure introduces a triad of engineering hurdles. Understanding these constraints is essential for designing resilient automation pipelines.
The Inaccessibility Dilemma
In a typical cloud deployment, a CI/CD server or external runner utilizes an SSH key to log directly into a remote virtual machine, pull the latest source code, and restart application services. However, enterprise-grade architectures often mandate that compute instances—such as Amazon Web Services (AWS) EC2 instances—reside in private subnets. These servers lack public IP addresses, receive inbound traffic exclusively through internal application load balancers, and maintain no direct exposure to the public internet. Consequently, standard automation tools like GitHub Actions cannot initiate direct connections over port 22, forcing engineers to reconsider how code delivery is executed.
Complex Multi-Container Dependencies
Modern web applications rarely run as monolithic instances; instead, they rely on orchestrated stacks defined via configurations like Docker Compose. In a representative web application stack, services maintain strict, sequential dependency chains. For instance, a PostgreSQL database must complete its initialization phase and pass internal health checks before backend application services can safely start. Similarly, backend database migration tools, such as Alembic, will crash if they attempt to connect to an uninitialized data store. Furthermore, reverse proxies like Nginx will return HTTP 502 Bad Gateway errors if they route traffic before the underlying backend application containers are fully responsive. Consequently, an effective deployment pipeline cannot merely swap a single container; it must orchestrate a coordinated, health-verified startup sequence across multiple interdependent services.
The Credential Vulnerability Problem
The traditional reliance on static access keys stored in source control management secrets—such as long-lived AWS Access Key IDs and Secret Access Keys—presents a severe security vulnerability. Static credentials do not naturally expire; if compromised via an accidental log leak, a misconfigured workflow, or a security breach, they remain fully functional until manually revoked by an administrator. In personal projects or resource-constrained teams, this revocation window can stretch indefinitely. Eliminating static secrets in favor of dynamic, short-lived trust relationships has thus become an industry best practice.
Establishing Zero-Trust Authentication via IAM OIDC
To eliminate the security risks associated with long-lived static credentials, modern cloud architectures leverage OpenID Connect (OIDC) to establish temporary, cryptographically secure trust relationships between external CI/CD platforms and cloud providers.
The Mechanics of Web Identity Federation
OpenID Connect allows workflows running on platforms like GitHub Actions to request a short-lived JSON Web Token (JSON Web Token / JWT) directly from the version control provider’s token service. The pipeline then presents this token to the AWS Security Token Service (STS), asserting its identity: "I am repository X, running a specific workflow on the main branch."
AWS evaluates the token against a pre-configured IAM trust policy. Upon validating the cryptographic signature and claims, AWS issues temporary security credentials that automatically expire—typically within one hour. This mechanism ensures that no permanent credentials are stored within the repository secrets manager. If an OIDC token is intercepted, it becomes entirely obsolete within minutes, and its usage is strictly bound to the designated repository, preventing unauthorized access from forks or external organizations.
Implementation via Infrastructure as Code
Deploying this trust framework programmatically ensures consistency and reproducibility across environments. Utilizing Infrastructure-as-code tools such as Terraform, engineers can provision the OIDC identity provider and configure the corresponding IAM role with precise IAM conditions:
resource "aws_iam_openid_connect_provider" "github"
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [
"6938fd4d98bab03faadb97b34396831e3780aea1",
"1c58a3a8518e8759bf075b76b750d4f2df264fcd",
"06d927fecd0a84aeba28aad1d808139470fe95c3"
]
resource "aws_iam_role" "github_actions"
name = "production-github-actions-role"
assume_role_policy = jsonencode(
Version = "2012-10-17"
Statement = [
Effect = "Allow"
Principal =
Federated = aws_iam_openid_connect_provider.github.arn
Action = "sts:AssumeRoleWithWebIdentity"
Condition =
StringEquals =
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
StringLike =
"token.actions.githubusercontent.com:sub" = "repo:organization*/repository-name*"
]
)
By enforcing strict conditional boundaries on the sub (subject) claim using wildcard-supported string matching, cloud administrators ensure that only verified branches within authorized repositories can assume the deployment role.
Orchestrating Deployments with AWS Systems Manager (SSM)
With secure, keyless authentication established, the remaining challenge involves commanding an isolated, unreachable server to execute deployment scripts. AWS Systems Manager (SSM) provides a robust solution to this operational problem.
The Outbound-Only Communication Model
AWS Systems Manager relies on the SSM Agent, a lightweight software daemon that runs natively on supported Linux and Windows Amazon Machine Images (AMIs). The SSM Agent maintains a persistent outbound TLS connection over HTTPS (port 443) to the regional AWS SSM service endpoints. Because the connection is initiated from inside the private subnet outward to AWS, the EC2 instance requires no public IP address, no inbound open ports, and no bastion host.
When an external orchestrator—such as a GitHub Actions workflow authenticated via OIDC—issues a command through the AWS API, the SSM service relays the instruction down the existing outbound channel to the agent on the target instance. The instance executes the command locally and returns the output logs through the same secure channel. Furthermore, every execution is automatically logged within AWS CloudTrail, providing a comprehensive, immutable audit trail of all deployment activities.
Least-Privilege IAM Authorization for SSM
To maintain strict security isolation, the IAM role assumed by the CI/CD pipeline must adhere to the principle of least privilege. The execution role should lack permissions to create, modify, or delete core cloud infrastructure, being restricted exclusively to interacting with designated SSM documents and specific tagged instances:
resource "aws_iam_role_policy" "github_actions_ssm"
role = aws_iam_role.github_actions.id
policy = jsonencode(
Version = "2012-10-17"
Statement = [
Effect = "Allow"
Action = ["ec2:DescribeInstances"]
Resource = "*"
,
Effect = "Allow"
Action = ["ssm:SendCommand"]
Resource = ["arn:aws:ec2:us-east-1:123456789012:instance/*"]
Condition =
StringEquals =
"ssm:ResourceTag/Name" = "production-server"
,
Effect = "Allow"
Action = ["ssm:SendCommand"]
Resource = ["arn:aws:ssm:us-east-1::document/AWS-RunShellScript"]
,
Effect = "Allow"
Action = ["ssm:GetCommandInvocation"]
Resource = "arn:aws:ssm:us-east-1:123456789012:*"
]
)
Constructing the CI/CD Pipeline Workflow
A resilient production pipeline separates code validation (Continuous Integration) from environment updates (Continuous Delivery), ensuring that broken code never reaches downstream targets.
Part 1: The Continuous Integration (CI) Phase
The CI workflow triggers automatically upon every pull request and push to the primary branch. Its primary objective is to execute rapid syntax checks, linting protocols, and container build validations:
- Docker Compose Validation: Ensures that the multi-service container definitions parse correctly and build without syntax errors.
- Frontend Compilation: Sets up Node.js runtimes, installs project dependencies, and verifies that client-side assets compile cleanly.
- Backend Static Analysis: Installs Python dependencies and executes static linters (such as Flake8) to catch syntax errors, undefined variables, and basic code style violations before runtime.
Part 2: The Continuous Delivery (CD) Phase
Once code changes pass all integration checks, the CD workflow initiates the deployment sequence:
- Authentication: The pipeline requests an OIDC identity token from GitHub and exchanges it for temporary AWS credentials via the configured IAM role.
- Instance Discovery: The workflow queries the AWS EC2 API to identify the active target instance ID based on resource tags and running state.
- Command Dispatch: Utilizing the AWS CLI, the workflow invokes
ssm:SendCommand, directing the target instance to execute a localized deployment script under the appropriate service user account. - Asynchronous Polling: Because containerized builds and database migrations require extended execution times, the pipeline polls AWS SSM for command completion status, streams execution logs, and evaluates exit codes to confirm deployment success or failure.
Operational Best Practices for Production Environments
Maintaining long-term stability in automated deployment pipelines requires adherence to several critical operational safeguards:
- Enforce Idempotent Server States: Production servers should never accumulate manual configuration drifts. Deployment scripts must enforce absolute synchronization with the source repository (e.g., utilizing
git reset --hard origin/main), treating the target server purely as an ephemeral runtime environment. - Proactive Resource Pruning: Automated container builds generate substantial disk usage over time through intermediate image layers. Incorporating routine cleanup commands—such as
docker image prune -f—preventative maintenance protects EBS storage volumes from exhausting disk space during active deployment cycles. - Robust Error Handling: Deployment scripts executed via remote agents should enforce strict error-handling parameters (such as
set -euo pipefailin Bash) to ensure that any individual command failure immediately halts execution, returning a non-zero exit code to the orchestrator. - Complementary Runtime Monitoring: While a successfully executed pipeline confirms that code compiled and services started, it does not guarantee end-user application health. Automated pipelines must be paired with active external health checks and monitoring systems to rapidly identify runtime regressions, unhandled exceptions, or load balancer target group failures.
Conclusion
Building a secure, automated CI/CD pipeline for infrastructure residing in private subnets demonstrates that stringent security requirements do not need to come at the expense of engineering velocity. By integrating OpenID Connect for keyless authentication and leveraging AWS Systems Manager to communicate across isolated network boundaries, engineering teams can achieve robust, zero-trust continuous deployment without opening vulnerable inbound ports, deploying bastion hosts, or managing permanent cloud credentials.







