If you've spent any time configuring user authentication on... Full Story
By Manny Fernandez
July 18, 2026
Deploying FortiGate cFOS: From a Docker Image to a Kubernetes Firewall Pod
1. Executive Summary
Objective: This guide takes a Fortinet-supplied Container FortiOS (cFOS) image, validates it standalone in Docker, then promotes it into a Kubernetes cluster as a licensed, policy-driven NGFW pod. We load the image, smoke test it in Docker, push it to a registry, wire up Kubernetes RBAC, deliver the license and firewall policy through ConfigMaps and Secrets, and finish with a working inspection point sitting inline with containerized workloads.
Why it matters: cFOS brings FortiOS-grade inspection (firewall policy, IPS, application control, web filtering, antivirus) into container platforms without a hardware or VM FortiGate. For an SE or platform engineer, it is the way to put real NGFW controls next to container workloads while keeping the whole deployment declarative and repeatable.
Target Audience: Network Security Engineers, Platform and DevOps Engineers, and Kubernetes Administrators who already run containerized workloads and are comfortable with the FortiOS CLI, Docker, and kubectl.
2. Prerequisites & Architecture
Assumed Knowledge
You should already understand:
- FortiOS CLI syntax (
config/edit/set/next/end). cFOS exposes a FortiOS-subset CLI. - Docker fundamentals: images, networks (bridge and MACVLAN), volumes, and Linux capabilities.
- Kubernetes primitives: Deployment, ConfigMap, Secret, and RBAC (ClusterRole and RoleBinding).
- That cFOS is headless. There is no full FortiGate web GUI; you drive it with the CLI, a REST API on port 443, and (in Kubernetes) ConfigMaps and Secrets.
Environment / Lab Requirements
| Requirement | Minimum | Notes |
|---|---|---|
| Docker host | containerd or Docker engine | For loading the image and the standalone smoke test |
| Kubernetes | Working cluster with kubectl |
RBAC enabled; examples use the default namespace |
| Multus CNI | Optional | Only if the pod needs more than one interface (separate WAN and LAN) |
| Container registry | Reachable by all nodes | Create an imagePullSecret if it is private |
| cFOS license | Valid FGT VM license | Purchase, then open a Fortinet TAC ticket; TAC supplies the image and the license |
| Image | cFOS 7.2.2, build 265 | Naming: cFOS_<CPU Arch>_<Container Type>-v<Major>-build<build>-<Company>.tar.gz |
Component Table
| Component | Role | Example Value |
|---|---|---|
| Docker host / K8s node | Runs the container engine | Ubuntu 22.04, containerd |
| cFOS image | Containerized FortiOS NGFW | cFOS_X64_DOCKER-v7-build265-FORTINET.tar.gz |
| Container registry | Stores the image for cluster pulls | registry.example.local:5000/cfos:7.2.2 |
| cFOS Deployment | Runs the firewall pod | fos-deployment (label app: fos) |
| License ConfigMap | Holds the FGT VM license text | fos-license (category: license) |
| Config ConfigMap | Holds FortiOS CLI configuration | foscfg-policy (category: config) |
| Secret | Sensitive values (PSKs, keys) | ipsec-certs |
| WAN network | Untrusted side, cFOS eth0 |
00-cFOS-WAN (macvlan) |
| LAN network | Protected side, cFOS eth1 |
99-cFOS-LAN / 192.168.21.0/24 |
Logical Flow
Kubernetes control plane
|
license + config via ConfigMaps / Secrets
(cFOS watches them, needs RBAC)
|
[ Workload ] --(traffic)--> [ cFOS pod ] --(policy + IPS + AV + NAT)--> [ Internet ]
eth1 (LAN) eth0 (WAN)
3. Step-by-Step Implementation Workflow
Phase 1: Obtain and Load the cFOS Image (Docker)
The Goal: Get the Fortinet-supplied tarball onto a Docker host and register it as a local image.
The Action: After TAC provides the image, load the tarball and confirm it was imported and tagged. The image naming convention is cFOS_<CPU Arch>_<Container Type>-v<Major>-build<build>-<Company>.tar.gz.
The CLI:
sudo docker load -i cFOS_X64_DOCKER-v7-build265-FORTINET.tar.gz # Confirm the image is present and note the REPOSITORY:TAG it was given sudo docker images
Verify: docker images lists the freshly loaded image, commonly tagged fos:latest.
Phase 2: Smoke Test Standalone in Docker
The Goal: Prove the image boots, licenses, and answers on CLI and REST before you invest in cluster plumbing.
The Action: Create MACVLAN WAN and LAN networks, create and start the container with the required capabilities and a persistent /data volume, then connect to the CLI and import the license.
The CLI: MACVLAN gives the container an interface that looks physically attached to your host NIC.
sudo docker network create --driver=macvlan \ --subnet=10.210.16.0/24 \ --gateway=10.210.16.1 \ -o parent=<host_wan_nic> \ 00-cFOS-WAN sudo docker network create --driver=macvlan \ --subnet=192.168.254.0/24 \ --gateway=192.168.254.1 \ -o parent=<host_lan_nic> \ 99-cFOS-LAN
Create and start the container. cFOS needs the NET_ADMIN and SYS_ADMIN capabilities plus a persistent /data volume. Replace <host_ip> with the host external IP.
sudo docker container create \ --network 00-cFOS-WAN \ --ip=10.210.16.254 \ -p <host_ip>:5443:5443 \ -p <host_ip>:4022:4022 \ -p <host_ip>:8080:8080 \ -p <host_ip>:500:500/udp \ -p <host_ip>:4500:4500/udp \ --cap-add=NET_ADMIN \ --cap-add=SYS_ADMIN \ --security-opt apparmor:unconfined \ --name cfos1 \ -v /srv/cfos/cfos1_data:/data \ --dns 96.45.45.45 \ --dns 96.45.46.46 \ -it fos # Start and attach sudo docker container start --attach -i cfos1 # Attach the protected (LAN) network to the running container sudo docker network connect --ip 192.168.254.254 99-cFOS-LAN cfos1
Connect to the CLI. The initial user is admin with an empty password. Set a password immediately, then paste the license contents.
sudo docker exec -it cfos1 /bin/cli
# Inside the cFOS CLI: set an admin password
config system admin
edit admin
set password <your_password>
next
end
# Paste the FULL contents of your license file between the quotes
exec import-license "<content_of_license_file>"
Verify: Run get system status in the CLI and confirm the license registers as valid. The REST API answers on port 443 (a subset of the FortiOS API):
curl -k "https://<host_ip>/api/v2/cmdb/antivirus/settings?access_token=<api_token>"
Phase 3: Push the Image to a Registry
The Goal: Make the image pullable by every cluster node.
The Action: Tag the loaded image for your registry and push it. Note the resulting <image_URL>, you will drop it into the Deployment in Phase 6.
The CLI:
sudo docker tag fos:latest registry.example.local:5000/cfos:7.2.2 sudo docker push registry.example.local:5000/cfos:7.2.2
Verify: From a cluster node, docker pull (or crictl pull) the tag to confirm reachability. Create an imagePullSecret if the registry is private.
Phase 4: Create the Kubernetes Role Bindings (RBAC)
The Goal: Let the cFOS pod read and watch the ConfigMaps and Secrets that carry its license and configuration. Without this, the license and policy never load.
The Action: Apply role bindings that grant the pod service account get, watch, and list on both resource types. This example binds the default service account; change it to match yours.
The CLI:
# rolebindings.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: configmap-reader
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: read-configmaps
namespace: default
subjects:
- kind: ServiceAccount
name: default
apiGroup: ""
roleRef:
kind: ClusterRole
name: configmap-reader
apiGroup: ""
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secrets-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: read-secrets
namespace: default
subjects:
- kind: ServiceAccount
name: default
apiGroup: ""
roleRef:
kind: ClusterRole
name: secrets-reader
apiGroup: ""
kubectl apply -f rolebindings.yaml
Verify: kubectl auth can-i watch configmaps --as=system:serviceaccount:default:default returns yes.
Phase 5: Deploy the License via ConfigMap
The Goal: Deliver the license declaratively so any replacement pod re-licenses itself automatically.
The Action: The labels app: fos and category: license are mandatory. Paste the full license between the markers.
The CLI:
# license-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: fos-license
labels:
app: fos
category: license
data:
license: |
-----BEGIN FGT VM LICENSE-----
<paste the full contents of your .lic file here>
-----END FGT VM LICENSE-----
kubectl apply -f license-configmap.yaml
Phase 6: Deploy the cFOS Container
The Goal: Run cFOS as a pod with the right capability and a data volume. It imports the license ConfigMap on boot.
The Action: Swap <image_URL> for your registry tag. NET_ADMIN is required because cFOS programs iptables rules. Ports 500 and 4500/UDP are exposed for IPsec.
The CLI:
# cfos.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: fos-deployment
labels:
app: fos
spec:
replicas: 1
selector:
matchLabels:
app: fos
template:
metadata:
labels:
app: fos
spec:
containers:
- name: fos
image: <image_URL>
securityContext:
capabilities:
add: ["NET_ADMIN"]
ports:
- name: isakmp
containerPort: 500
protocol: UDP
- name: ipsec-nat-t
containerPort: 4500
protocol: UDP
volumeMounts:
- mountPath: /data
name: data-volume
volumes:
- name: data-volume
emptyDir: {}
kubectl apply -f cfos.yaml
The example uses an emptyDir ramdisk to keep the install simple. On boot, cFOS re-imports configuration from the ConfigMap, so config is not lost when the pod is replaced. For durable logs and state, back /data with a PersistentVolume instead.
Phase 7: Apply Firewall Configuration (ConfigMap + Secrets)
The Goal: Push actual policy into cFOS declaratively. A partial config layers on top of the running config; a full config replaces it entirely.
The Action: The labels app: fos and category: config plus type: partial are required. Interfaces map to eth0 (WAN) and eth1 (LAN).
The CLI:
# policy-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: foscfg-policy
labels:
app: fos
category: config
data:
type: partial
config: |-
config firewall policy
edit 1
set name "lan-to-wan"
set srcintf "eth1"
set dstintf "eth0"
set srcaddr "all"
set dstaddr "all"
set action accept
set schedule "always"
set service "ALL"
set utm-status enable
set av-profile "default"
set ips-sensor "default"
set nat enable
next
end
kubectl apply -f policy-configmap.yaml
For sensitive values such as an IPsec pre-shared key, reference a Secret with the token {{<Secret name>:<Key name>}} instead of writing plaintext into the ConfigMap.
# Create the secret the config will reference
kubectl create secret generic ipsec-certs \
--from-literal=ipsec-cert-pass=12345678
# ...then in the config data, reference it:
# set psksecret {{ipsec-certs:ipsec-cert-pass}}
For a full replacement, use type=full and include every dependency the config references (for example, a policy that calls a web filter profile must also include that profile).
kubectl create configmap fos-config \ --from-file=config=<path_to_config_file> \ --from-literal=type=full kubectl label configmap fos-config app=fos kubectl label configmap fos-config category=config
4. Verification & Validation
Step 1: Confirm the pod is running
kubectl get pods -l app=fos
The pod should show Running, Ready 1/1, and a low restart count.
Step 2: Confirm the license and config applied
kubectl logs --tail=200 -l app=fos
What success looks like: log lines showing the license accepted and the ConfigMap config imported with no errors.
Step 3: Check status and policy from the CLI
kubectl exec -it <pod> -- /bin/cli # then, inside the cFOS CLI: get system status show firewall policy
Version and build 265 appear, the license reports valid, and your lan-to-wan policy is present with UTM enabled. For the underlying Linux shell, the guide documents sysctl sh from inside the cFOS CLI.
Step 4: Confirm the REST API answers
curl -k "https://<pod_ip>/api/v2/cmdb/antivirus/settings?access_token=<token>"
A healthy result is HTTP 200 with a JSON body of AV settings.
5. Troubleshooting & Gotchas
Gotcha 1: Traffic is not being filtered (missing NET_ADMIN)
cFOS programs iptables rules at boot. Without NET_ADMIN (and SYS_ADMIN in standalone Docker) the pod may start but forwards or drops traffic without inspection, and the logs show iptables or permission errors.
Diagnose:
# Diagnose
kubectl logs --tail=200 -l app=fos # K8s
sudo docker logs cfos1 # Docker
# Confirm the capability is actually set on the pod
kubectl get pod <pod> -o jsonpath='{.spec.containers[0].securityContext.capabilities.add}'
Resolution: Add NET_ADMIN under securityContext.capabilities.add in the Deployment (Phase 6), and both NET_ADMIN and SYS_ADMIN plus apparmor:unconfined in the Docker run, then recreate the container or roll the Deployment.
Gotcha 2: License or config never applies (RBAC or missing labels)
In Kubernetes, cFOS reads its license and config by watching ConfigMaps and Secrets. Two failure modes look identical from the outside (features stay locked, policy never appears): the service account lacks watch permissions, or the ConfigMap is missing the required labels.
Diagnose:
# Diagnose: RBAC kubectl logs -l app=fos | grep -i -E "forbidden|watch|configmap|secret" kubectl auth can-i watch configmaps \ --as=system:serviceaccount:default:default # Diagnose: labels (both must be present) kubectl get configmap fos-license --show-labels kubectl get configmap foscfg-policy --show-labels
Resolution: Apply rolebindings.yaml and confirm the roleRef service account matches the one the pod runs as. Ensure every license ConfigMap carries app: fos + category: license, and every config ConfigMap carries app: fos + category: config.
Gotcha 3: Full config rejected, or state lost on pod restart
A type: full config that references an object it does not define (a policy calling a web filter profile that is not in the file) is rejected on import. Separately, because the sample Deployment uses an ephemeral emptyDir, logs written to /data vanish when the pod is replaced.
Diagnose:
# Diagnose the rejected import kubectl logs -l app=fos | grep -i -E "depend|profile|not found|invalid"
Resolution: Make full configs self-contained by including every referenced profile and its dependencies, or prefer layered partial configs. For durable state, replace emptyDir with a PersistentVolumeClaim, and forward logs off-box with config log syslogd setting to a FortiAnalyzer or syslog collector.
Recent posts
-
-
DNS is one of those technologies that quietly underpins... Full Story
-
BGP issues on FortiGate firewalls usually trace back to... Full Story
-
Every time your laptop talks to your router, a... Full Story
-
If you've spent any time configuring NAT on a... Full Story
-
If you have spent any time configuring firewall policies... Full Story
-
High availability on FortiGate is one of those features... Full Story
-
If you've configured SD-WAN on a FortiGate, you've almost... Full Story
-
FortiLink is the management protocol that turns a FortiSwitch... Full Story
-
FortiSwitches are pretty rock solid from Mean Time Between... Full Story
-
This is a quicky tip. Have you ever gone... Full Story
-
DNS is one of those quiet pieces of internet... Full Story
-
This article is an updated version of the previous... Full Story
-
You will add ns2 as a secondary (slave) BIND9... Full Story
-
In the process of deploying my lab, I needed... Full Story
-
RFC 8805, used to be known as Self-Correcting IP... Full Story
-
Years back, I wrote an article about certificate pinning. ... Full Story
-
FortiGates have the ability to send alerts to Microsoft... Full Story
-
In this post, I am going to walk through... Full Story
-
Troubleshooting VoIP on a FortiGate can feel like trying... Full Story
-
Prior to FortiOS 7.0, there were three commands to... Full Story
-
In this post, I am going to go over... Full Story
-
What we are going to do: We are going... Full Story
-
Choosing between FGCP (FortiGate Clustering Protocol) and FGSP (FortiGate... Full Story
-
Creating a VLAN on macOS (The "Pro" Move) A... Full Story
-
This blog post explores the logic behind how macOS... Full Story
-
Pretty Fly for a Wi-Fi Tell My Wi-Fi Love... Full Story
-
Part of my daily gig is creating BoMs (Bill-of-Materials)... Full Story
-
ICMP introduces several security risks, but careful filtering, rate... Full Story
-
The command diag debug application dhcps -1 enables full... Full Story
-
In the world of FortiOS, execute tac report is... Full Story
-
LLDP; What is it The Link Layer Discovery Protocol... Full Story
-
What it actually does When you run diagnose fdsm... Full Story
-
Monkey Bites are bite-sized, high-impact security insights designed for... Full Story
-
I have run macOS in macOS with Parallels but... Full Story
-
Don't be confused with my other FortiNAC posts where... Full Story
-
This is the third session in a multi-part article... Full Story
-
Today I was configuring key-based authentication on a FortiGate... Full Story
-
Netcat, often called the "Swiss Army knife" of networking,... Full Story
-
At its core, IEEE 802.1X is a network layer... Full Story
-
In case you did not see the previous FortiNAC... Full Story
-
This is our 5th session where we are going... Full Story
-
Now that we have Wireshark installed and somewhat configured,... Full Story
-
The Philosophy of Packet Analysis Troubleshooting isn't about looking... Full Story
-
How next-token prediction became the core of applied AI,... Full Story
-
1. Executive Summary Objective. This guide explains, at the... Full Story
-
1. Executive Summary Objective: This guide takes a Fortinet-supplied... Full Story