Production-grade Kubernetes deployment of FreeTAKServer on k3s — hardened Docker image, GitLab CI/CD with Kaniko, Traefik ingress with automatic TLS and per-IP rate limiting.
FreeTAKServer (FTS) is an open-source Python implementation of a TAK server, enabling ATAK/WinTAK clients to share geospatial information, chat, exchange files, and more. This repository contains everything needed to build and deploy it as a production Kubernetes workload.
graph TD
ATAK["ATAK / WinTAK Client"]
LB["LoadBalancer Service\n(TAK ports: 8080, 8089, 8443)"]
HTTP["Browser / API Consumer"]
TRAEFIK["Traefik Ingress\n(HTTPS only)"]
MW["Middleware\nRate limit: 20 req/s burst 40 per IP"]
API_SVC["ClusterIP Service\n(:19023 REST API)"]
POD["FreeTAKServer Pod\n(non-root user freetak)"]
INIT["initContainer: fix-permissions\n(busybox, runAsUser: 0, chown 999+chmod 770)"]
PVC["PersistentVolumeClaim\nSQLite DB · TLS certs · Data packages"]
SECRET["k8s Secret\nKeys · Passwords · Addresses"]
CERTMGR["cert-manager\n(Let's Encrypt)"]
GITLAB["GitLab CI\nKaniko build → kubectl deploy"]
ATAK -->|"SSL CoT :8089\nData packages :8080/:8443"| LB
HTTP -->|HTTPS :443| TRAEFIK
TRAEFIK --> MW --> API_SVC --> POD
LB --> POD
INIT -->|"pre-creates dirs + DB file\nchmod 777 before app starts"| PVC
POD --> PVC
POD -.->|"envFrom secretRef"| SECRET
CERTMGR -.->|"TLS certificate"| TRAEFIK
GITLAB -.->|"image push + rollout"| POD
FTS was not designed to run in Kubernetes out of the box. Getting it there required identifying and fixing several non-obvious issues:
The Dockerfile creates a dedicated system user freetak and switches to it before the app starts (USER freetak). This prevents privilege escalation if the container is ever compromised.
The catch: Kubernetes runAsNonRoot: true requires a numeric UID to verify the constraint at runtime. Because the image uses a named user rather than a UID, runAsNonRoot cannot be set — k8s would reject the pod at admission. The workaround is to let the Dockerfile enforce non-root via USER, and enable seccompProfile: RuntimeDefault at the pod level to still reduce the syscall attack surface.
local-path volumes are provisioned root-owned. The freetak user (UID 999) cannot write to them without an explicit permission step.
Additionally, FTS calls validate_and_sanitize_path() on FTS_DB_PATH and FTS_LOGFILE_PATH before SQLAlchemy has a chance to create the DB file. If those paths don't exist at startup, FTS aborts.
An initContainer (busybox, runAsUser: 0) pre-creates the required directory tree, touches FTSDataBase.db, sets ownership to freetak (UID 999) via chown -R 999:999, and applies chmod 770 on the volume before the main container starts.
/opt/fts/
├── FTSDataBase.db # touched by initContainer
├── Logs/ # created by initContainer
├── certs/ # created by initContainer
├── ExCheck/
│ ├── template/
│ └── checklist/
└── FreeTAKServerDataPackageFolder/
opentelemetry-sdk >= 1.24.0 made BatchSpanProcessor.span_exporter a read-only property. This silently broke digitalpy == 0.3.13.7 (the version shipped with FTS v2.2.1) at runtime with an AttributeError.
The fix is a pinned force-reinstall after the editable FTS install so pip cannot upgrade it as a transitive dependency:
RUN pip install --force-reinstall "opentelemetry-sdk<1.24.0"FTS uses a SQLite database stored on the PVC. SQLite does not support concurrent writes from multiple processes. The Deployment uses strategy: Recreate to guarantee that only one pod holds the DB file at a time — no split-brain, no file corruption on rollout.
The CI pipeline captures the Secret's resourceVersion before and after applying it. If the version changed (credentials were rotated), it automatically triggers a kubectl rollout restart so the pod picks up the new values. Kubernetes does not hot-reload envFrom: secretRef values.
CI_JOB_TOKEN / CI_REGISTRY_USER are ephemeral and expire when the CI job ends. The k8s node needs to pull the image independently (e.g. after a node reboot). A GitLab Deploy Token with read_registry scope is stored as a long-lived docker-registry secret (gitlab-registry-secret) and referenced in imagePullSecrets.
The Middleware CRD (k8s/middleware.yml) must exist before the Ingress is applied, otherwise Traefik cannot resolve the router.middlewares annotation and will silently drop the rule. The deploy job applies middleware.yml first, then ingress.yml.
| Layer | Technology |
|---|---|
| Runtime | k3s (Kubernetes) |
| Ingress | Traefik v2 |
| TLS | cert-manager + Let's Encrypt |
| Container build | Kaniko (no privileged mode) |
| CI/CD | GitLab CI |
| Persistence | local-path PVC (SQLite) |
| Secrets | k8s Secret + GitLab masked variables |
| Base image | python:3.11-slim |
.
├── Dockerfile # Hardened FTS image (non-root user, pinned deps)
├── .gitlab-ci.yml # Build (Kaniko) + deploy (kubectl) pipeline
├── .gitmodules # freetakserver-src → FreeTAKTeam/FreeTakServer
└── k8s/
├── namespace.yml # freetakserver namespace
├── pvc.yml # 5 Gi local-path volume for DB, certs, data packages
├── deployment.yml # Pod spec: initContainer, envFrom secret, probes
├── service.yml # LoadBalancer (TAK ports) + ClusterIP (REST API)
├── ingress.yml # Traefik HTTPS ingress for the REST API
├── middleware.yml # Per-IP rate limiting (20 req/s, burst 40)
└── secret.example.yml # Secret schema reference (do not apply directly)
This repository uses a git submodule for the FTS application source. Clone with:
git clone --recurse-submodules https://github.com/YOUR_USERNAME/freetakserver-k8s.gitOr, if you already cloned without --recurse-submodules:
git submodule update --init --recursiveNote: The CI/CD pipeline (
.gitlab-ci.yml) is written for GitLab CI. If you want to adapt it for GitHub Actions, the Kaniko build step and thekubectldeploy step map directly — the main difference is variable syntax (${{ secrets.X }}vs$X) and the runner tag.
For the full deployment flow, see CI/CD variables and Deployment flow below.
Set these in GitLab → Settings → CI/CD → Variables:
| Variable | Description | Masked |
|---|---|---|
KUBECONFIG_CONTENT |
kubeconfig file for kubectl | — |
REGISTRY_DEPLOY_USER |
GitLab Deploy Token username (read_registry scope) |
— |
REGISTRY_DEPLOY_TOKEN |
Deploy Token password | ✓ |
FTS_DP_ADDRESS |
Public IP / hostname for DataPackage connections | — |
FTS_USER_ADDRESS |
Public IP / hostname for CoT connections | — |
FTS_SECRET_KEY |
FTS internal secret (≥ 16 random chars) | ✓ |
FTS_FED_PASSWORD |
Federation service password | ✓ |
FTS_CLIENT_CERT_PASSWORD |
SSL client certificate password | ✓ |
FTS_NODE_ID |
Unique node identifier (alphanumeric, ≤ 32 chars) | — |
FTS_CONNECTION_MESSAGE |
(optional) Welcome message for ATAK clients | — |
FTS_API_DOMAIN |
(optional) Domain for the REST API Ingress | — |
git push → main
│
├─ build:freetakserver
│ └─ Kaniko builds image from Dockerfile + freetakserver-src/ submodule
│ Tags: :latest and :<commit-sha>
│ Pushes to GitLab Container Registry
│
└─ deploy:freetakserver
├─ kubectl apply namespace, pull secret, app secret, PVC
├─ sed replaces __FTS_IMAGE__ placeholder → kubectl apply deployment
├─ kubectl apply services
├─ kubectl rollout status (timeout: 5 min)
├─ if secret changed → kubectl rollout restart
├─ kubectl apply middleware
└─ if FTS_API_DOMAIN set → sed replaces __FTS_API_DOMAIN__ → kubectl apply ingress
| Port | Protocol | Purpose |
|---|---|---|
| 8080 | TCP | HTTP DataPackage service |
| 8087 | TCP | Plain CoT (internal only) |
| 8089 | TCP (TLS) | SSL Cursor-on-Target |
| 8443 | TCP (TLS) | HTTPS DataPackage service |
| 9000 | TCP | FTS Federation |
| 19023 | TCP | REST API (Ingress only) |
FreeTAKServer is developed and maintained by the FreeTAKTeam and released under the Eclipse Public License 2.0. This repository contains only infrastructure and deployment code — not the FTS application source.