Skip to main content
Pterodactyl Wings uses Docker as its container runtime to provide isolation, resource management, and consistent environments for game servers. Each server runs in its own Docker container with carefully configured security and resource constraints.

Why Docker?

Wings exclusively uses Docker for several key reasons:
  • Isolation - Each server runs in an isolated environment
  • Resource Control - CPU, memory, and I/O limits are enforced
  • Security - Containers provide security boundaries
  • Consistency - Same environment across different host systems
  • Image Management - Easy distribution of game server environments
Currently, Wings only supports Docker as an environment. The architecture allows for other environments in the future, but Docker is hardcoded in server initialization (server/manager.go:204-206).

Docker Environment Structure

Each server has a Docker environment that manages its container lifecycle.

Environment Type

// From environment/docker/environment.go:29-58
type Environment struct {
    mu sync.RWMutex
    
    // Container identifier (server UUID)
    Id string
    
    // Environment configuration
    Configuration *environment.Configuration
    
    // Container metadata
    meta *Metadata
    
    // Docker client
    client *client.Client
    
    // Hijacked response stream for container I/O
    stream *types.HijackedResponse
    
    // Stats stream
    stats io.ReadCloser
    
    // Event bus
    emitter *events.Bus
    
    // Log callback
    logCallback func([]byte)
    
    // Current state
    st *system.AtomicString
}

Container Naming

Containers are named using the server’s UUID:
// From environment/docker/environment.go:64-80
func New(id string, m *Metadata, c *environment.Configuration) (*Environment, error) {
    cli, err := environment.Docker()
    if err != nil {
        return nil, err
    }
    
    e := &Environment{
        Id:            id,  // Server UUID becomes container name
        Configuration: c,
        meta:          m,
        client:        cli,
        st:            system.NewAtomicString(environment.ProcessOfflineState),
        emitter:       events.NewBus(),
    }
    
    return e, nil
}

Container Creation

Wings creates containers with specific configurations for security and functionality.

Container Configuration

// From environment/docker/container.go:176-188
conf := &container.Config{
    Hostname:     e.Id,
    Domainname:   cfg.Docker.Domainname,
    AttachStdin:  true,
    AttachStdout: true,
    AttachStderr: true,
    OpenStdin:    true,
    Tty:          true,
    ExposedPorts: a.Exposed(),
    Image:        strings.TrimPrefix(e.meta.Image, "~"),
    Env:          e.Configuration.EnvironmentVariables(),
    Labels:       labels,
}
Key Settings:
  • TTY enabled - Allows interactive console access
  • Stdin open - Enables command sending
  • Exposed ports - Defined by server allocations

User Configuration

Containers run as a specific user for security:
// From environment/docker/container.go:191-195
if cfg.System.User.Rootless.Enabled {
    conf.User = fmt.Sprintf("%d:%d", cfg.System.User.Rootless.ContainerUID, cfg.System.User.Rootless.ContainerGID)
} else {
    conf.User = strconv.Itoa(cfg.System.User.Uid) + ":" + strconv.Itoa(cfg.System.User.Gid)
}
This ensures server processes don’t run as root inside containers.

Host Configuration

// From environment/docker/container.go:227-260
hostConf := &container.HostConfig{
    PortBindings: a.DockerBindings(),
    Mounts:       e.convertMounts(),
    Tmpfs: map[string]string{
        "/tmp": "rw,exec,nosuid,size=" + strconv.Itoa(int(cfg.Docker.TmpfsSize)) + "M",
    },
    Resources:      e.Configuration.Limits().AsContainerResources(),
    DNS:            cfg.Docker.Network.Dns,
    LogConfig:      cfg.Docker.ContainerLogConfig(),
    SecurityOpt:    []string{"no-new-privileges"},
    ReadonlyRootfs: true,
    CapDrop: []string{
        "setpcap", "mknod", "audit_write", "net_raw", "dac_override",
        "fowner", "fsetid", "net_bind_service", "sys_chroot", "setfcap",
    },
    NetworkMode: networkMode,
    UsernsMode:  container.UsernsMode(cfg.Docker.UsernsMode),
}
Security Features:
  • Read-only root filesystem - Prevents tampering with container OS
  • Dropped capabilities - Removes unnecessary Linux capabilities
  • no-new-privileges - Prevents privilege escalation
  • tmpfs /tmp - Writable temporary directory in memory

Resource Limits

Docker enforces resource limits to prevent servers from consuming excessive resources.

Resource Types

type Limits struct {
    MemoryLimit int64  // Memory in MB
    CpuLimit    int64  // CPU percentage (100 = 1 core)
    DiskSpace   int64  // Disk in MB (enforced by Wings, not Docker)
    IoWeight    uint16 // I/O priority weight
    Threads     int64  // PID limit
}

Applying Limits

Limits are converted to Docker resource constraints:
func (c *Configuration) AsContainerResources() container.Resources {
    return container.Resources{
        Memory:            c.MemoryLimit * 1024 * 1024,  // Convert MB to bytes
        MemoryReservation: c.MemoryLimit * 1024 * 1024,
        MemorySwap:        c.MemoryLimit * 1024 * 1024,
        CPUQuota:          c.CpuLimit * 1000,            // Convert to microseconds
        CPUPeriod:         100000,
        BlkioWeight:       c.IoWeight,
        PidsLimit:         &c.Threads,
    }
}

In-Situ Updates

Limits can be updated without restarting the container:
// From environment/docker/container.go:106-133
func (e *Environment) InSituUpdate() error {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    
    if _, err := e.ContainerInspect(ctx); err != nil {
        if client.IsErrNotFound(err) {
            return nil  // Container doesn't exist, nothing to update
        }
        return errors.Wrap(err, "could not inspect container")
    }
    
    if _, err := e.client.ContainerUpdate(ctx, e.Id, container.UpdateConfig{
        Resources: e.Configuration.Limits().AsContainerResources(),
    }); err != nil {
        return errors.Wrap(err, "could not update container")
    }
    
    return nil
}
CPU pinning and memory limits cannot be removed once applied - the container must be recreated.

Networking

Wings configures networking to expose server ports and control traffic.

Port Bindings

Server allocations are mapped to container ports:
type Allocation struct {
    Ip      string
    Port    int
}

func (a *Allocations) DockerBindings() nat.PortMap {
    bindings := nat.PortMap{}
    for _, alloc := range a.Mappings {
        binding := nat.PortBinding{
            HostIP:   alloc.Ip,
            HostPort: strconv.Itoa(alloc.Port),
        }
        bindings[nat.Port(fmt.Sprintf("%d/tcp", alloc.Port))] = []nat.PortBinding{binding}
        bindings[nat.Port(fmt.Sprintf("%d/udp", alloc.Port))] = []nat.PortBinding{binding}
    }
    return bindings
}
Both TCP and UDP ports are exposed for each allocation.

Network Modes

Wings supports different network configurations: Bridge Mode (Default):
NetworkMode: container.NetworkMode(cfg.Docker.Network.Mode)  // e.g., "pterodactyl0"
Force Outgoing IP: When enabled, creates a dedicated bridge network per server:
// From environment/docker/container.go:198-225
if a.ForceOutgoingIP {
    networkName := "ip-" + strings.ReplaceAll(strings.ReplaceAll(a.DefaultMapping.Ip, ".", "-"), ":", "-")
    networkMode = container.NetworkMode(networkName)
    
    if _, err := e.client.NetworkInspect(ctx, networkName, network.InspectOptions{}); err != nil {
        if !client.IsErrNotFound(err) {
            return err
        }
        
        if _, err := e.client.NetworkCreate(ctx, networkName, network.CreateOptions{
            Driver: "bridge",
            Options: map[string]string{
                "com.docker.network.host_ipv4": a.DefaultMapping.Ip,
            },
        }); err != nil {
            return err
        }
    }
}

Internal IP Translation

The Panel uses 127.0.0.1 as a placeholder, which Wings translates:
// From environment/docker/container.go:159-164
for i, v := range evs {
    // Convert 127.0.0.1 to the pterodactyl0 network interface
    if v == "SERVER_IP=127.0.0.1" {
        evs[i] = "SERVER_IP=" + cfg.Docker.Network.Interface
    }
}

Container Lifecycle

Container Attachment

Before starting, Wings attaches to the container for I/O:
// From environment/docker/container.go:48-100
func (e *Environment) Attach(ctx context.Context) error {
    if e.IsAttached() {
        return nil
    }
    
    opts := container.AttachOptions{
        Stdin:  true,
        Stdout: true,
        Stderr: true,
        Stream: true,
    }
    
    if st, err := e.client.ContainerAttach(ctx, e.Id, opts); err != nil {
        return errors.WrapIf(err, "error while attaching to container")
    } else {
        e.SetStream(&st)
    }
    
    go func() {
        // Create context for polling
        pollCtx, cancel := context.WithCancel(context.Background())
        defer cancel()
        defer e.stream.Close()
        defer func() {
            e.SetState(environment.ProcessOfflineState)
            e.SetStream(nil)
        }()
        
        // Start resource polling
        go e.pollResources(pollCtx)
        
        // Scan output and send to callback
        if err := system.ScanReader(e.stream.Reader, func(v []byte) {
            e.logCallbackMx.Lock()
            defer e.logCallbackMx.Unlock()
            e.logCallback(v)
        }); err != nil && err != io.EOF {
            log.WithField("error", err).Warn("error processing console output")
        }
    }()
    
    return nil
}
The attachment goroutine:
  1. Polls container resource usage
  2. Reads console output
  3. Calls the log callback (sends to websockets)
  4. Automatically cleans up when container stops

Container Starting

The start sequence ensures everything is ready:
// From environment/docker/power.go:51-130
func (e *Environment) Start(ctx context.Context) error {
    sawError := false
    defer func() {
        if sawError {
            e.SetState(environment.ProcessStoppingState)
            e.SetState(environment.ProcessOfflineState)
        }
    }()
    
    // Check if already running
    if c, err := e.ContainerInspect(ctx); err == nil {
        if c.State.Running {
            e.SetState(environment.ProcessRunningState)
            return e.Attach(ctx)
        }
        
        // Truncate old logs
        if _, err := os.Stat(c.LogPath); err == nil {
            os.Truncate(c.LogPath, 0)
        }
    }
    
    e.SetState(environment.ProcessStartingState)
    sawError = true
    
    // Recreate container with latest config
    if err := e.OnBeforeStart(ctx); err != nil {
        return err
    }
    
    // Attach BEFORE starting (critical ordering)
    actx, cancel := context.WithTimeout(ctx, time.Second*30)
    defer cancel()
    
    if err := e.Attach(actx); err != nil {
        return errors.WrapIf(err, "failed to attach to container")
    }
    
    if err := e.client.ContainerStart(actx, e.Id, container.StartOptions{}); err != nil {
        return errors.WrapIf(err, "failed to start container")
    }
    
    sawError = false
    return nil
}
Attachment must occur before starting the container. Reversing this order causes a deadlock.

Container Destruction

When a server is deleted, its container is removed:
// From environment/docker/container.go:271-292
func (e *Environment) Destroy() error {
    e.SetState(environment.ProcessStoppingState)
    
    err := e.client.ContainerRemove(context.Background(), e.Id, container.RemoveOptions{
        RemoveVolumes: true,
        RemoveLinks:   false,
        Force:         true,
    })
    
    e.SetState(environment.ProcessOfflineState)
    
    // Don't error on container not found
    if err != nil && client.IsErrNotFound(err) {
        return nil
    }
    
    return err
}

Image Management

Wings automatically pulls Docker images when needed.

Image Pulling

// From environment/docker/container.go:348-439
func (e *Environment) ensureImageExists(img string) error {
    e.Events().Publish(environment.DockerImagePullStarted, "")
    defer e.Events().Publish(environment.DockerImagePullCompleted, "")
    
    // Skip local images (prefixed with ~)
    if strings.HasPrefix(img, "~") {
        return nil
    }
    
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
    defer cancel()
    
    // Get registry authentication
    var registryAuth *config.RegistryConfiguration
    for registry, c := range config.Get().Docker.Registries {
        if strings.HasPrefix(img, registry) {
            registryAuth = &c
            break
        }
    }
    
    imagePullOptions := image.PullOptions{All: false}
    if registryAuth != nil {
        b64, err := registryAuth.Base64()
        if err == nil {
            imagePullOptions.RegistryAuth = b64
        }
    }
    
    out, err := e.client.ImagePull(ctx, img, imagePullOptions)
    if err != nil {
        // Check if image exists locally
        images, ierr := e.client.ImageList(ctx, image.ListOptions{})
        if ierr != nil {
            return ierr
        }
        
        for _, img2 := range images {
            for _, t := range img2.RepoTags {
                if t == img {
                    log.Warn("unable to pull image but it exists locally")
                    return nil  // Use local copy
                }
            }
        }
        
        return errors.Wrapf(err, "failed to pull image: %s", img)
    }
    defer out.Close()
    
    // Block until pull completes
    scanner := bufio.NewScanner(out)
    for scanner.Scan() {
        b := scanner.Bytes()
        status, _ := jsonparser.GetString(b, "status")
        progress, _ := jsonparser.GetString(b, "progress")
        e.Events().Publish(environment.DockerImagePullStatus, status+" "+progress)
    }
    
    return scanner.Err()
}
Fallback Behavior: If the image pull fails but the image exists locally (e.g., during registry outage), Wings uses the local copy and logs a warning.

Registry Authentication

Wings supports private registry authentication configured in config.yml:
docker:
  registries:
    registry.example.com:
      username: myuser
      password: mypassword

Mounts and Volumes

Wings mounts the server’s data directory into containers:
// From environment/docker/container.go:441-453
func (e *Environment) convertMounts() []mount.Mount {
    mounts := e.Configuration.Mounts()
    out := make([]mount.Mount, len(mounts))
    for i, m := range mounts {
        out[i] = mount.Mount{
            Type:     mount.TypeBind,
            Source:   m.Source,
            Target:   m.Target,
            ReadOnly: m.ReadOnly,
        }
    }
    return out
}
Primary Mount:
Source: "/var/lib/pterodactyl/volumes/" + serverUUID
Target: "/home/container"
ReadOnly: false
Additional mounts can be defined for shared libraries or configurations.

Console I/O

Sending Commands

Commands are sent through the attached stream:
// From environment/docker/container.go:294-315
func (e *Environment) SendCommand(c string) error {
    if !e.IsAttached() {
        return errors.Wrap(ErrNotAttached, "cannot send command")
    }
    
    e.mu.RLock()
    defer e.mu.RUnlock()
    
    // Detect stop command to update state
    if e.meta.Stop.Type == "command" && c == e.meta.Stop.Value {
        e.SetState(environment.ProcessStoppingState)
    }
    
    _, err := e.stream.Conn.Write([]byte(c + "\n"))
    return errors.Wrap(err, "could not write to container stream")
}

Reading Logs

Historical logs are retrieved from Docker:
// From environment/docker/container.go:317-338
func (e *Environment) Readlog(lines int) ([]string, error) {
    r, err := e.client.ContainerLogs(context.Background(), e.Id, container.LogsOptions{
        ShowStdout: true,
        ShowStderr: true,
        Tail:       strconv.Itoa(lines),
    })
    if err != nil {
        return nil, errors.WithStack(err)
    }
    defer r.Close()
    
    var out []string
    scanner := bufio.NewScanner(r)
    for scanner.Scan() {
        out = append(out, scanner.Text())
    }
    
    return out, nil
}

Container Recreation

Containers are recreated on every start to apply configuration changes:
// From environment/docker/power.go:26-46
func (e *Environment) OnBeforeStart(ctx context.Context) error {
    // Always destroy and re-create the container
    if err := e.client.ContainerRemove(ctx, e.Id, container.RemoveOptions{
        RemoveVolumes: true,
    }); err != nil {
        if !client.IsErrNotFound(err) {
            return errors.WrapIf(err, "failed to remove container")
        }
    }
    
    // Create container with latest configuration
    if err := e.Create(); err != nil {
        return err
    }
    
    return nil
}
This ensures that environment variables, resource limits, and other settings are always current.

Next Steps

Architecture

Understand the overall system architecture

Server Lifecycle

Learn about server states and transitions

File Management

Explore filesystem operations

Build docs developers (and LLMs) love