First Container Orchestration Deployment (k3s)
Gerald went to a restaurant technology expo and came back saying “containers are the future.” He means shipping containers. But the point still stands. The expo had a booth about Kubernetes, and Gerald picked up a brochure. He now wants “orchestration” for the three-location website expansion. You asked him what orchestration means to him. He said, “Like an orchestra. But for websites.”
Docker Compose handles a lot for you on a single server: it can even restart crashed containers automatically with a restart policy. But it only runs on one machine. What happens when Gerald’s website needs to run across multiple servers, or you need to push an update without taking the site down, or a whole node fails? Kubernetes (often abbreviated k8s) solves this by acting as an orchestrator: a system that continuously monitors your workloads, replaces failed containers, manages multi-service networking, and rolls out updates without downtime. In this lab, you will install k3s, a lightweight Kubernetes distribution designed for resource-constrained environments, and deploy a WordPress site served through an nginx reverse proxy using Kubernetes primitives: Deployments, Services, ConfigMaps, and Secrets.
Before You Start
Section titled “Before You Start”You need:
- An AWS Academy Learner Lab environment
- An SSH client on your laptop
Why Kubernetes?
Section titled “Why Kubernetes?”When you used Docker Compose, it handled restarting crashed containers and wiring up networks on a single host. Kubernetes does the same thing, but across a cluster of machines, and it adds a control loop: you declare “I want 2 copies of WordPress running at all times,” and Kubernetes continuously compares desired state to actual state, making corrections automatically. A container crashes? Kubernetes starts a new one. You push a new image? Kubernetes rolls out the update gracefully, one Pod at a time, so the site stays up.
k3s vs. full Kubernetes: Standard Kubernetes is complex and resource-intensive. k3s is a certified Kubernetes distribution that strips out cloud-provider-specific components and bundles everything into a single binary. It runs comfortably on a t3.small instance and is ideal for learning, edge computing, and single-node deployments.
Questions
Section titled “Questions”Watch for the answers to these questions as you follow the tutorial.
- What k3s version is running on your node? Write down the node’s INTERNAL-IP address. (Use
kubectl get nodes -o wide.) (2 points) - Write down the names and IP addresses of your two WordPress Pods. (Use
kubectl get pods -o wide.) (2 points) - Run
kubectl get secret db-secret -o jsonpath='{.data.db-password}' | base64 --decode. What does it print? What does this tell you about how Secrets store values, and why base64 encoding is not the same as encryption? (3 points) - After configuring nginx as a reverse proxy and the Ingress, what does
curl -L http://localhostreturn? Describe the redirect chain: what responds first, what does it redirect to, and what serves the final response? (3 points) - After deleting one WordPress Pod, how many seconds did it take for Kubernetes to create a replacement? What does this demonstrate about Deployments? (2 points)
- How does nginx’s
proxy_passdirective reference WordPress: by IP address or by DNS name? Why does this matter when Pods restart and get new IP addresses? (2 points) - When you first encountered the
?reauth=1redirect loop, what command showed that noWORDPRESS_AUTH_KEYenvironment variables were set in the Pod? What is the root cause of the loop with two replicas? (3 points) - After applying the updated Secret and Deployment, what did
kubectl rollout status deployment/wordpressreport, and what does that confirm about the update? (3 points) - Get your TA’s initials showing the WordPress admin dashboard (past the login screen) accessible via nginx in a browser. (5 points)
Tutorial
Section titled “Tutorial”Installing k3s
Section titled “Installing k3s”-
Launch an EC2 instance
In the AWS Console, launch a SUSE Linux Enterprise Server 16 instance (available in the Quick Start AMIs). Use t3.small (2 vCPU, 2 GiB RAM); k3s runs on t3.micro but performs better with a bit more memory. Create a Security Group that allows:
- SSH (port 22) from Anywhere
- HTTP (port 80) from Anywhere
Connect via SSH. SLES uses
ec2-useras the default username:Terminal window ssh -i ~/Downloads/cs312-key.pem ec2-user@<your-public-ip>You are now on a SUSE Linux Enterprise Server. k3s is a Rancher project, and Rancher was acquired by SUSE in 2020, so you are running k3s on its home operating system. The package manager here is
zypperrather thanapt, though you will not need it much in these labs since k3s and all workloads install via scripts and container images. -
Install k3s
k3s installs with a single command:
Terminal window curl -sfL https://get.k3s.io | sh -This downloads the k3s binary, installs it as a systemd service, and starts it immediately. It also installs
kubectl, the Kubernetes command-line tool. -
Configure kubectl access
By default, k3s writes its configuration to
/etc/rancher/k3s/k3s.yaml, which is only readable by root. To usekubectlwithoutsudo:Terminal window mkdir -p ~/.kubesudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/configsudo chown $(id -u):$(id -g) ~/.kube/configexport KUBECONFIG=~/.kube/configecho 'export KUBECONFIG=~/.kube/config' >> ~/.bashrc -
Verify the cluster
Terminal window kubectl get nodes -o wideYou should see one node with status “Ready.” Record the k3s version (in the VERSION column) and the INTERNAL-IP for your lab questions.
Understanding Kubernetes Primitives
Section titled “Understanding Kubernetes Primitives”Before writing manifests, let’s understand the building blocks:
- Pod: The smallest unit in Kubernetes. A Pod runs one or more containers. You rarely create Pods directly.
- Deployment: Manages a set of identical Pods. You tell it “run 2 replicas of WordPress,” and it ensures exactly 2 Pods are always running. If one dies, the Deployment creates a replacement.
- Service: Provides a stable network endpoint for a set of Pods. Pods get random IP addresses that change when they restart, but a Service gives you a fixed DNS name and IP to reach them.
- ConfigMap: Stores configuration data (like files or environment variables) separately from the container image. This lets you change configuration without rebuilding the image.
- Secret: Like a ConfigMap, but for sensitive data such as passwords and API keys. Values are base64-encoded, not encrypted: anyone who can read the Secret object can decode them instantly with
base64 --decode. The value of Secrets is not confidentiality against cluster access, but rather keeping credentials out of your manifest files and Git history, and enabling RBAC to restrict which Pods and users can read which Secrets. In production, encrypted storage requires tools like HashiCorp Vault or the AWS Secrets Manager integration, which are outside the scope of this lab.
Storing Credentials with Secrets
Section titled “Storing Credentials with Secrets”MariaDB needs a password. Hardcoding it in a manifest is bad practice; it ends up in your version control. Instead, you will store it in a Kubernetes Secret and reference it by name from other manifests.
-
Create a project directory and write the Secret
Terminal window mkdir ~/k8s-lab && cd ~/k8s-labvim db-secret.yamlapiVersion: v1kind: Secretmetadata:name: db-secrettype: OpaquestringData:db-root-password: rootpassworddb-password: wordpresspasswordUsing
stringDatalets you write plain text; Kubernetes base64-encodes the values automatically when storing the Secret. -
Apply the Secret
Terminal window kubectl apply -f db-secret.yaml -
Inspect the stored value
Terminal window kubectl get secret db-secret -o jsonpath='{.data.db-password}' | base64 --decodeThe value is stored encoded, but you can decode it with standard base64 tooling. Record this output for your lab questions.
Deploying MariaDB
Section titled “Deploying MariaDB”MariaDB needs a stable DNS name so WordPress can reach it. You will create a Deployment and a ClusterIP Service (internal-only, with no NodePort) for MariaDB.
-
Write the MariaDB Deployment
Terminal window vim mariadb-deployment.yamlapiVersion: apps/v1kind: Deploymentmetadata:name: mariadblabels:app: mariadbspec:replicas: 1selector:matchLabels:app: mariadbtemplate:metadata:labels:app: mariadbspec:containers:- name: mariadbimage: mariadb:11env:- name: MYSQL_ROOT_PASSWORDvalueFrom:secretKeyRef:name: db-secretkey: db-root-password- name: MYSQL_DATABASEvalue: wordpress- name: MYSQL_USERvalue: wordpress- name: MYSQL_PASSWORDvalueFrom:secretKeyRef:name: db-secretkey: db-passwordports:- containerPort: 3306The
secretKeyReffields pull values fromdb-secretat runtime. The password never appears in plain text in the manifest. -
Write the MariaDB Service
Terminal window vim mariadb-service.yamlapiVersion: v1kind: Servicemetadata:name: mariadb-servicespec:type: ClusterIPselector:app: mariadbports:- port: 3306targetPort: 3306ClusterIPmeans this Service is only reachable from inside the cluster; MariaDB should never be directly exposed to the internet. -
Apply both manifests
Terminal window kubectl apply -f mariadb-deployment.yaml -f mariadb-service.yaml -
Wait for MariaDB to be ready
Terminal window kubectl get pods -l app=mariadbWait until the MariaDB Pod shows “Running.” MariaDB takes 20-30 seconds to initialize on first run. If the Pod shows “CrashLoopBackOff,” wait another 30 seconds and check again; it typically self-corrects as MariaDB finishes its initialization sequence.
Deploying WordPress
Section titled “Deploying WordPress”WordPress connects to MariaDB using the Service DNS name mariadb-service, which Kubernetes resolves to the MariaDB Pod’s IP automatically. This is why Services are essential: if WordPress referenced a Pod IP directly, that IP would change every time the MariaDB Pod restarted.
-
Write the WordPress Deployment
Terminal window vim wordpress-deployment.yamlapiVersion: apps/v1kind: Deploymentmetadata:name: wordpresslabels:app: wordpressspec:replicas: 2selector:matchLabels:app: wordpresstemplate:metadata:labels:app: wordpressspec:containers:- name: wordpressimage: wordpress:6.5.5-php8.3-apacheenv:- name: WORDPRESS_DB_HOSTvalue: mariadb-service:3306- name: WORDPRESS_DB_NAMEvalue: wordpress- name: WORDPRESS_DB_USERvalue: wordpress- name: WORDPRESS_DB_PASSWORDvalueFrom:secretKeyRef:name: db-secretkey: db-password- name: WORDPRESS_CONFIG_EXTRAvalue: "define('WP_AUTO_UPDATE_CORE', false);"ports:- containerPort: 80 -
Write the WordPress Service
WordPress will not be exposed directly to the internet (nginx will sit in front of it), so a ClusterIP Service is sufficient.
Terminal window vim wordpress-service.yamlapiVersion: v1kind: Servicemetadata:name: wordpress-servicespec:type: ClusterIPselector:app: wordpressports:- port: 80targetPort: 80 -
Apply both manifests
Terminal window kubectl apply -f wordpress-deployment.yaml -f wordpress-service.yaml -
Verify the WordPress Pods
Terminal window kubectl get pods -o wideYou should see 2 WordPress Pods with status “Running” alongside the MariaDB Pod. Note the names and IP addresses of both WordPress Pods for your lab questions.
Deploying nginx as a Reverse Proxy
Section titled “Deploying nginx as a Reverse Proxy”Traefik is k3s’s built-in ingress controller and is the component actually listening on public port 80. Strictly speaking, you could route Traefik directly to wordpress-service and skip nginx entirely. We are keeping nginx in front of WordPress on purpose because it gives you an explicit reverse-proxy layer to configure yourself: you can see proxy_pass, forwarded headers, and service-to-service DNS in action, and in Lab 8 you will use that same nginx layer to practice probes, resource controls, and rollouts without changing the WordPress application container. The proxy configuration lives in a ConfigMap, so you can update routing rules without rebuilding the nginx image.
The WordPress image used in this lab (wordpress:6.5.5-php8.3-apache) runs Apache internally as the PHP application server. nginx’s role here is purely as a reverse proxy: it accepts the request from Traefik, forwards it to WordPress over the cluster network, and returns the response. The request flow is:
flowchart TB
browser[Browser]
subgraph edge[Cluster Edge]
traefik[Traefik Ingress\npublic HTTP on port 80]
end
subgraph proxy[Reverse Proxy Layer]
nginxSvc[nginx-service\nClusterIP Service]
nginxPod[nginx Pod\nproxy_pass + forwarded headers]
nginxSvc --> nginxPod
end
subgraph app[Application Layer]
wpSvc[wordpress-service\nClusterIP Service]
wpPod1[WordPress Pod A\nApache + PHP]
wpPod2[WordPress Pod B\nApache + PHP]
wpSvc --> wpPod1
wpSvc --> wpPod2
end
subgraph data[Data Layer]
mariadbSvc[mariadb-service\nClusterIP Service]
mariadbPod[MariaDB Pod]
mariadbSvc --> mariadbPod
end
browser -->|HTTP request| traefik
traefik -->|Ingress routes /| nginxSvc
nginxPod -->|proxy_pass to service DNS| wpSvc
wpPod1 -->|SQL on 3306| mariadbSvc
wpPod2 -->|SQL on 3306| mariadbSvc
This separation is common in production: the ingress controller handles cluster entry, the reverse proxy handles application-facing HTTP behavior, and the application server focuses on running application code.
-
Create the nginx ConfigMap
Terminal window vim nginx-configmap.yamlapiVersion: v1kind: ConfigMapmetadata:name: nginx-configdata:default.conf: |server {listen 80;location / {proxy_pass http://wordpress-service;proxy_set_header Host $http_host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;}}proxy_pass http://wordpress-servicetells nginx to forward all requests to thewordpress-serviceService. Kubernetes DNS resolveswordpress-serviceto the Service’s ClusterIP, which then load-balances across the 2 WordPress Pods.proxy_set_header Host $http_hostpasses the fullhost:portfrom the original request to WordPress. Using$hostinstead would strip the port, causing WordPress to generate redirect and asset URLs without the correct port, which breaks CSS and redirects. -
Write the nginx Deployment
Terminal window vim nginx-deployment.yamlapiVersion: apps/v1kind: Deploymentmetadata:name: nginxlabels:app: nginxspec:replicas: 1selector:matchLabels:app: nginxtemplate:metadata:labels:app: nginxspec:containers:- name: nginximage: nginx:1.27ports:- containerPort: 80volumeMounts:- name: nginx-config-volumemountPath: /etc/nginx/conf.dvolumes:- name: nginx-config-volumeconfigMap:name: nginx-configThe ConfigMap is mounted at
/etc/nginx/conf.d, where nginx looks for virtual host files. Thedefault.confkey in the ConfigMap becomes the file/etc/nginx/conf.d/default.confinside the container. -
Write the nginx Service
Terminal window vim nginx-service.yamlapiVersion: v1kind: Servicemetadata:name: nginx-servicespec:type: ClusterIPselector:app: nginxports:- port: 80targetPort: 80nginx is a ClusterIP Service: it is reachable only from inside the cluster. External traffic reaches it through the Ingress you will create next, not directly.
-
Write the Ingress
k3s ships with Traefik as the default ingress controller. It is already running and bound to port 80 on the host. An Ingress resource tells Traefik which Service to route traffic to.
Terminal window vim wordpress-ingress.yamlapiVersion: networking.k8s.io/v1kind: Ingressmetadata:name: wordpress-ingressspec:rules:- http:paths:- path: /pathType: Prefixbackend:service:name: nginx-serviceport:number: 80No
host:field is set, so this rule matches any hostname. Traefik receives all HTTP traffic on port 80 and forwards it tonginx-service, which forwards it to WordPress. -
Apply all four manifests
Terminal window kubectl apply -f nginx-configmap.yaml -f nginx-deployment.yaml -f nginx-service.yaml -f wordpress-ingress.yaml -
Test the proxy
Terminal window curl -L http://localhostYou should be redirected to
/wp-admin/install.phpand receive the WordPress installation wizard HTML. You can also openhttp://<your-ec2-public-ip>in your browser (no port needed) to see it rendered. Record what you observe for your lab questions.
Configure WordPress
Section titled “Configure WordPress”-
Open the setup wizard
Open
http://<your-ec2-public-ip>in a browser. You will be redirected to/wp-admin/install.php. Complete the WordPress installation:- Site Title: anything you like
- Username:
admin(or your choice) - Password: a password you will remember
- Email: any email address
Click Install WordPress.
-
Log in
After installation completes, click Log In and enter your credentials.
-
Trigger the authentication loop
Once logged in to the dashboard, try navigating to Posts, Settings, or any other admin page. You will be redirected back to
wp-login.php?reauth=1and asked to log in again, only to be redirected back again immediately. The site is stuck in an infinite loop.
Diagnosing Multi-Replica Authentication Failure
Section titled “Diagnosing Multi-Replica Authentication Failure”The WordPress Docker image generates a random set of authentication keys (AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, NONCE_KEY, and four corresponding salt values) at container startup. With two replicas, Pod A and Pod B each generate their own independent set of keys. When you log in and nginx routes your session to Pod A, WordPress signs your session cookie using Pod A’s keys. Your next request may go to Pod B, which has entirely different keys and cannot validate the cookie. WordPress treats the cookie as invalid and redirects you to wp-login.php?reauth=1. Every subsequent request has the same problem, producing the infinite loop.
-
Confirm both Pods are running
Terminal window kubectl get pods -l app=wordpressNote the two Pod names. They have different names and different IP addresses.
-
Check logs from both Pods simultaneously
Terminal window kubectl logs -l app=wordpress --prefixThe
--prefixflag prepends the Pod name to each log line. Refresh the WordPress page a few times. You will see requests served by alternating Pods, confirming that nginx is distributing traffic between them. -
Confirm no auth keys are set
Terminal window kubectl exec $(kubectl get pods -l app=wordpress -o jsonpath='{.items[0].metadata.name}') -- env | grep WORDPRESS_AUTHYou will see no output. No auth key environment variables were set, so each Pod is using its own randomly-generated values. This is the root cause.
Fixing Multi-Replica Authentication
Section titled “Fixing Multi-Replica Authentication”The fix is to generate a consistent set of auth keys once, store them in the Secret, and inject them into every WordPress Pod as environment variables. With all replicas using the same keys, any Pod can validate any cookie.
-
Generate the eight auth keys
Terminal window for key in AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY AUTH_SALT SECURE_AUTH_SALT LOGGED_IN_SALT NONCE_SALT; doecho "$key: $(openssl rand -base64 48)"doneCopy all eight lines of output. You will paste each value into the Secret.
-
Update the Secret
Edit
db-secret.yamlto add the eight keys. Replace each placeholder with the actual generated value:apiVersion: v1kind: Secretmetadata:name: db-secrettype: OpaquestringData:db-root-password: rootpassworddb-password: wordpresspasswordauth-key: "<paste AUTH_KEY value>"secure-auth-key: "<paste SECURE_AUTH_KEY value>"logged-in-key: "<paste LOGGED_IN_KEY value>"nonce-key: "<paste NONCE_KEY value>"auth-salt: "<paste AUTH_SALT value>"secure-auth-salt: "<paste SECURE_AUTH_SALT value>"logged-in-salt: "<paste LOGGED_IN_SALT value>"nonce-salt: "<paste NONCE_SALT value>" -
Update the WordPress Deployment
Edit
wordpress-deployment.yamland add these entries to theenvsection of the WordPress container, after the existing environment variables:- name: WORDPRESS_AUTH_KEYvalueFrom:secretKeyRef:name: db-secretkey: auth-key- name: WORDPRESS_SECURE_AUTH_KEYvalueFrom:secretKeyRef:name: db-secretkey: secure-auth-key- name: WORDPRESS_LOGGED_IN_KEYvalueFrom:secretKeyRef:name: db-secretkey: logged-in-key- name: WORDPRESS_NONCE_KEYvalueFrom:secretKeyRef:name: db-secretkey: nonce-key- name: WORDPRESS_AUTH_SALTvalueFrom:secretKeyRef:name: db-secretkey: auth-salt- name: WORDPRESS_SECURE_AUTH_SALTvalueFrom:secretKeyRef:name: db-secretkey: secure-auth-salt- name: WORDPRESS_LOGGED_IN_SALTvalueFrom:secretKeyRef:name: db-secretkey: logged-in-salt- name: WORDPRESS_NONCE_SALTvalueFrom:secretKeyRef:name: db-secretkey: nonce-salt -
Apply the changes
Terminal window kubectl apply -f db-secret.yaml -f wordpress-deployment.yamlkubectl rollout status deployment/wordpressThis triggers a Deployment update and waits until WordPress is healthy again. Record what
kubectl rollout statusreports for your lab questions. You will explore rollout mechanics (pause, resume, revisions, and rollback strategies) in detail in the next lab. -
Verify the fix
Terminal window kubectl get pods -l app=wordpressBoth Pods will have a recent AGE, showing they were replaced during the rollout.
Open the WordPress admin in your browser. Log in and navigate between pages. The
reauth=1loop should be gone: because every Pod now uses the same keys, any Pod can validate any session cookie.
Testing Self-Healing
Section titled “Testing Self-Healing”One of Kubernetes’ most important features is that it automatically replaces failed Pods to maintain the desired replica count.
-
List the running WordPress Pods
Terminal window kubectl get pods -l app=wordpressNote the names and AGE of both Pods.
-
Delete one WordPress Pod
Terminal window kubectl delete pod <pod-name>Replace
<pod-name>with one of the actual Pod names from the list. -
Watch the replacement
Immediately run:
Terminal window kubectl get pods -l app=wordpressYou should see one Pod still running and a new Pod being created with a very recent AGE. The Deployment’s control loop detected that the actual state (1 Pod) did not match the desired state (2 Pods) and created a replacement.
-
Describe the WordPress Service endpoints
Terminal window kubectl describe service wordpress-serviceFind the “Endpoints” line. This shows the IP addresses of all Pods that the Service routes traffic to. When a Pod is replaced and gets a new IP, the endpoint list updates automatically, which is why nginx can use
proxy_pass http://wordpress-serviceinstead of a hardcoded Pod IP.
Clean Up
Section titled “Clean Up”This cluster is the foundation for Lab 8 (Cluster Operations) and Lab 9 (Observability). Do not delete these resources yet.
To pause your work, simply end your AWS Academy Learner Lab session. Your EC2 instance and everything on it (k3s, all manifests, all running workloads) persists between sessions. When you restart the lab, the instance will be there and k3s will resume automatically.
If you have completed all three labs and are permanently done, you can tear everything down:
kubectl delete \ -f wordpress-ingress.yaml \ -f nginx-deployment.yaml -f nginx-service.yaml -f nginx-configmap.yaml \ -f wordpress-deployment.yaml -f wordpress-service.yaml \ -f mariadb-deployment.yaml -f mariadb-service.yaml -f db-secret.yamlYou now understand how Kubernetes orchestrates a multi-service application. Traefik receives public traffic on port 80 and routes it via the Ingress to nginx, which proxies to WordPress (running Apache internally), which connects to MariaDB, all wired together through Services and their DNS names. Secrets kept credentials out of your manifests, and Deployments kept both nginx and WordPress running even when individual Pods failed. In the next lab, you will add health checks, resource limits, and practice failure drills.