diff --git a/shipcat.py b/shipcat.py
new file mode 100755
index 0000000000000000000000000000000000000000..80e32be5a79a979278cb13dec823a5c51a19df90
--- /dev/null
+++ b/shipcat.py
@@ -0,0 +1,227 @@
+#!/usr/bin/python3
+
+import argparse
+from dataclasses import dataclass
+import os
+from pathlib import Path
+from pwd import getpwnam
+import socket
+import subprocess
+import sys
+from typing import NoReturn, List, Optional
+
+from shipcat.config import GlobalConfig, ContainerConfig, ConfigError
+
+
+def die(msg: str) -> NoReturn:
+    print(msg, file=sys.stderr)
+    sys.exit(1)
+
+
+def progress(msg: str) -> None:
+    print(msg, file=sys.stderr, end="", flush=True)
+
+
+def load_container_config(name: str) -> ContainerConfig:
+    try:
+        cc = ContainerConfig.load(config, name)
+    except ConfigError as e:
+        die(str(e))
+
+    return cc
+
+
+def is_root() -> bool:
+    return os.geteuid() == 0
+
+
+def assert_root() -> None:
+    if not is_root():
+        die('This operation must be performed by root')
+
+
+@dataclass(frozen=True, order=True)
+class SubIdRange:
+    first: int
+    count: int
+    name: str
+
+
+class SubIds:
+    ranges: List[SubIdRange]
+
+    def __init__(self, file: str) -> None:
+        ranges = []
+        with open(file) as f:
+            for line in f:
+                name, first_str, count_str = line.rstrip().split(':')
+                ranges.append(SubIdRange(int(first_str), int(count_str), name))
+
+        ranges.sort()
+        for i in range(1, len(ranges)):
+            r, pr = ranges[i], ranges[i-1]
+            if r.first > pr.first + pr.count:
+                die(f'Inconsistent {name}: entries {r} and {pr} overlap')
+
+        self.ranges = ranges
+
+    def find_range(self, uid: int, user: str, size: int) -> Optional[SubIdRange]:
+        uid_str = str(uid)
+        for r in self.ranges:
+            if (r.name == user or r.name == uid_str) and r.count >= size:
+                return r
+        return None
+
+    def alloc_range(self, user: str, size: int) -> SubIdRange:
+        # Call at most once (does not update the list of ranges)
+        i = 100000
+        for r in self.ranges:
+            if r.first >= i + size:
+                return SubIdRange(i, size, user)
+            else:
+                i = r.first + r.count
+        return SubIdRange(i, size, user)
+
+
+def do_init(args: argparse.Namespace) -> None:
+    name = args.name
+    cc = load_container_config(name)
+    assert_root()
+
+    progress(f'User {cc.user_name}: ')
+    try:
+        pwd = getpwnam(cc.user_name)
+        progress('already exists\n')
+    except KeyError:
+        progress('creating\n')
+        subprocess.run(
+            ['adduser', '--system', '--group', '--gecos', f'Container {name}', '--disabled-password', cc.user_name],
+            check=True,
+        )
+        pwd = getpwnam(cc.user_name)
+    uid = pwd.pw_uid
+
+    progress('Subuid range: ')
+    subuids = SubIds('/etc/subuid')
+    sur = subuids.find_range(uid, cc.user_name, 65536)
+    if sur is not None:
+        progress('already exists\n')
+    else:
+        progress('allocating\n')
+        sur = subuids.alloc_range(cc.user_name, 65536)
+        subprocess.run(
+            ['usermod', '--add-subuids', f'{sur.first}-{sur.first + sur.count - 1}', cc.user_name],
+            check=True,
+        )
+
+    progress('Subgid range: ')
+    subgids = SubIds('/etc/subgid')
+    sgr = subgids.find_range(uid, cc.user_name, 65536)
+    if sgr is not None:
+        progress('already exists\n')
+    else:
+        progress('allocating\n')
+        sgr = subgids.alloc_range(cc.user_name, 65536)
+        subprocess.run(
+            ['usermod', '--add-subgids', f'{sgr.first}-{sgr.first + sgr.count - 1}', cc.user_name],
+            check=True,
+        )
+
+    progress(f'Using user {cc.user_name}, uid {uid}, subuids {sur.first}+{sur.count}, subgids {sgr.first}+{sgr.count}\n')
+
+    root_path = Path(cc.root_dir)
+    progress(f'Container directory {root_path}: ')
+    if root_path.is_dir():
+        progress('already exists\n')
+    else:
+        root_path.mkdir(mode=0o750, exist_ok=True)
+        os.chown(root_path, 0, sgr.first)
+        progress('created\n')
+
+    data_path = root_path / 'data'
+    progress(f'Data directory {data_path}: ')
+    if data_path.is_dir():
+        progress('already exists\n')
+    else:
+        data_path.mkdir(mode=0o755, exist_ok=True)
+        os.chown(data_path, sur.first, sgr.first)
+        progress('created\n')
+
+    progress('IP address: ')
+    try:
+        ip = socket.gethostbyname(name)
+    except OSError as e:
+        die(str(e))
+    progress(f'{ip}\n')
+
+
+def do_create(args: argparse.Namespace) -> None:
+    name = args.name
+    cc = load_container_config(name)
+    assert_root()
+
+    data_dir = Path(cc.root_dir) / 'data'
+    ip = socket.gethostbyname(name)
+
+    subprocess.run(
+        ['podman', 'pull', cc.image],
+        check=True,
+    )
+
+    # FIXME: If the container was running, stop it
+
+    subprocess.run(
+        ['podman', 'rm', '-if', name],
+        check=True,
+    )
+
+    subprocess.run(
+        [
+            'podman', 'create',
+            '--name', name,
+            '--conmon-pidfile', cc.pid_file,
+            '--log-driver', 'journald',
+            '--hostname', name,
+            '--volume', f'{data_dir}:/data',
+            '--network', 'static',
+            '--ip', ip,
+            '--subuidname', cc.user_name,
+            '--subgidname', cc.user_name,
+            cc.image,
+        ],
+        check=True,
+    )
+
+
+def do_start(args: argparse.Namespace) -> None:
+    pass
+
+
+parser = argparse.ArgumentParser(
+    description='FIXME',
+)
+subparsers = parser.add_subparsers(help='action to perform', dest='action', required=True, metavar='ACTION')
+
+init_parser = subparsers.add_parser('init', help='initialize a new container', description='Initialize a new container. Should be called by root.')
+init_parser.add_argument('name', help='Name of the container')
+
+create_parser = subparsers.add_parser('create', help='create a container from an image', description='Create a container from an image.')
+create_parser.add_argument('name', help='Name of the container')
+
+start_parser = subparsers.add_parser('start', help='start a container', description='Start a container')
+start_parser.add_argument('name', help='Name of the container')
+
+args = parser.parse_args()
+
+try:
+    config = GlobalConfig.load('shipcat.toml')
+except ConfigError as e:
+    print(e, file=sys.stderr)
+    sys.exit(1)
+
+if args.action == 'init':
+    do_init(args)
+elif args.action == 'create':
+    do_create(args)
+elif args.action == 'start':
+    do_start(args)
diff --git a/shipcat/config.py b/shipcat/config.py
index 6c82a1adf35787bc4feaa2ef316d4a9a0fa16405..2d34571cfe376e7e42f7ab1e796900b2918e8bcc 100644
--- a/shipcat/config.py
+++ b/shipcat/config.py
@@ -16,7 +16,7 @@ class ConfigError(RuntimeError):
 
 
 class GlobalConfig:
-    container_dir: Path
+    container_config_dir: Path
     verbosity: int
 
     @classmethod
@@ -39,7 +39,7 @@ class GlobalConfig:
 
     def parse(self, walker: Walker) -> None:
         with walker.enter_object() as w:
-            self.container_dir = Path(w['container_dir'].as_str())
+            self.container_config_dir = Path(w['container_config_dir'].as_str())
             self.verbosity = w['verbosity'].as_int(0)
 
 
@@ -50,12 +50,16 @@ class ContainerConfig:
     allowed_users: Set[int]
     allowed_groups: Set[int]
 
+    # Automatically generated
+    pid_file: str
+    user_name: str
+
     @classmethod
     def load(cls, global_config: GlobalConfig, name: str) -> 'ContainerConfig':
         if not re.fullmatch(r'[0-9A-Za-z_-]+', name):
             raise ConfigError(f'Invalid container name {name}')
 
-        path = global_config.container_dir / (name + '.toml')
+        path = global_config.container_config_dir / (name + '.toml')
         try:
             with open(path, 'rb') as f:
                 root = tomllib.load(f)
@@ -95,3 +99,6 @@ class ContainerConfig:
                 except KeyError:
                     wu.raise_error(f'Unknown group {name}')
                 self.allowed_groups.add(grp.gr_gid)
+
+        self.pid_file = f'/run/{self.container}.pid'
+        self.user_name = self.container